mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fc8d6973b | ||
|
|
27216646c8 | ||
|
|
d501e786b8 | ||
|
|
b7463a63f5 | ||
|
|
eeec2bf83d | ||
|
|
63c2de7128 | ||
|
|
1d65bc309e | ||
|
|
88d8bed2e3 |
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
|
|||||||
|
|
||||||
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
|
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
|
||||||
|
|
||||||
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
|
- **LAN multi-user keys** (v3.7.0) — reach one Claude Pro/Max subscription from your own devices across the LAN. Each device gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation. Pro/Max are **per-user** accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other **people**.
|
||||||
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
|
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
|
||||||
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
|
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
|
||||||
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||||
@@ -49,7 +49,7 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
|
|||||||
| GitHub stars / ecosystem size | small | large | mid |
|
| GitHub stars / ecosystem size | small | large | mid |
|
||||||
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
|
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
|
||||||
|
|
||||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to reach one Claude Pro/Max subscription from your own IDEs and devices, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||||
|
|
||||||
### Related: OLP — Open LLM Proxy
|
### Related: OLP — Open LLM Proxy
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either
|
|||||||
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
**LAN mode** — share with other devices on your network:
|
**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
|
```bash
|
||||||
# Enable LAN access with per-user auth (recommended)
|
# Enable LAN access with per-user auth (recommended)
|
||||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||||
@@ -716,6 +716,7 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
|
|||||||
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||||
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||||
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||||
|
| `claude-sonnet-5` | Latest Sonnet (available by full ID; `sonnet` alias repoint tracked separately) |
|
||||||
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+14
-8
@@ -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:
|
||||||
|
|||||||
+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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+91
-28
@@ -47,7 +47,7 @@ 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";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -97,8 +97,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 +140,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);
|
||||||
@@ -379,7 +426,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 +1554,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 +1642,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
|
||||||
|
|||||||
+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;
|
||||||
|
|
||||||
|
|||||||
+135
-2
@@ -537,7 +537,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 +651,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);
|
||||||
@@ -3254,6 +3326,31 @@ test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model
|
|||||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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) ────────────────────────────
|
||||||
// Replicated verbatim from dashboard.html so tests run without a browser.
|
// Replicated verbatim from dashboard.html so tests run without a browser.
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
@@ -3453,7 +3550,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 +3618,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