mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2144e6769f | ||
|
|
e6f1a6aac1 | ||
|
|
f14f4ec754 | ||
|
|
bafad077ff | ||
|
|
b038d3ceac | ||
|
|
faea02d951 | ||
|
|
6f18613f9d | ||
|
|
0c3e42b2e4 | ||
|
|
0fc8d6973b | ||
|
|
27216646c8 | ||
|
|
d501e786b8 | ||
|
|
b7463a63f5 | ||
|
|
eeec2bf83d | ||
|
|
63c2de7128 | ||
|
|
1d65bc309e | ||
|
|
88d8bed2e3 |
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# 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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
Part of [OCP](../README.md) — LAN & multi-user: server setup, client connect, API-key management, per-key quotas, anonymous access, and the deployment/security model (including the honest limits of sharing).
|
||||||
|
|
||||||
|
# LAN & multi-user
|
||||||
|
|
||||||
|
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Server (always-on device) ─────────────────────────────┐
|
||||||
|
│ Mac mini / NAS / Raspberry Pi / Desktop │
|
||||||
|
│ Claude CLI + OCP server → bound to 0.0.0.0:3456 │
|
||||||
|
└───────────────────────┬─────────────────────────────────┘
|
||||||
|
│ LAN
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
Laptop Phone/Tablet Pi / Server
|
||||||
|
(client) (browser) (client)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Setup
|
||||||
|
|
||||||
|
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
|
||||||
|
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.x it works behind `--experimental-sqlite`)
|
||||||
|
- `git`
|
||||||
|
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — install and authenticate:
|
||||||
|
```bash
|
||||||
|
npm install -g @anthropic-ai/claude-code
|
||||||
|
claude auth login # prints a URL + code — open URL on any browser, sign in, paste code back
|
||||||
|
```
|
||||||
|
Headless servers (Pi / NAS / VPS without a desktop browser): see [Headless install notes](#headless-install-notes) below.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and run setup
|
||||||
|
git clone https://github.com/dtzp555-max/ocp.git
|
||||||
|
cd ocp
|
||||||
|
node setup.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The setup script will:
|
||||||
|
1. Verify Claude CLI is installed and authenticated
|
||||||
|
2. Start the proxy on port 3456
|
||||||
|
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||||
|
|
||||||
|
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this document assumes `ocp` is on your PATH.
|
||||||
|
|
||||||
|
> **Cloud/Linux servers:** If `ocp: command not found` after a cloud install, the binary isn't in PATH. Full path in that layout: `~/.openclaw/projects/ocp/ocp`
|
||||||
|
|
||||||
|
**Single-machine use** — just set your IDE to use the proxy:
|
||||||
|
```bash
|
||||||
|
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):
|
||||||
|
```bash
|
||||||
|
# Enable LAN access with per-user auth (recommended)
|
||||||
|
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create API keys for each person/device:
|
||||||
|
```bash
|
||||||
|
# Generate a strong admin key (one-time — save it for later key management):
|
||||||
|
export OCP_ADMIN_KEY=$(openssl rand -base64 32)
|
||||||
|
# Add the same export line to ~/.zshrc or ~/.bashrc so it persists.
|
||||||
|
|
||||||
|
ocp keys add wife-laptop
|
||||||
|
# ✓ Key created for "wife-laptop"
|
||||||
|
# API Key: ocp_example12345abcde...
|
||||||
|
# Copy this key now — you won't see it again.
|
||||||
|
|
||||||
|
ocp keys add son-ipad
|
||||||
|
ocp keys add pi-server
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Headless install notes
|
||||||
|
|
||||||
|
OCP is designed for always-on devices that often don't have a desktop browser — Mac mini, NAS, Raspberry Pi, cloud VPS. The Claude CLI auth flow still works headless:
|
||||||
|
|
||||||
|
**Option 1 — interactive OAuth over SSH (one-shot).** `claude auth login` prints a URL + 8-digit code. Open the URL on **any** device with a browser (your laptop, phone), sign in to your Anthropic account, and paste the code back into the SSH session. No browser needed on the server itself.
|
||||||
|
|
||||||
|
**Option 2 — long-lived token (auth once, no re-prompts).**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude setup-token # subscription-backed long-lived token
|
||||||
|
```
|
||||||
|
|
||||||
|
Same Claude subscription as Option 1; the token is stored in Claude CLI's normal config location. Useful when you'd rather not redo the OAuth flow when sessions expire.
|
||||||
|
|
||||||
|
If `claude auth login` errors out with something like `cannot open browser`, you've hit the same case — fall back to either option above.
|
||||||
|
|
||||||
|
## AI-assisted install prompts
|
||||||
|
|
||||||
|
If you've got Claude Code, Cursor, or any other AI coding assistant on this machine, you can copy-paste one of these prompts and let the AI walk through the install for you. Each prompt pins the AI to the right README section, names the verification step, and forbids silent retries — so you stay in the loop.
|
||||||
|
|
||||||
|
**Single-machine use** — install OCP for IDEs on this same machine only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
I want to install OCP on this machine to use my Claude Pro/Max subscription
|
||||||
|
as an OpenAI-compatible API for local IDEs.
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
|
§Quickstart (single-machine install):
|
||||||
|
|
||||||
|
1. Verify prerequisites: macOS or Linux, Node.js 22.5+, git, Claude CLI
|
||||||
|
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).
|
||||||
|
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.
|
||||||
|
|
||||||
|
Before each step, tell me what you'll run and wait for confirmation.
|
||||||
|
On any error, diagnose first — don't auto-retry.
|
||||||
|
```
|
||||||
|
|
||||||
|
**LAN mode (server)** — install OCP as a server so your own devices on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people):
|
||||||
|
|
||||||
|
```text
|
||||||
|
I want to install OCP on this device as a LAN server so my own devices on the
|
||||||
|
network can reach my Claude Pro/Max subscription through a local
|
||||||
|
OpenAI-compatible endpoint.
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
|
||||||
|
"Server Setup" → "LAN mode" path:
|
||||||
|
|
||||||
|
1. Verify prerequisites: macOS or Linux (Windows not supported), Node.js
|
||||||
|
22.5+, git, Claude CLI installed and authenticated.
|
||||||
|
2. Generate a strong admin key with `openssl rand -base64 32`. Save it —
|
||||||
|
I'll need it to manage per-user keys later.
|
||||||
|
3. git clone https://github.com/dtzp555-max/ocp.git && cd ocp
|
||||||
|
4. Run `node setup.mjs --bind 0.0.0.0 --auth-mode multi`.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Tell me each step before running it. On error, diagnose before retrying.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client connect** — configure this device to use an existing OCP server on your LAN:
|
||||||
|
|
||||||
|
```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).
|
||||||
|
|
||||||
|
Server IP: <SERVER_IP>
|
||||||
|
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
|
||||||
|
|
||||||
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
|
||||||
|
"Client Setup" path:
|
||||||
|
|
||||||
|
1. Download ocp-connect:
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Setup
|
||||||
|
|
||||||
|
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
||||||
|
>
|
||||||
|
> **Find the server's LAN IP** by running `ocp lan` on the server machine — it prints both the IP and a ready-to-share connect command.
|
||||||
|
|
||||||
|
**One-command setup** — download the lightweight `ocp-connect` script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||||
|
chmod +x ocp-connect
|
||||||
|
./ocp-connect <server-ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ocp-connect <server-ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
If the server requires a key, pass it with `--key`:
|
||||||
|
```bash
|
||||||
|
./ocp-connect <server-ip> --key <your-api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
Or as a one-liner (no file saved):
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
$ ./ocp-connect 192.168.1.100
|
||||||
|
|
||||||
|
OCP Connect v1.3.0
|
||||||
|
─────────────────────────────────────
|
||||||
|
Remote: http://192.168.1.100:3456
|
||||||
|
|
||||||
|
Checking connectivity...
|
||||||
|
✓ Connected
|
||||||
|
|
||||||
|
Remote OCP v3.11.0 (auth: multi)
|
||||||
|
|
||||||
|
ⓘ Using server-advertised anonymous key: ocp_publ...n_v1
|
||||||
|
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||||
|
|
||||||
|
Testing API access...
|
||||||
|
✓ API accessible (6 models available)
|
||||||
|
|
||||||
|
Shell config:
|
||||||
|
✓ .bashrc
|
||||||
|
✓ .zshrc
|
||||||
|
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
|
||||||
|
|
||||||
|
System-level (launchctl):
|
||||||
|
✓ OPENAI_BASE_URL set for GUI apps and daemons
|
||||||
|
|
||||||
|
IDE Configuration
|
||||||
|
─────────────────────────────────────
|
||||||
|
Detected: OpenClaw (~/.openclaw/openclaw.json)
|
||||||
|
|
||||||
|
Configure OpenClaw to use this OCP? [Y/n] y
|
||||||
|
Provider name (models show as <name>/model-id) [ocp]: ocp
|
||||||
|
|
||||||
|
How should OCP models be configured?
|
||||||
|
1) Primary — use OCP by default, keep existing models as backup
|
||||||
|
2) Backup — keep current primary, add OCP as additional option
|
||||||
|
|
||||||
|
Choice [1]: 1
|
||||||
|
|
||||||
|
Writing OpenClaw config...
|
||||||
|
✓ Per-agent auth profile seeded (2):
|
||||||
|
• ~/.openclaw/agents/main/agent/auth-profiles.json
|
||||||
|
• ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
|
||||||
|
✓ OpenClaw configured
|
||||||
|
Provider: ocp
|
||||||
|
Models:
|
||||||
|
• 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)
|
||||||
|
|
||||||
|
Restart OpenClaw to apply: openclaw gateway restart
|
||||||
|
|
||||||
|
Running smoke test...
|
||||||
|
✓ Smoke test passed: OK
|
||||||
|
Note: smoke test only verifies OCP is reachable and the key is valid.
|
||||||
|
It does not verify your IDE/agent end-to-end. To verify OpenClaw works,
|
||||||
|
restart it (`openclaw gateway restart`) and send a test message to your bot.
|
||||||
|
|
||||||
|
Done. Reload your shell to apply:
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
The script automatically:
|
||||||
|
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
||||||
|
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
||||||
|
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
|
||||||
|
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
|
||||||
|
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
|
||||||
|
|
||||||
|
On macOS, `launchctl setenv` vars reset on reboot — re-run `ocp-connect` after restart.
|
||||||
|
|
||||||
|
**Manual setup** — if you prefer not to use the script:
|
||||||
|
```bash
|
||||||
|
export OPENAI_BASE_URL=http://<server-ip>:3456/v1
|
||||||
|
export OPENAI_API_KEY=ocp_<your-key>
|
||||||
|
```
|
||||||
|
Add these lines to `~/.bashrc` or `~/.zshrc` to persist across sessions.
|
||||||
|
|
||||||
|
## Monitoring (Server-side)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Per-key usage stats
|
||||||
|
ocp usage --by-key
|
||||||
|
# Key Reqs OK Err Avg Time
|
||||||
|
# wife-laptop 5 5 0 8.0s
|
||||||
|
# son-ipad 3 3 0 6.2s
|
||||||
|
|
||||||
|
# Manage keys
|
||||||
|
ocp keys # List all keys
|
||||||
|
ocp keys revoke son-ipad # Revoke a key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web Dashboard:** Open `http://<server-ip>:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Auth Modes
|
||||||
|
|
||||||
|
| Mode | Env | Use Case |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
||||||
|
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||||
|
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys for usage tracking + quotas (trusted users only — see Deployment model below) |
|
||||||
|
|
||||||
|
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
|
||||||
|
|
||||||
|
## Deployment model & security (read this)
|
||||||
|
|
||||||
|
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
|
||||||
|
|
||||||
|
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
|
||||||
|
|
||||||
|
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
|
||||||
|
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
|
||||||
|
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
|
||||||
|
- **Account terms and ToS — read before sharing with others.** Claude Pro/Max are *per-user* accounts. Pooling a single subscription across **multiple distinct people** may violate Anthropic's Consumer Terms of Service and risk account suspension by the abuse classifier. The defensible framing is **"one person, your own devices"** — sharing with friends or a team is not. OCP does not change your account terms, and whether any particular sharing setup complies with the ToS is the account holder's responsibility. Review Anthropic's Usage Policy before extending access to other people.
|
||||||
|
|
||||||
|
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](tui-mode.md#subscription-pool-tui-mode).)
|
||||||
|
|
||||||
|
## Anonymous Access (optional)
|
||||||
|
|
||||||
|
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
||||||
|
|
||||||
|
**Enable**:
|
||||||
|
|
||||||
|
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||||
|
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||||
|
```
|
||||||
|
|
||||||
|
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
||||||
|
|
||||||
|
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||||
|
|
||||||
|
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||||
|
|
||||||
|
**Not a secret**: because `/health` is an unauthenticated endpoint, the anonymous key is **publicly readable** by anyone who can reach the server. That is intentional — the key exists so clients can self-configure without out-of-band coordination. Treat it as a convenience handle, not as an access credential.
|
||||||
|
|
||||||
|
## Per-Key Quota (Budget Control)
|
||||||
|
|
||||||
|
Prevent any single user from exhausting your subscription. Set daily, weekly, or monthly request limits per API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set a daily limit of 50 requests for a key
|
||||||
|
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
|
||||||
|
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
|
||||||
|
-d '{"daily": 50}'
|
||||||
|
|
||||||
|
# Set multiple limits at once
|
||||||
|
curl -X PATCH http://127.0.0.1:3456/api/keys/son-ipad/quota \
|
||||||
|
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
|
||||||
|
-d '{"daily": 20, "weekly": 100}'
|
||||||
|
|
||||||
|
# Check current quota + usage
|
||||||
|
curl http://127.0.0.1:3456/api/keys/wife-laptop/quota
|
||||||
|
# → { "daily": { "limit": 50, "used": 12 }, "weekly": { "limit": null, "used": 34 }, ... }
|
||||||
|
|
||||||
|
# Remove a limit (set to null)
|
||||||
|
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
|
||||||
|
-d '{"daily": null}'
|
||||||
|
```
|
||||||
|
|
||||||
|
When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Quota exceeded: 50/50 requests (daily). Resets 6h 12m.",
|
||||||
|
"type": "quota_exceeded",
|
||||||
|
"quota": { "period": "daily", "limit": 50, "used": 50, "resetsIn": "6h 12m" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `null` = unlimited (default for all keys)
|
||||||
|
- Only successful requests count toward quota
|
||||||
|
- Admin and anonymous users are never subject to quotas
|
||||||
|
- PATCH is a partial update — omitted fields are left unchanged
|
||||||
|
|
||||||
|
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||||
|
- `ocp usage` shows how much quota remains
|
||||||
|
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
||||||
|
- Admin key is required for key management API endpoints
|
||||||
|
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
||||||
|
- File modes for `~/.ocp` (0700), `admin-key` + `ocp.db` (0600) are auto-tightened at server startup as of v3.14.0
|
||||||
@@ -8,7 +8,7 @@ The billing classifier reading `cli` is **necessary but NOT sufficient** proof.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup in README](../../README.md#enabling-tui-mode-opt-in))
|
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup](../tui-mode.md#enabling-tui-mode-opt-in))
|
||||||
- `tmux` installed on the host
|
- `tmux` installed on the host
|
||||||
- No other OCP traffic during the canary (quiesce — see below)
|
- No other OCP traffic during the canary (quiesce — see below)
|
||||||
- Access to your Anthropic account billing page (manual step — see below)
|
- Access to your Anthropic account billing page (manual step — see below)
|
||||||
@@ -148,4 +148,4 @@ Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's ow
|
|||||||
|
|
||||||
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
|
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
|
||||||
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
|
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
|
||||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
- [Subscription-pool (TUI) mode](../tui-mode.md#subscription-pool-tui-mode)
|
||||||
|
|||||||
@@ -176,5 +176,5 @@ If you want to continue using OCP without TUI-mode after 2026-06-15, budget for
|
|||||||
|
|
||||||
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
|
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
|
||||||
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
|
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
|
||||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
- [Subscription-pool (TUI) mode](../tui-mode.md#subscription-pool-tui-mode)
|
||||||
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
|
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
Part of [OCP](../README.md) — full troubleshooting manual. The README keeps a slim version with the most common issues and the one-time bootstrap quirks; everything else lives here.
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Run `ocp doctor` and follow its `next_action`. Tell me if you hit
|
||||||
|
anything that needs human input.
|
||||||
|
```
|
||||||
|
|
||||||
|
The doctor produces a JSON `next_action` with `ai_executable[]` (commands
|
||||||
|
the agent runs verbatim) and `human_required[]` (steps that need you,
|
||||||
|
typically just OAuth).
|
||||||
|
|
||||||
|
## Manual debugging
|
||||||
|
|
||||||
|
### Setup fails with "claude: command not found"
|
||||||
|
|
||||||
|
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
|
||||||
|
|
||||||
|
### Setup fails with "EADDRINUSE: port 3456 already in use"
|
||||||
|
|
||||||
|
Something else is already bound to port 3456 — usually an old OCP instance. Check what:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
(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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install node # macOS
|
||||||
|
# Linux: see https://nodejs.org/en/download for current install commands
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm with `node --version` (should be ≥ v22.5).
|
||||||
|
|
||||||
|
### Requests fail or agents stuck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clear sessions and restart
|
||||||
|
ocp clear
|
||||||
|
ocp restart
|
||||||
|
|
||||||
|
# If using OpenClaw gateway
|
||||||
|
openclaw gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Env var change (e.g. `CLAUDE_BIND`, `CLAUDE_CODE_OAUTH_TOKEN`) doesn't take effect after restart
|
||||||
|
|
||||||
|
On **macOS**, `ocp restart` does a full `launchctl bootout` + `bootstrap` of the agent, which **re-reads the plist `EnvironmentVariables`** — so an env change you made (in `~/Library/LaunchAgents/dev.ocp.proxy.plist`) actually takes effect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp restart
|
||||||
|
```
|
||||||
|
|
||||||
|
This is deliberate: the older `launchctl kickstart -k` only re-execs the process and **reuses launchd's cached environment**, so plist env edits would be silently ignored. If you ever restart the agent by hand, use bootout+bootstrap, not `kickstart -k`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null
|
||||||
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the new value reached the running process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ps -E -p "$(launchctl print gui/$(id -u)/dev.ocp.proxy 2>/dev/null | awk '/pid =/{print $3}')" | tr ' ' '\n' | grep CLAUDE_
|
||||||
|
```
|
||||||
|
|
||||||
|
On **Linux**, `systemctl --user restart` already re-reads the unit's `EnvironmentFile`, so no special handling is needed.
|
||||||
|
|
||||||
|
### Usage shows "unknown"
|
||||||
|
|
||||||
|
Usually caused by an expired Claude CLI session. Fix:
|
||||||
|
```bash
|
||||||
|
claude auth login
|
||||||
|
ocp restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Startup log warns "OpenClaw registry out of sync"
|
||||||
|
|
||||||
|
On boot, OCP compares OpenClaw's registered models against [`models.json`](../models.json) and warns if they drift. Cause: someone (or an OpenClaw upgrade) modified `~/.openclaw/openclaw.json` and removed entries OCP expects. Fix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ~/ocp/scripts/sync-openclaw.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
This is read-only at startup; the warning never blocks the gateway from running.
|
||||||
|
|
||||||
|
### A TUI session vanished right after upgrading OCP
|
||||||
|
|
||||||
|
If you ran a pre-3.21.1 OCP instance and a post-3.21.1 instance on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance — restart the affected session (`ocp restart` or re-run your TUI turn) and it will come back under the new instance's port-scoped naming.
|
||||||
|
|
||||||
|
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
|
||||||
|
|
||||||
|
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ~/ocp/scripts/sync-openclaw.mjs
|
||||||
|
openclaw gateway restart # so OpenClaw re-reads the config
|
||||||
|
```
|
||||||
|
|
||||||
|
Future `ocp update` invocations sync automatically.
|
||||||
|
|
||||||
|
<a id="tui-401"></a>
|
||||||
|
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
|
||||||
|
|
||||||
|
A long-running TUI-mode host can get stuck returning a permanent 401 (`Please run /login · API Error: 401`) that re-login cannot fix.
|
||||||
|
|
||||||
|
**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
|
||||||
|
|
||||||
|
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux (systemd): confirm the token is in the service env
|
||||||
|
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
|
||||||
|
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
|
||||||
|
|
||||||
|
See [Subscription-pool (TUI) mode](tui-mode.md#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
Part of [OCP](../README.md) — subscription-pool (TUI) mode: serve requests through interactive `claude` so they bill the Pro/Max subscription pool instead of the metered Agent SDK path.
|
||||||
|
|
||||||
|
# Subscription-pool (TUI) mode
|
||||||
|
|
||||||
|
> **SECURITY — read before enabling.**
|
||||||
|
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
The announced routing keys `claude` invocations by `cc_entrypoint`:
|
||||||
|
|
||||||
|
| Launch method | `cc_entrypoint` | Billing pool (announced regime, currently paused) |
|
||||||
|
|---------------|-----------------|-------------|
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
<a id="tui-entrypoint"></a>
|
||||||
|
## Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
|
||||||
|
|
||||||
|
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
|
||||||
|
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
|
||||||
|
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
|
||||||
|
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
|
||||||
|
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
|
||||||
|
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
|
||||||
|
|
||||||
|
## Enabling TUI-mode (opt-in)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites
|
||||||
|
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
||||||
|
# tmux must be installed: brew install tmux / apt install tmux
|
||||||
|
|
||||||
|
# Enable
|
||||||
|
export CLAUDE_TUI_MODE=true
|
||||||
|
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
|
||||||
|
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
|
||||||
|
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
|
||||||
|
# is the only credential and is authoritative. This both stops a stale credentials.json
|
||||||
|
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
|
||||||
|
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
|
||||||
|
# See the auth note below + ADR 0007 PR-D.
|
||||||
|
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||||
|
# Optionally tune:
|
||||||
|
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||||
|
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||||
|
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
||||||
|
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
|
||||||
|
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||||
|
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
|
||||||
|
```
|
||||||
|
|
||||||
|
## What changes / what doesn't
|
||||||
|
|
||||||
|
- **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: [`plans/2026-07-13-tui-latency/streaming-spike.md`](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. With the env token set and `OCP_TUI_HOME` unset, OCP 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. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
||||||
|
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||||
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
||||||
|
- **Optional warm pane pool (`OCP_TUI_POOL_SIZE`, default off).** Pre-boots panes so a request skips the cold boot — measured p50 `10.17s` → `6.00s` (−41%). Pooled panes are **single-use** (one turn, then killed and replaced in the background), each carrying its own fresh `--session-id`, so one session still means one exchange and no earlier-turn text can leak into a later answer. They are named `ocp-tui-<port>-p<hex>` and coexist with the reaper by design: the sweep **drains the pool first**, then reaps (so `kill-server` still flushes `<defunct>` zombies), then the pool refills in the background. Drain→reap→resume is synchronous, so no request can land mid-sweep; a request arriving while the pool is still re-booting simply misses it and cold-boots. A live pooled pane is never reaped — **including one that is still booting**, whose tmux session already exists — while an *orphaned* one (left by a previous process generation) still is.
|
||||||
|
|
||||||
|
## ⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable
|
||||||
|
|
||||||
|
**TUI mode cannot serve real-time or interactive-latency consumers.** This is a hard property of the
|
||||||
|
path, stated plainly so you can rule it out before building on it:
|
||||||
|
|
||||||
|
| | measured |
|
||||||
|
|---|---|
|
||||||
|
| **TTFT floor (first token)** | **≈ 6 s** — immovable |
|
||||||
|
| cold boot → input bar ready | ~1 s (per request; not the bottleneck) |
|
||||||
|
| OCP's own overhead above the CLI | ~4 s (n=1 same-turn decomposition) |
|
||||||
|
| direct Anthropic API, same prompt (for scale) | 0.84–1.64 s |
|
||||||
|
|
||||||
|
The ~6 s floor is the `claude` CLI itself: it always injects the full Claude Code system prompt plus
|
||||||
|
its tool definitions before your prompt, on every turn, no matter what you ask. No flag removes it
|
||||||
|
(`--exclude-dynamic-system-prompt-sections` was measured: **no effect** on the floor). Extended
|
||||||
|
thinking is *not* the cause — `OCP_TUI_EFFORT` already defaults to `low`, which is what cuts a
|
||||||
|
formerly-inherited `xhigh` down to this floor and collapses its variance.
|
||||||
|
|
||||||
|
On top of the floor you pay the model's generation time (a function of output length). Progressive
|
||||||
|
output is not wired up **yet** (see "No real token streaming" above — it is achievable and planned),
|
||||||
|
so today a turn returns as one blob once generation completes. Note that streaming, when it lands,
|
||||||
|
will move the *first* byte earlier — it does **not** shorten the turn, and a consumer that needs the
|
||||||
|
complete answer gains nothing from it.
|
||||||
|
|
||||||
|
**Use TUI mode for**: batch, background, and latency-insensitive work where the subscription pool is
|
||||||
|
the point. **Do not use it for**: anything a person is waiting on interactively, or any consumer with
|
||||||
|
a sub-5-second budget. Full measurements and methodology:
|
||||||
|
[`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/).
|
||||||
|
|
||||||
|
## 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):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"tui": {
|
||||||
|
"enabled": true, // CLAUDE_TUI_MODE === "true"
|
||||||
|
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
|
||||||
|
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
|
||||||
|
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
|
||||||
|
"inflight": 1, // TUI turns running right now
|
||||||
|
"queued": 0, // TUI turns waiting for a concurrency slot
|
||||||
|
"maxConcurrent": 2, // OCP_TUI_MAX_CONCURRENT
|
||||||
|
"pool": { // warm pane pool — null when OCP_TUI_POOL_SIZE=0 (the default)
|
||||||
|
"size": 2, // target warm panes (OCP_TUI_POOL_SIZE)
|
||||||
|
"warm": 2, // panes ready right now — each is a LIVE idle claude process
|
||||||
|
"booting": 0, // replacement panes currently pre-booting
|
||||||
|
"model": "claude-sonnet-4-6", // the model being warmed (the most recently requested one)
|
||||||
|
"hits": 12, // requests served by a warm pane
|
||||||
|
"misses": 1, // requests that fell back to the cold boot (the 1st is always one)
|
||||||
|
"boots": 14, // panes successfully pre-booted
|
||||||
|
"bootFailures": 0, // pre-boots that genuinely never reached the input bar — WATCH this
|
||||||
|
"cancelled": 4, // in-flight boots OCP killed on purpose (drain / model switch) — not faults
|
||||||
|
"dropped": 8 // panes discarded unused (drain sweep / expired / unhealthy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
|
||||||
|
|
||||||
|
With the pool on, `hits` / `misses` is the hit rate (a steady single-model consumer should sit near 100% after the first request), and `warm` is your standing idle-process cost. A climbing `bootFailures` means panes are not reaching their input bar — the pool then degrades safely to the cold path, but latency reverts to the un-pooled numbers. `cancelled` counts boots OCP killed *on purpose* (a drain, a model switch) and is **not** a fault signal — do not alert on it. A steadily climbing `dropped` is likewise normal: the 15-min reap sweep drains and re-boots the pool on every tick so `kill-server` can still flush `<defunct>` zombies.
|
||||||
|
|
||||||
|
## Kill-switch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unset CLAUDE_TUI_MODE
|
||||||
|
# restart OCP
|
||||||
|
```
|
||||||
|
|
||||||
|
The stream-json path is restored immediately. No other change is needed.
|
||||||
|
|
||||||
|
## Operator checklist for the (paused) billing split
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
- **[Flip/rollback runbook](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](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).
|
||||||
|
|
||||||
|
## Architecture and design decisions
|
||||||
|
|
||||||
|
See [`adr/0007-tui-interactive-mode.md`](adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
||||||
|
|
||||||
|
## TUI-mode environment variables
|
||||||
|
|
||||||
|
The README [Environment Variables](../README.md#environment-variables) table lists these as one-line pointers; the full behaviour of each lives here.
|
||||||
|
|
||||||
|
<a id="ocp-tui-stream"></a>
|
||||||
|
### `OCP_TUI_STREAM` — real SSE streaming (opt-in)
|
||||||
|
|
||||||
|
`OCP_TUI_STREAM` default `0` (off). 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).
|
||||||
|
|
||||||
|
Two related streaming knobs:
|
||||||
|
|
||||||
|
- **`OCP_TUI_STREAM_DIR`** (default `$HOME/.ocp-tui/stream`) — 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`** (default `100`) — interval at which OCP drains the delta sink. The hook fires at block granularity (seconds apart), so a finer poll buys nothing.
|
||||||
|
|
||||||
|
<a id="ocp-tui-stream-holdback"></a>
|
||||||
|
### `OCP_TUI_STREAM_HOLDBACK`
|
||||||
|
|
||||||
|
`OCP_TUI_STREAM_HOLDBACK` default `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 above. Answers shorter than the holdback are simply delivered whole at end-of-turn, exactly as the buffered path does.
|
||||||
|
|
||||||
|
<a id="ocp-tui-pool-size"></a>
|
||||||
|
### `OCP_TUI_POOL_SIZE` — warm pane pool
|
||||||
|
|
||||||
|
`OCP_TUI_POOL_SIZE` default `0` (off). 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 [`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/).
|
||||||
|
|
||||||
|
<a id="ocp-tui-full-tools"></a>
|
||||||
|
### `OCP_TUI_FULL_TOOLS` — full tool surface (single-user only)
|
||||||
|
|
||||||
|
`OCP_TUI_FULL_TOOLS` default *(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 ADR 0007.
|
||||||
|
|
||||||
|
<a id="tui-other-vars"></a>
|
||||||
|
### Other TUI-mode variables
|
||||||
|
|
||||||
|
- **`OCP_TUI_MAX_CONCURRENT`** (default `2`) — 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_ENTRYPOINT`** (default `cli`) — 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 the "Billing-classifier labeling" section above and ADR 0007.
|
||||||
|
- **`OCP_TUI_EFFORT`** (default `low`) — 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 [`plans/2026-07-13-tui-latency/`](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_HOME`** (default *(auto)*) — `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. If you previously set this to the real home (or any home containing a `credentials.json`) and hit a permanent 401, unset it — see [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401).
|
||||||
|
- **`CLAUDE_TUI_WALLCLOCK_MS`** (default `120000`) — Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns.
|
||||||
|
- **`OCP_TUI_CWD`** (default `$HOME/.ocp-tui/work`) — Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically.
|
||||||
|
- **`CLAUDE_CODE_OAUTH_TOKEN`** — the recommended TUI credential; when set (and `OCP_TUI_HOME` unset) it selects the credential-isolated home. Full precedence and the 401 root cause it prevents are in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401).
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
Part of [OCP](../README.md) — the full upgrade manual (`ocp update` paths, manual flags, rollback, and OpenClaw auto-sync). The README keeps a short stub with the one-liner.
|
||||||
|
|
||||||
|
# Upgrading
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Upgrade my OCP. Run `ocp update` and follow whatever it says.
|
||||||
|
If it tells me to run `claude auth login`, I'll do that.
|
||||||
|
```
|
||||||
|
|
||||||
|
What `ocp update` does:
|
||||||
|
|
||||||
|
- **Patch bump** (e.g. `v3.21.0 → v3.21.1`):
|
||||||
|
light path (git pull + npm install + restart).
|
||||||
|
- **Cross-minor** (e.g. `v3.18 → v3.22`):
|
||||||
|
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):
|
||||||
|
fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to
|
||||||
|
migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is
|
||||||
|
preserved; you do not need to re-OAuth unless your token expired
|
||||||
|
separately.
|
||||||
|
|
||||||
|
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never
|
||||||
|
auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once
|
||||||
|
you're confident the upgrade is stable.
|
||||||
|
|
||||||
|
## Manual upgrade — same command, no AI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp update # smart-pick path
|
||||||
|
ocp update --check # show available updates, don't apply
|
||||||
|
ocp update --dry-run # preview plan
|
||||||
|
ocp update --target v3.13.0 # pin a specific version
|
||||||
|
ocp update --rollback --yes # restore most recent snapshot (--yes confirms)
|
||||||
|
ocp update --rollback --list # list snapshots, no mutation
|
||||||
|
ocp update --rollback --dry-run # preview rollback plan
|
||||||
|
```
|
||||||
|
|
||||||
|
## When upgrade fails
|
||||||
|
|
||||||
|
`ocp update` prints a recovery line on failure. To restore from the snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp update --rollback --yes # --yes confirms the destructive restore
|
||||||
|
ocp doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ocp doctor` still reports problems after rollback, open a GitHub issue
|
||||||
|
with the snapshot path and the doctor JSON output (`ocp doctor --json`).
|
||||||
|
|
||||||
|
## OpenClaw Auto-Sync (v3.11.0+)
|
||||||
|
|
||||||
|
Whenever the model list in [`models.json`](../models.json) changes, `ocp update` automatically reconciles your OpenClaw config so the model dropdown stays in sync — no more "I upgraded OCP but my Telegram bot still shows the old models" surprises.
|
||||||
|
|
||||||
|
**What gets synced** (and only this — all other config keys are preserved):
|
||||||
|
- `models.providers."claude-local".models` in `~/.openclaw/openclaw.json`
|
||||||
|
- `agents.defaults.models["claude-local/*"]` aliases
|
||||||
|
|
||||||
|
**Safety**:
|
||||||
|
- Timestamped backup written before every change: `~/.openclaw/openclaw.json.bak.<ms>`
|
||||||
|
- Idempotent — already-in-sync runs are a no-op (no backup, no rewrite)
|
||||||
|
- Non-fatal — sync failure does NOT abort `ocp update`; `/v1/models` still works
|
||||||
|
- Skips silently if OpenClaw is not installed (`~/.openclaw/openclaw.json` missing)
|
||||||
|
|
||||||
|
**Manual trigger** (e.g. after fixing a hand-edited config, or for the one-time v3.10.0→v3.11.0 bootstrap quirk):
|
||||||
|
```bash
|
||||||
|
node ~/ocp/scripts/sync-openclaw.mjs
|
||||||
|
node ~/ocp/scripts/sync-openclaw.mjs --quiet # silent unless changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opt-out**: `ocp update` only invokes the sync if `node` and `scripts/sync-openclaw.mjs` are both present. Removing the script disables auto-sync; the rest of `ocp update` still works.
|
||||||
|
|
||||||
|
**One-time bootstrap caveat (v3.10.0 → v3.11.0 only)**: the first `ocp update` to v3.11.0 runs the *old* `cmd_update` already loaded into your shell, so the new sync hook does NOT fire on this single jump. Run `node ~/ocp/scripts/sync-openclaw.mjs` once manually. Every future update from v3.11.0+ syncs automatically. (Also captured in the README Troubleshooting section as a bootstrap quirk.)
|
||||||
|
|
||||||
|
**Other IDEs** (Cline / Aider / Cursor / opencode) query `/v1/models` live, so they pick up new models on the next request — no sync needed. Continue.dev users edit their own `config.json` model id manually.
|
||||||
@@ -24,12 +24,22 @@ import { homedir } from "node:os";
|
|||||||
// The override is gated on NODE_ENV === "test", and that gate is the ACTUAL guard. An earlier
|
// 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
|
// 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
|
// plus a comment — which is precisely the failure mode this whole change exists to indict (a
|
||||||
// comment describing an intention that nothing enforces). A production server runs without
|
// comment describing an intention that nothing enforces). The two-key gate means NEITHER var
|
||||||
// NODE_ENV, so it CANNOT honor the override, however the variable got into its environment
|
// alone does anything: a stray OCP_DIR_OVERRIDE with no NODE_ENV is inert, and NODE_ENV=test with
|
||||||
// (`ocp start`'s nohup fallback inherits the invoking shell's env — a maintainer who exported
|
// no override just resolves the default dir.
|
||||||
// this while debugging and then started the server would otherwise get a server silently
|
//
|
||||||
// authenticating against an empty key store: in AUTH_MODE=multi, a total auth outage, with
|
// This gate does NOT, by itself, prove a production daemon can't be redirected — an earlier
|
||||||
// nothing logged and nothing on /health to show it).
|
// 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() {
|
function resolveOcpDir() {
|
||||||
const override = process.env.NODE_ENV === "test" ? process.env.OCP_DIR_OVERRIDE : null;
|
const override = process.env.NODE_ENV === "test" ? process.env.OCP_DIR_OVERRIDE : null;
|
||||||
const dir = override || join(homedir(), ".ocp");
|
const dir = override || join(homedir(), ".ocp");
|
||||||
@@ -345,6 +355,11 @@ export function cacheHash(model, messages, opts = {}) {
|
|||||||
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
||||||
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
||||||
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
|
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
|
||||||
|
// #176: fold the server's boot-config epoch into the key, so a config change that shapes
|
||||||
|
// answers (operator system prompt, wrapper text, allowed tools, NO_CONTEXT) invalidates
|
||||||
|
// the persistent cache instead of serving answers composed under the old config. Callers
|
||||||
|
// that omit it (older paths, tests) hash byte-identically to before.
|
||||||
|
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
h.update(m.role || "");
|
h.update(m.role || "");
|
||||||
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// lib/prompt.mjs — pure operator-append step for the system prompt.
|
||||||
|
//
|
||||||
|
// Extracted so the rule is unit-testable (the suite never imports server.mjs — it
|
||||||
|
// boots a listener). server.mjs composes wrapper + client system messages exactly as
|
||||||
|
// before, then passes the result through this. With CLAUDE_SYSTEM_PROMPT unset the
|
||||||
|
// return is the INPUT STRING UNCHANGED — the default path stays byte-for-byte
|
||||||
|
// identical, which is the repo's bar for touching a request-shaping function.
|
||||||
|
//
|
||||||
|
// The operator prompt goes LAST deliberately: a server-wide directive ("answer in
|
||||||
|
// Chinese") should read as the final instruction, not something a client system
|
||||||
|
// message overrides by coming later. Whitespace-only values are treated as unset —
|
||||||
|
// a stray space in a service unit's Environment= line must not inject "\n\n " into
|
||||||
|
// every request.
|
||||||
|
export function appendOperatorPrompt(base, operatorAppend) {
|
||||||
|
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
|
||||||
|
return op ? `${base}\n\n${op}` : base;
|
||||||
|
}
|
||||||
@@ -45,6 +45,21 @@ import { detectTuiUpstreamError } from "./transcript.mjs";
|
|||||||
// Default holdback before the first byte is released to the client. See TuiDeltaAssembler.
|
// Default holdback before the first byte is released to the client. See TuiDeltaAssembler.
|
||||||
export const DEFAULT_HOLDBACK_CHARS = 100;
|
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`).
|
// 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.
|
// - `printf` is a shell BUILTIN in sh/dash/bash, so the newline costs no fork.
|
||||||
|
|||||||
+9
-1
@@ -26,6 +26,14 @@
|
|||||||
"contextWindow": 200000,
|
"contextWindow": 200000,
|
||||||
"maxTokens": 16384
|
"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",
|
"id": "claude-sonnet-4-6",
|
||||||
"displayName": "Claude Sonnet 4.6",
|
"displayName": "Claude Sonnet 4.6",
|
||||||
@@ -45,7 +53,7 @@
|
|||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"opus": "claude-opus-4-8",
|
"opus": "claude-opus-4-8",
|
||||||
"sonnet": "claude-sonnet-4-6",
|
"sonnet": "claude-sonnet-5",
|
||||||
"haiku": "claude-haiku-4-5-20251001"
|
"haiku": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
"legacyAliases": {
|
"legacyAliases": {
|
||||||
|
|||||||
@@ -622,7 +622,12 @@ cmd_restart() {
|
|||||||
self_r="${BASH_SOURCE[0]}"
|
self_r="${BASH_SOURCE[0]}"
|
||||||
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
||||||
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
||||||
DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
# 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 &
|
||||||
fi
|
fi
|
||||||
sleep 3
|
sleep 3
|
||||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||||
|
|||||||
+21
-10
@@ -122,11 +122,17 @@ provider = {
|
|||||||
"models": []
|
"models": []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
|
# 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_meta = {
|
model_meta = {
|
||||||
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||||
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||||
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_model_meta(mid):
|
def get_model_meta(mid):
|
||||||
@@ -178,11 +184,11 @@ config.setdefault("agents", {})
|
|||||||
config["agents"].setdefault("defaults", {})
|
config["agents"].setdefault("defaults", {})
|
||||||
config["agents"]["defaults"].setdefault("models", {})
|
config["agents"]["defaults"].setdefault("models", {})
|
||||||
|
|
||||||
# Build alias map (prefix match)
|
# Build alias map (family prefix match — version-agnostic, see model_meta note)
|
||||||
alias_prefixes = {
|
alias_prefixes = {
|
||||||
"claude-opus-4": "Claude Opus",
|
"claude-opus": "Claude Opus",
|
||||||
"claude-sonnet-4": "Claude Sonnet",
|
"claude-sonnet": "Claude Sonnet",
|
||||||
"claude-haiku-4": "Claude Haiku",
|
"claude-haiku": "Claude Haiku",
|
||||||
}
|
}
|
||||||
|
|
||||||
for mid in model_ids:
|
for mid in model_ids:
|
||||||
@@ -196,8 +202,13 @@ for mid in model_ids:
|
|||||||
|
|
||||||
# Handle primary/backup
|
# Handle primary/backup
|
||||||
if priority == "1":
|
if priority == "1":
|
||||||
# OCP as primary — pick the best model (prefer sonnet for daily use)
|
# OCP as primary — pick the best model (prefer the latest 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]
|
# 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],
|
||||||
|
)
|
||||||
config["agents"]["defaults"].setdefault("model", {})
|
config["agents"]["defaults"].setdefault("model", {})
|
||||||
config["agents"]["defaults"]["model"]["primary"] = primary_model
|
config["agents"]["defaults"]["model"]["primary"] = primary_model
|
||||||
# Keep existing fallbacks
|
# Keep existing fallbacks
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.21.1",
|
"version": "3.22.1",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export async function runDoctor(opts = {}) {
|
|||||||
// of recommending a downgrade against a stale hardcoded value.
|
// of recommending a downgrade against a stale hardcoded value.
|
||||||
let latestVersion = opts.mockLatest;
|
let latestVersion = opts.mockLatest;
|
||||||
if (!latestVersion) {
|
if (!latestVersion) {
|
||||||
|
// Issue #173: `git show origin/main:...` reads the LOCALLY CACHED remote ref. Without a
|
||||||
|
// fetch first, a machine that hasn't pulled since the last release sees latest == current
|
||||||
|
// and reports noop — new releases were invisible everywhere except the machine that cut
|
||||||
|
// the tag (live repro: Oracle VM, 2026-07-17). Fetch before comparing; on failure
|
||||||
|
// (offline, auth, timeout) fall through to the cached ref — the pre-existing behavior.
|
||||||
|
if (!opts.skipNetwork) {
|
||||||
|
try {
|
||||||
|
execSync(`git -C ${ocpDir} fetch --tags --quiet`, { stdio: ["pipe", "pipe", "pipe"], timeout: 15000 });
|
||||||
|
} catch { /* offline → compare against cached origin/main, as before */ }
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
const remotePkg = JSON.parse(out);
|
const remotePkg = JSON.parse(out);
|
||||||
|
|||||||
@@ -8,6 +8,19 @@
|
|||||||
//
|
//
|
||||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||||
// is stable enough for our hand-written templates in setup.mjs.
|
// 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()),
|
// 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.
|
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
|
||||||
@@ -36,7 +49,7 @@ export function mergePlistEnv(existing, template) {
|
|||||||
|
|
||||||
const preserved = {};
|
const preserved = {};
|
||||||
for (const [k, v] of Object.entries(existingEnv)) {
|
for (const [k, v] of Object.entries(existingEnv)) {
|
||||||
if (!KNOWN.has(k)) preserved[k] = v;
|
if (!KNOWN.has(k) && !NEVER_PRESERVE.has(k)) preserved[k] = v;
|
||||||
}
|
}
|
||||||
if (Object.keys(preserved).length === 0) return template;
|
if (Object.keys(preserved).length === 0) return template;
|
||||||
|
|
||||||
@@ -72,7 +85,7 @@ export function mergeSystemdEnv(existing, template) {
|
|||||||
const KNOWN = new Set(Object.keys(templateEnv));
|
const KNOWN = new Set(Object.keys(templateEnv));
|
||||||
|
|
||||||
const preservedLines = Object.entries(existingEnv)
|
const preservedLines = Object.entries(existingEnv)
|
||||||
.filter(([k]) => !KNOWN.has(k))
|
.filter(([k]) => !KNOWN.has(k) && !NEVER_PRESERVE.has(k))
|
||||||
.map(([k, v]) => `Environment=${k}=${v}`);
|
.map(([k, v]) => `Environment=${k}=${v}`);
|
||||||
if (preservedLines.length === 0) return template;
|
if (preservedLines.length === 0) return template;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readd
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||||
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
const ts = formatSnapshotTimestamp(new Date());
|
||||||
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
||||||
mkdirSync(root, { recursive: true });
|
mkdirSync(root, { recursive: true });
|
||||||
|
|
||||||
@@ -48,7 +48,10 @@ export function listSnapshots(homeDir) {
|
|||||||
return readdirSync(root)
|
return readdirSync(root)
|
||||||
.filter(name => name.startsWith("upgrade-snapshot-"))
|
.filter(name => name.startsWith("upgrade-snapshot-"))
|
||||||
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => {
|
||||||
|
const chronological = parseSnapshotTimestamp(a.name) - parseSnapshotTimestamp(b.name);
|
||||||
|
return chronological || a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,9 +110,21 @@ export function gcSnapshots(homeDir, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseSnapshotTimestamp(name) {
|
function parseSnapshotTimestamp(name) {
|
||||||
|
// Both legacy ISO names and Windows-safe names are supported.
|
||||||
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
||||||
|
// upgrade-snapshot-2026-05-11T08-30-00Z → epoch ms
|
||||||
const m = name.match(/upgrade-snapshot-(.+)$/);
|
const m = name.match(/upgrade-snapshot-(.+)$/);
|
||||||
if (!m) return 0;
|
if (!m) return 0;
|
||||||
const t = Date.parse(m[1]);
|
const raw = m[1];
|
||||||
return Number.isFinite(t) ? t : 0;
|
const t = Date.parse(raw);
|
||||||
|
if (Number.isFinite(t)) return t;
|
||||||
|
const iso = raw.replace(/(T\d{2})-(\d{2})-(\d{2})Z$/, "$1:$2:$3Z");
|
||||||
|
const portable = Date.parse(iso);
|
||||||
|
return Number.isFinite(portable) ? portable : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSnapshotTimestamp(date) {
|
||||||
|
// Windows forbids ':' in directory names. Replacing only the time separators
|
||||||
|
// preserves chronological lexical order and keeps the timestamp readable.
|
||||||
|
return date.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -17,6 +17,20 @@ import { existsSync, copyFileSync } from "node:fs";
|
|||||||
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||||
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
|
// Post-flight acceptance predicate (issue #173). A health probe passes ONLY when the server
|
||||||
|
// is authed AND actually serving the TARGET version. auth.ok alone is not enough: a stale
|
||||||
|
// process holding the port answers auth.ok=true while still running the OLD code — exactly
|
||||||
|
// what a nohup-fallback orphan did on 2026-07-17 (upgrade "succeeded", /health kept serving
|
||||||
|
// 3.21.1). Comparing /health.version to the checkout target catches orphan-holds-port,
|
||||||
|
// restart-didn't-take, and wrong-unit-restarted alike. `target` tolerates a leading "v"
|
||||||
|
// (doctor reports "v3.22.1"; /health reports "3.22.1"); an empty/unknown target degrades to
|
||||||
|
// the old auth-only check rather than blocking an otherwise-good upgrade.
|
||||||
|
export function postFlightOk(body, target) {
|
||||||
|
if (body?.auth?.ok !== true) return false;
|
||||||
|
const want = String(target || "").replace(/^v/, "");
|
||||||
|
return !want || body?.version === want;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runUpgrade(opts = {}) {
|
export async function runUpgrade(opts = {}) {
|
||||||
const dryRun = !!opts.dryRun;
|
const dryRun = !!opts.dryRun;
|
||||||
const yes = !!opts.yes;
|
const yes = !!opts.yes;
|
||||||
@@ -137,16 +151,22 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
if (!opts.mockExec) {
|
if (!opts.mockExec) {
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
let ok = false;
|
let ok = false;
|
||||||
|
let lastSeen = null;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
||||||
const body = JSON.parse(out);
|
const body = JSON.parse(out);
|
||||||
if (body.auth?.ok === true) { ok = true; break; }
|
lastSeen = body.version;
|
||||||
|
if (postFlightOk(body, doctor.latest_version)) { ok = true; break; }
|
||||||
} catch { /* retry */ }
|
} catch { /* retry */ }
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
|
phases.push({
|
||||||
|
name: "post-flight", status: "fail",
|
||||||
|
message: `health did not return auth.ok=true AND version=${doctor.latest_version} within 10s`
|
||||||
|
+ (lastSeen ? ` (last saw version=${lastSeen} — a stale process may still hold the port; check \`ss -ltnp\` / \`lsof -i\`)` : ""),
|
||||||
|
});
|
||||||
throw new Error("post-flight failed");
|
throw new Error("post-flight failed");
|
||||||
}
|
}
|
||||||
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
||||||
|
|||||||
+115
-35
@@ -35,7 +35,7 @@
|
|||||||
*/
|
*/
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn, execFileSync, spawnSync } from "node:child_process";
|
import { spawn, execFileSync, spawnSync } from "node:child_process";
|
||||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
import { randomUUID, timingSafeEqual, createHash as cryptoCreateHash } from "node:crypto";
|
||||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
@@ -47,8 +47,9 @@ import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneH
|
|||||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||||
import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||||
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
|
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
|
||||||
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS } from "./lib/tui/stream.mjs";
|
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
|
||||||
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||||
|
import { appendOperatorPrompt } from "./lib/prompt.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
@@ -97,8 +98,40 @@ function _collectNodeManagerCandidates(home) {
|
|||||||
|
|
||||||
return out;
|
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() {
|
function resolveClaude() {
|
||||||
|
const isWin = process.platform === "win32";
|
||||||
if (process.env.CLAUDE_BIN) {
|
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 {
|
try {
|
||||||
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
|
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
|
||||||
return process.env.CLAUDE_BIN;
|
return process.env.CLAUDE_BIN;
|
||||||
@@ -108,28 +141,43 @@ function resolveClaude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const home = process.env.HOME || "";
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||||
const candidates = [
|
const candidates = isWin
|
||||||
"/opt/homebrew/bin/claude",
|
? _collectWindowsClaudeCandidates()
|
||||||
"/usr/local/bin/claude",
|
: [
|
||||||
"/usr/bin/claude",
|
"/opt/homebrew/bin/claude",
|
||||||
join(home, ".local/bin/claude"),
|
"/usr/local/bin/claude",
|
||||||
..._collectNodeManagerCandidates(home),
|
"/usr/bin/claude",
|
||||||
];
|
join(home, ".local/bin/claude"),
|
||||||
|
..._collectNodeManagerCandidates(home),
|
||||||
|
];
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (isWin) {
|
||||||
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
|
try {
|
||||||
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
|
const lines = _lookupLines(execFileSync("where.exe", ["claude"], { encoding: "utf8", timeout: 5000 }));
|
||||||
} catch {}
|
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 {}
|
||||||
|
}
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
"FATAL: claude binary not found.\n" +
|
"FATAL: claude binary not found.\n" +
|
||||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
(isWin
|
||||||
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
|
? " Set CLAUDE_BIN to the absolute path of claude.exe or ensure claude.exe is in PATH.\n" +
|
||||||
" shown by `which claude` in your interactive shell.\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") +
|
||||||
" Checked: " + candidates.join(", ")
|
" Checked: " + candidates.join(", ")
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -148,17 +196,19 @@ function resolveClaude() {
|
|||||||
const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`;
|
const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`;
|
||||||
|
|
||||||
// Build the full system-prompt string: OCP_SYSTEM_PROMPT_WRAPPER prepended,
|
// Build the full system-prompt string: OCP_SYSTEM_PROMPT_WRAPPER prepended,
|
||||||
// then any system-role messages from the request appended (separated by blank line).
|
// then any system-role messages from the request appended (separated by blank line),
|
||||||
// ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper".
|
// then the operator-wide CLAUDE_SYSTEM_PROMPT appended LAST (lib/prompt.mjs — a
|
||||||
|
// no-op returning the same string when the var is unset, so the default path is
|
||||||
|
// byte-for-byte unchanged). ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper".
|
||||||
function extractSystemPrompt(messages) {
|
function extractSystemPrompt(messages) {
|
||||||
const systemMessages = (messages ?? []).filter(m => m.role === "system");
|
const systemMessages = (messages ?? []).filter(m => m.role === "system");
|
||||||
if (systemMessages.length === 0) {
|
if (systemMessages.length === 0) {
|
||||||
return OCP_SYSTEM_PROMPT_WRAPPER;
|
return appendOperatorPrompt(OCP_SYSTEM_PROMPT_WRAPPER, SYSTEM_PROMPT);
|
||||||
}
|
}
|
||||||
const clientContent = systemMessages.map(m =>
|
const clientContent = systemMessages.map(m =>
|
||||||
contentToText(m.content)
|
contentToText(m.content)
|
||||||
).join("\n\n");
|
).join("\n\n");
|
||||||
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
|
return appendOperatorPrompt(`${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`, SYSTEM_PROMPT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NDJSON line buffer parser (Phase 6c port) ─────────────────────────────
|
// ── NDJSON line buffer parser (Phase 6c port) ─────────────────────────────
|
||||||
@@ -296,6 +346,19 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
|
|||||||
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
||||||
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||||
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
||||||
|
// Config epoch for the response cache (issue #176). The cache key hashes model + messages +
|
||||||
|
// sampling params, but the ANSWER also depends on boot-time server config that shapes the
|
||||||
|
// composed prompt / tool surface: the operator system prompt (#175), the OCP wrapper text,
|
||||||
|
// the allowed-tools set, and NO_CONTEXT. The cache store is SQLite-backed and survives
|
||||||
|
// restarts, so without this an operator who changes any of these and restarts keeps serving
|
||||||
|
// answers composed under the OLD config until TTL expiry. Folding a digest of the four into
|
||||||
|
// every cache key makes any change an instant, whole-cache invalidation — the honest behavior.
|
||||||
|
// Deliberately boot-time-only: runtime-mutable settings (e.g. maxPromptChars via the settings
|
||||||
|
// API) are excluded because a const epoch cannot track them; truncation also only drops
|
||||||
|
// context rather than changing the instruction set.
|
||||||
|
const CONFIG_EPOCH = cryptoCreateHash("sha256")
|
||||||
|
.update(JSON.stringify([SYSTEM_PROMPT, OCP_SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT]))
|
||||||
|
.digest("hex").slice(0, 16);
|
||||||
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
|
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
|
||||||
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
|
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
|
||||||
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
|
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
|
||||||
@@ -379,7 +442,20 @@ const TUI_STREAM_DIR = process.env.OCP_TUI_STREAM_DIR || `${process.env.HOME}/.o
|
|||||||
// exceeds this, which puts it out of the default banner detector's <=100-char reach — the
|
// 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:
|
// 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.
|
// no further emission at all once a message boundary follows an emit). Only raise it.
|
||||||
const TUI_STREAM_HOLDBACK = parseInt(process.env.OCP_TUI_STREAM_HOLDBACK || String(DEFAULT_HOLDBACK_CHARS), 10);
|
// 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) {
|
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
|
// 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
|
// auth-banner detector (which cannot match a message longer than 100 chars). An
|
||||||
@@ -1494,6 +1570,17 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
|
|||||||
streamDir: TUI_STREAM ? TUI_STREAM_DIR : null,
|
streamDir: TUI_STREAM ? TUI_STREAM_DIR : null,
|
||||||
abortSignal: streamCtx ? streamCtx.signal : 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.
|
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
|
||||||
// A throw here propagates to the catch below (recordModelError + reject), so the
|
// A throw here propagates to the catch below (recordModelError + reject), so the
|
||||||
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
||||||
@@ -1571,17 +1658,9 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||||
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
// Entrypoint/billing-pool observation was already recorded above, right after runTuiTurn
|
||||||
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
// returned — see the A3-fix comment there (it must cover failed turns too, so it cannot live
|
||||||
// return text but cost money — warn loudly so it's visible. (issue #115)
|
// on this success-only path).
|
||||||
// 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;
|
return text;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A mid-turn client disconnect (streaming path only — abortSignal) is NOT an upstream
|
// A mid-turn client disconnect (streaming path only — abortSignal) is NOT an upstream
|
||||||
@@ -2563,8 +2642,9 @@ async function handleChatCompletions(req, res) {
|
|||||||
req._cacheHash = null;
|
req._cacheHash = null;
|
||||||
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
|
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
|
||||||
} else {
|
} else {
|
||||||
// D1: include keyId in hash to isolate per-key cache pools (v2 format)
|
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
|
||||||
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
|
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
|
||||||
|
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
|
||||||
req._cacheHash = hash; // store for later write-back
|
req._cacheHash = hash; // store for later write-back
|
||||||
try {
|
try {
|
||||||
const cached = getCachedResponse(hash, CACHE_TTL);
|
const cached = getCachedResponse(hash, CACHE_TTL);
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ if (OPENCLAW_PRESENT) {
|
|||||||
`║ Aider / OpenClaw) at: ║`,
|
`║ Aider / OpenClaw) at: ║`,
|
||||||
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
||||||
`║ ║`,
|
`║ ║`,
|
||||||
`║ See README § "Client Setup" for per-IDE instructions. ║`,
|
`║ See docs/lan-mode.md for per-IDE client setup. ║`,
|
||||||
`║ ║`,
|
`║ ║`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -12,9 +12,10 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
export const TEST_OCP_DIR = mkdtempSync(join(tmpdir(), "ocp-test-"));
|
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 that a
|
// BOTH are required. keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so neither
|
||||||
// production server — which runs without NODE_ENV — cannot be redirected onto a different key
|
// var alone redirects anything — a stray OCP_DIR_OVERRIDE in a production env is inert without
|
||||||
// store no matter how the variable reached its environment.
|
// 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.NODE_ENV = "test";
|
||||||
process.env.OCP_DIR_OVERRIDE = TEST_OCP_DIR;
|
process.env.OCP_DIR_OVERRIDE = TEST_OCP_DIR;
|
||||||
|
|
||||||
|
|||||||
+268
-20
@@ -204,6 +204,23 @@ test("cacheHash includes temperature in hash", () => {
|
|||||||
assert.notEqual(h2, h3);
|
assert.notEqual(h2, h3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── configEpoch (#176): a boot-config change must invalidate the persistent cache ──
|
||||||
|
// Mutation-proof: drop the `ce:` fold in keys.mjs and the first test goes green-to-red.
|
||||||
|
test("cacheHash: different configEpoch → different key (config change invalidates)", () => {
|
||||||
|
const h1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
|
||||||
|
const h2 = cacheHash("sonnet", msgs1, { configEpoch: "bbbb000011112222" });
|
||||||
|
assert.notEqual(h1, h2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cacheHash: same configEpoch is stable; absent epoch hashes byte-identically to pre-#176", () => {
|
||||||
|
const e1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
|
||||||
|
const e2 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
|
||||||
|
assert.equal(e1, e2);
|
||||||
|
// absent-epoch calls (older callers, all pre-existing tests) must not change behavior
|
||||||
|
assert.equal(cacheHash("sonnet", msgs1, {}), cacheHash("sonnet", msgs1));
|
||||||
|
assert.notEqual(e1, cacheHash("sonnet", msgs1), "epoch-carrying key differs from legacy key");
|
||||||
|
});
|
||||||
|
|
||||||
test("cacheHash includes max_tokens in hash", () => {
|
test("cacheHash includes max_tokens in hash", () => {
|
||||||
const h1 = cacheHash("sonnet", msgs1, {});
|
const h1 = cacheHash("sonnet", msgs1, {});
|
||||||
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
||||||
@@ -537,7 +554,7 @@ async function runSingleflightTests() {
|
|||||||
await runSingleflightTests();
|
await runSingleflightTests();
|
||||||
|
|
||||||
// ── Plist Env Merge Tests ──
|
// ── Plist Env Merge Tests ──
|
||||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
import { mergePlistEnv, mergeSystemdEnv, NEVER_PRESERVE } from "./scripts/lib/plist-merge.mjs";
|
||||||
|
|
||||||
console.log("\nPlist env merge:");
|
console.log("\nPlist env merge:");
|
||||||
|
|
||||||
@@ -651,6 +668,78 @@ test("mergePlistEnv is idempotent", () => {
|
|||||||
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
|
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", () => {
|
test("mergeSystemdEnv is idempotent", () => {
|
||||||
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
||||||
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
||||||
@@ -749,11 +838,61 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
|
|||||||
assert.equal(result.next_action.kind, "noop");
|
assert.equal(result.next_action.kind, "noop");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── System-prompt operator append (CLAUDE_SYSTEM_PROMPT wiring) ─────────────
|
||||||
|
// The var was documented + echoed on /health but never reached a request (dead
|
||||||
|
// since APPEND_SYSTEM_PROMPT was retired — caught in PR #170 review). The wiring
|
||||||
|
// contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt
|
||||||
|
// return `base` unconditionally and the first test fails; make it stop trimming
|
||||||
|
// and the whitespace test fails.
|
||||||
|
import { appendOperatorPrompt } from "./lib/prompt.mjs";
|
||||||
|
|
||||||
|
console.log("\nSystem-prompt operator append:");
|
||||||
|
|
||||||
|
test("appendOperatorPrompt: appends the operator prompt LAST, blank-line separated", () => {
|
||||||
|
assert.equal(appendOperatorPrompt("WRAPPER\n\nclient", "Answer in Chinese."), "WRAPPER\n\nclient\n\nAnswer in Chinese.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("appendOperatorPrompt: unset/empty/whitespace-only → base returned BYTE-IDENTICAL", () => {
|
||||||
|
const base = "WRAPPER\n\nclient sys";
|
||||||
|
assert.equal(appendOperatorPrompt(base, undefined), base);
|
||||||
|
assert.equal(appendOperatorPrompt(base, ""), base);
|
||||||
|
assert.equal(appendOperatorPrompt(base, " \n "), base, "a stray space in a service unit must not inject anything");
|
||||||
|
assert.equal(appendOperatorPrompt(base, null), base);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("appendOperatorPrompt: operator value is trimmed before appending", () => {
|
||||||
|
assert.equal(appendOperatorPrompt("W", " hi "), "W\n\nhi");
|
||||||
|
});
|
||||||
|
|
||||||
// ── Upgrade Tests ──
|
// ── Upgrade Tests ──
|
||||||
import { runUpgrade } from "./scripts/upgrade.mjs";
|
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";
|
||||||
|
|
||||||
console.log("\nUpgrade:");
|
console.log("\nUpgrade:");
|
||||||
|
|
||||||
|
// ── postFlightOk (issue #173) — the acceptance predicate for phase 6 ─────────
|
||||||
|
// Mutation-proof: revert the version comparison to auth-only and the "stale process
|
||||||
|
// still holds the port" test below goes green-to-red (that case is the 2026-07-17
|
||||||
|
// Oracle incident: orphan answered auth.ok=true while serving the OLD version).
|
||||||
|
test("postFlightOk: rejects a healthy-looking probe that serves the WRONG version (orphan case)", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.21.1" }, "v3.22.1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postFlightOk: accepts auth.ok + exact target version, tolerating the leading v", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, "v3.22.1"), true);
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, "3.22.1"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postFlightOk: auth failure rejects regardless of version", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: false }, version: "3.22.1" }, "v3.22.1"), false);
|
||||||
|
assert.equal(postFlightOk({ version: "3.22.1" }, "v3.22.1"), false);
|
||||||
|
assert.equal(postFlightOk(null, "v3.22.1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postFlightOk: unknown/empty target degrades to the auth-only check (never blocks)", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, ""), true);
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, undefined), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("upgrade --dry-run prints plan, no side effects", async () => {
|
test("upgrade --dry-run prints plan, no side effects", async () => {
|
||||||
const result = await runUpgrade({
|
const result = await runUpgrade({
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
@@ -808,6 +947,41 @@ import { join as testJoin } from "node:path";
|
|||||||
|
|
||||||
console.log("\nSnapshot:");
|
console.log("\nSnapshot:");
|
||||||
|
|
||||||
|
const portableSnapshotName = (isoTimestamp) => `upgrade-snapshot-${isoTimestamp.replace(/:/g, "-")}`;
|
||||||
|
const legacyMixedSnapshot = "upgrade-snapshot-2026-05-11T09:05:00Z";
|
||||||
|
const portableMixedSnapshot = "upgrade-snapshot-2026-05-11T09-47-00Z";
|
||||||
|
|
||||||
|
function runMixedSnapshotScenario() {
|
||||||
|
// NTFS rejects the legacy ':' name, so exercise the real exported functions
|
||||||
|
// in an isolated process whose built-in fs bindings expose both formats.
|
||||||
|
const moduleUrl = new URL("./scripts/lib/snapshot.mjs", import.meta.url).href;
|
||||||
|
const script = `
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { syncBuiltinESMExports } from "node:module";
|
||||||
|
const names = ${JSON.stringify([legacyMixedSnapshot, portableMixedSnapshot])};
|
||||||
|
const deleted = [];
|
||||||
|
fs.existsSync = () => true;
|
||||||
|
fs.readdirSync = () => [...names];
|
||||||
|
fs.statSync = () => ({ mtimeMs: 0 });
|
||||||
|
fs.rmSync = (path) => { deleted.push(path); };
|
||||||
|
syncBuiltinESMExports();
|
||||||
|
const { listSnapshots, gcSnapshots } = await import(${JSON.stringify(moduleUrl)});
|
||||||
|
const listed = listSnapshots("/virtual-home").map(snapshot => snapshot.name);
|
||||||
|
const gc = gcSnapshots("/virtual-home", {
|
||||||
|
keepCount: 1,
|
||||||
|
keepDays: 0,
|
||||||
|
now: new Date("2026-05-12T00:00:00Z")
|
||||||
|
});
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
listed,
|
||||||
|
kept: gc.kept.map(snapshot => snapshot.name),
|
||||||
|
removed: gc.removed.map(snapshot => snapshot.name),
|
||||||
|
deleted
|
||||||
|
}));
|
||||||
|
`;
|
||||||
|
return JSON.parse(execFileSync(process.execPath, ["--input-type=module", "--eval", script], { encoding: "utf8" }));
|
||||||
|
}
|
||||||
|
|
||||||
test("writeSnapshot creates dir + manifest files", () => {
|
test("writeSnapshot creates dir + manifest files", () => {
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
@@ -832,7 +1006,7 @@ test("listSnapshots returns sorted by ISO timestamp", () => {
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
|
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
const list = listSnapshots(root);
|
const list = listSnapshots(root);
|
||||||
assert.equal(list.length, 3);
|
assert.equal(list.length, 3);
|
||||||
@@ -841,6 +1015,19 @@ test("listSnapshots returns sorted by ISO timestamp", () => {
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("listSnapshots sorts mixed legacy and Windows-safe names chronologically", () => {
|
||||||
|
const result = runMixedSnapshotScenario();
|
||||||
|
assert.deepEqual(result.listed, [legacyMixedSnapshot, portableMixedSnapshot]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots keeps the newer Windows-safe snapshot across the format boundary", () => {
|
||||||
|
const result = runMixedSnapshotScenario();
|
||||||
|
assert.deepEqual(result.kept, [portableMixedSnapshot]);
|
||||||
|
assert.deepEqual(result.removed, [legacyMixedSnapshot]);
|
||||||
|
assert.equal(result.deleted.length, 1);
|
||||||
|
assert.ok(result.deleted[0].endsWith(legacyMixedSnapshot));
|
||||||
|
});
|
||||||
|
|
||||||
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
||||||
// Use mockExec=true so no real commands are run.
|
// Use mockExec=true so no real commands are run.
|
||||||
// Verify the success path returns a snapshotPath (Fix B regression guard).
|
// Verify the success path returns a snapshotPath (Fix B regression guard).
|
||||||
@@ -930,7 +1117,7 @@ test("gcSnapshots keeps last N regardless of age", () => {
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
assert.equal(result.kept.length, 3);
|
assert.equal(result.kept.length, 3);
|
||||||
@@ -1013,7 +1200,7 @@ test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () =
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
|
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
|
||||||
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
@@ -1027,7 +1214,7 @@ test("gcSnapshots never deletes the most recent snapshot", () => {
|
|||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
tMkdirSync(testJoin(dotOcp, "upgrade-snapshot-2026-01-01T10:00:00Z"));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName("2026-01-01T10:00:00Z")));
|
||||||
// Even with keepCount=0 and keepDays=0, the most recent must survive
|
// Even with keepCount=0 and keepDays=0, the most recent must survive
|
||||||
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
assert.equal(result.kept.length, 1);
|
assert.equal(result.kept.length, 1);
|
||||||
@@ -1040,13 +1227,13 @@ test("gcSnapshots --dry-run reports plan without deleting", () => {
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
assert.equal(result.dryRun, true);
|
assert.equal(result.dryRun, true);
|
||||||
assert.equal(result.removed.length, 2);
|
assert.equal(result.removed.length, 2);
|
||||||
// Files still exist
|
// Files still exist
|
||||||
assert.ok(testExistsSync(testJoin(dotOcp, "upgrade-snapshot-2026-04-01T10:00:00Z")));
|
assert.ok(testExistsSync(testJoin(dotOcp, portableSnapshotName("2026-04-01T10:00:00Z"))));
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2541,21 +2728,21 @@ import { tmpdir as hTmp } from "node:os";
|
|||||||
console.log("\nTUI home preparation:");
|
console.log("\nTUI home preparation:");
|
||||||
|
|
||||||
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
|
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
|
||||||
const realHome = hMkdtemp(`${hTmp()}/real-`);
|
const realHome = hMkdtemp(testJoin(hTmp(), "real-"));
|
||||||
hMkdir(`${realHome}/.claude`, { recursive: true });
|
hMkdir(testJoin(realHome, ".claude"), { recursive: true });
|
||||||
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"x"}');
|
hWrite(testJoin(realHome, ".claude", ".credentials.json"), '{"token":"x"}');
|
||||||
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
|
hWrite(testJoin(realHome, ".claude.json"), JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
|
||||||
const tuiHome = hMkdtemp(`${hTmp()}/tui-`);
|
const tuiHome = hMkdtemp(testJoin(hTmp(), "tui-"));
|
||||||
const cwd = `${tuiHome}/work`;
|
const cwd = testJoin(tuiHome, "work");
|
||||||
prepareTuiHome(realHome, tuiHome, cwd);
|
prepareTuiHome(realHome, tuiHome, cwd);
|
||||||
// credentials symlinked (token never copied)
|
// credentials symlinked (token never copied)
|
||||||
assert.equal(hReadlink(`${tuiHome}/.claude/.credentials.json`), `${realHome}/.claude/.credentials.json`);
|
assert.equal(hReadlink(testJoin(tuiHome, ".claude", ".credentials.json")), testJoin(realHome, ".claude", ".credentials.json"));
|
||||||
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
|
const seed = JSON.parse(hRead(testJoin(tuiHome, ".claude.json"), "utf8"));
|
||||||
assert.equal(seed.hasCompletedOnboarding, true);
|
assert.equal(seed.hasCompletedOnboarding, true);
|
||||||
assert.equal(seed.theme, "dark"); // onboarded config carried over
|
assert.equal(seed.theme, "dark"); // onboarded config carried over
|
||||||
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
|
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
|
||||||
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
|
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
|
||||||
assert.ok(hExists(`${tuiHome}/.claude/projects`)); // own projects dir
|
assert.ok(hExists(testJoin(tuiHome, ".claude", "projects"))); // own projects dir
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {
|
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {
|
||||||
@@ -3250,8 +3437,33 @@ test("models.json aliases.haiku === 'claude-haiku-4-5-20251001' (usage-probe SPO
|
|||||||
assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001");
|
assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model SPOT)", () => {
|
test("models.json aliases.sonnet === 'claude-sonnet-5' (default-request-model SPOT)", () => {
|
||||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
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)`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||||
@@ -3453,7 +3665,7 @@ async function runAsyncTests() {
|
|||||||
// ── TUI real streaming: MessageDisplay hook sink (backlog #2) ───────────────
|
// ── TUI real streaming: MessageDisplay hook sink (backlog #2) ───────────────
|
||||||
// Pure-logic coverage for lib/tui/stream.mjs: sink parsing, the concat===T assertion,
|
// 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.
|
// prefix-stability, the auth-banner holdback, message scoping, and the error paths.
|
||||||
import { TuiDeltaAssembler, parseDeltaChunk, buildStreamSettings, streamFilePath, HOOK_SCRIPT, prepareStreamHook } from "./lib/tui/stream.mjs";
|
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)", () => {
|
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 });
|
const p = (i, d, final = false) => JSON.stringify({ hook_event_name: "MessageDisplay", session_id: "s", message_id: "m", index: i, final, delta: d });
|
||||||
@@ -3521,6 +3733,42 @@ test("stream: holdback releases once past the detector's reach, and only then",
|
|||||||
assert.equal(a.push(mdFire(2, "tail")), "tail", "subsequent deltas stream straight through");
|
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", () => {
|
test("stream: a short answer never passes the holdback and is delivered whole at terminal", () => {
|
||||||
const T = "The capital of France is Paris.";
|
const T = "The capital of France is Paris.";
|
||||||
const a = new TuiDeltaAssembler();
|
const a = new TuiDeltaAssembler();
|
||||||
|
|||||||
Reference in New Issue
Block a user