mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c86e3d014f | ||
|
|
3322d7bdae | ||
|
|
79c1d61e1d | ||
|
|
a37ff713d9 | ||
|
|
6d4751f983 | ||
|
|
0dced52215 | ||
|
|
d291331998 | ||
|
|
9568411bcb | ||
|
|
1f577c075f | ||
|
|
6dff36959a | ||
|
|
1b02f181fa | ||
|
|
0000926358 | ||
|
|
aa1c65beb1 | ||
|
|
879b40fe93 | ||
|
|
68d58e7df4 | ||
|
|
4a7d79c330 | ||
|
|
c3b1f32c86 | ||
|
|
4458490caa | ||
|
|
36be723198 | ||
|
|
7b065600aa |
@@ -29,10 +29,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
|
||||
# Each token is matched as a fixed string against server.mjs only.
|
||||
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
|
||||
# (1) known LLM hallucinations (e.g. the 2026-04-11 /api/oauth/usage drift), and
|
||||
# (2) pinned wrong-host variants of a VERIFIED Class A endpoint (a hit means a
|
||||
# drift to a known-wrong host, not necessarily a hallucination).
|
||||
# Extend only via an ALIGNMENT.md amendment PR. Matched as fixed strings vs server.mjs.
|
||||
BLACKLIST=(
|
||||
"api.anthropic.com/api/oauth/usage"
|
||||
"console.anthropic.com/v1/oauth/token"
|
||||
)
|
||||
|
||||
FAIL=0
|
||||
@@ -51,8 +55,8 @@ jobs:
|
||||
============================================================
|
||||
server.mjs contains a token on the OCP alignment blacklist.
|
||||
|
||||
These tokens were introduced by LLM hallucinations and do
|
||||
not appear in cli.js at any shipped Claude Code version.
|
||||
These tokens are either LLM hallucinations that never appeared in cli.js,
|
||||
or pinned wrong-host variants of a verified Class A endpoint (a drift).
|
||||
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
||||
(commit b87992f) for the full incident record.
|
||||
|
||||
|
||||
@@ -52,6 +52,26 @@ The following Rules apply to **Class A operations** (the `cli.js`-mirror surface
|
||||
|
||||
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
|
||||
|
||||
### OAuth token-host verification (2026-05-31)
|
||||
|
||||
Motivating evidence: the 2026-05-31 code audit (issues #112 / #119 / #123). The OAuth bearer
|
||||
machinery is a Class A surface (Rules 1–5). Because `cli.js` now ships as a
|
||||
compiled binary, the token-refresh host was re-verified against `claude.exe` (Claude Code
|
||||
`2.1.154`) on 2026-05-31 using the compiled-binary protocol — `strings` on the Mach-O, **no
|
||||
live OAuth probe** (a `refresh_token` grant would rotate the operator's real credentials):
|
||||
|
||||
- **Verified host:** `https://platform.claude.com/v1/oauth/token` — present in the binary
|
||||
byte-for-byte, paired with `OAUTH_CLIENT_ID` in the same `prod` config object (matches
|
||||
`server.mjs` `OAUTH_TOKEN_URL` / `OAUTH_CLIENT_ID`). The legacy `console.anthropic.com/v1/oauth`
|
||||
host is absent (0 hits).
|
||||
- **Pinned wrong-host variant:** `console.anthropic.com/v1/oauth/token` is added to the
|
||||
`alignment.yml` blacklist so a future accidental revert to the legacy host hard-fails CI.
|
||||
|
||||
The blacklist therefore now holds two kinds of token: (1) known hallucinations (e.g.
|
||||
`api.anthropic.com/api/oauth/usage`, the 2026-04-11 drift), and (2) pinned wrong-host variants
|
||||
of a *verified* Class A endpoint. A blacklist hit means either a re-introduced hallucination
|
||||
**or** a drift to a known-wrong host — both are alignment failures under Rules 2 and 3.
|
||||
|
||||
---
|
||||
|
||||
## Historical Lesson: The 2026-04-11 Drift
|
||||
|
||||
+69
-1
@@ -1,6 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v3.20.0 — 2026-06-10
|
||||
|
||||
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||
|
||||
### TUI — honesty & cache correctness (#137)
|
||||
|
||||
- **C-1** — `callClaudeTui` now throws on a claude-CLI auth-failure banner (e.g. `Please run /login · API Error: 401 …`, `Failed to authenticate. API Error: 401 …`) instead of returning it as a real answer, so it is never cached, singleflight-shared, or counted as a model success. Conservative detector (whole trimmed text ≤100 chars + `API Error: 4xx` + auth keyword + no code/quote char); overridable via `CLAUDE_TUI_ERROR_PATTERNS`. Live-reproduced on PI231.
|
||||
- **C-2** — `readTuiTranscript` distinguishes a complete turn from a wallclock-truncated partial (`truncated` flag); `callClaudeTui` throws `tui_wallclock_truncated` so a partial is never cached or counted as success.
|
||||
- **C-3** — `verifyEntrypoint` reads the `entrypoint` field from any transcript line, not just `{system, turn_duration}` — some claude builds emit zero turn_duration lines (live-confirmed on Oracle's claude 2.1.114), which previously left the billing-drift assertion blind on those builds.
|
||||
- **C-4 (paste)** — short prompts (e.g. `hi`) could never pass paste-landing detection; threshold lowered. Live-reproduced on PI231.
|
||||
|
||||
### TUI — concurrency & observability (#139)
|
||||
|
||||
- **Concurrency** — `OCP_TUI_MAX_CONCURRENT` (default 2) bounds concurrent interactive `claude` boots via a queuing semaphore (`lib/tui/semaphore.mjs`); the slot is released on throw so honesty-gate / spawn failures never leak it; bounded wait-queue → `tui_queue_full` (503). Independent of the global `MAX_CONCURRENT` (8) — a TUI turn is a heavy per-request cold-boot of tmux+claude + up to 120s wallclock.
|
||||
- **Observability** — additive `/health` `tui` block (`enabled` / `entrypointMode` / `lastEntrypoint` / `entrypointMismatches` / `inflight` / `maxConcurrent`) so an operator can poll for a silent `sdk-cli` metered-pool drift (the audit's top risk) instead of grepping journald. Authorized by the ADR 0007 PR-B amendment under the ALIGNMENT grandfather provision (additive, behaviour-preserving — every pre-existing `/health` field unchanged).
|
||||
|
||||
### Operations (#138)
|
||||
|
||||
- `docs/runbooks/615-canary.md` — the 2026-06-15 credit-balance canary: quiesce, read the Agent SDK credit balance (manual — no programmatic API exists for that pool; OCP's `/usage` headers are subscription rate-limit data, not the credit pool), one TUI canary turn, confirm `entrypoint:cli` in the transcript, green/red decision tree, periodic auto-mode self-classification mini-canary.
|
||||
- `docs/runbooks/tui-flip-rollback.md` — flip/rollback per deployment (systemd `daemon-reload`; launchd `bootout`/`bootstrap`, not `kickstart -k`).
|
||||
- `setup.mjs` auth quick-test gated behind `OCP_SKIP_AUTH_TEST=1` (the `claude -p` probe draws from the metered Agent SDK pool after 6/15).
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `OCP_TUI_MAX_CONCURRENT` — max concurrent interactive TUI turns (default 2) (#139).
|
||||
- `OCP_SKIP_AUTH_TEST` — skip the `claude -p` auth probe in `setup.mjs` (default off) (#138).
|
||||
|
||||
## v3.19.0 — 2026-06-02
|
||||
|
||||
TUI-mode reliability + proxy-purity release. Two fixes diagnosed and verified live on both test hosts (PI231 / Oracle, claude 2.1.104 / 2.1.114), each its own PR with a fresh-context reviewer (Iron Rule 10), then an adversarial multi-host test battery (0 hangs / 0 crashes / 0 injection / 0 leaks). The default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||
|
||||
### TUI
|
||||
|
||||
- **#130** — Fixed the "stuck typing" hang on large multi-line prompts. Three root causes: (1) terminal-turn detection only recognized `{system, turn_duration}`, which older claude builds (e.g. 2.1.114) don't emit → the reader ran to the wallclock and returned partial text; now also accepts an `assistant` line with a final `stop_reason` (`end_turn`/`stop_sequence`/`max_tokens`), while `tool_use` stays non-terminal. (2) Large prompts pasted via `send-keys -l` delivered embedded newlines as separate Enter events → the prompt never landed; now uses `tmux load-buffer` + `paste-buffer -p` (bracketed paste, atomic). (3) The paste-landed check false-positived on claude's empty curly-quote placeholder → Enter fired into an empty box; now positive-signal-only (`[Pasted text]` / prompt text) with a readiness/paste-verify poll + fast-fail (deterministic ~5s error instead of a 120s wallclock hang).
|
||||
- **#4** — TUI-mode never injects the host's `CLAUDE.md` / auto-memory into proxied turns. OCP is a proxy: the proxied client (OpenClaw / an IDE) owns its own context and memory. `buildTuiCmd` now always sets `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (unconditional — proxy purity is not an opt-in). Verified live with a marker `CLAUDE.md`: obeyed by the proxied turn before the fix, blocked after, on both hosts. Residual host-context vectors (managed-policy / `settings.json` / output-styles) tracked in #133. The env is delivered via an `env`-prefix on the tmux pane command (tmux does not forward the spawning process's environment, and `new-session -e` requires tmux ≥3.2 while the cloud host runs 2.7).
|
||||
|
||||
## v3.18.0 — 2026-06-01
|
||||
|
||||
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123–#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
|
||||
|
||||
### Security
|
||||
|
||||
- **#109 (P0)** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. Prevents any LAN-reachable device from harvesting a working, quota-spending bearer credential from the unauthenticated `/health` endpoint. **Behavior change:** `ocp-connect` zero-config Path A now requires the server to set `PROXY_ADVERTISE_ANON_KEY=1`; otherwise pass `--key` or use anonymous access.
|
||||
- **#114** — Dashboard escapes all DB-sourced strings (key names, usage rows) before `innerHTML`; the revoke button uses a `data-` attribute + listener instead of an inline `onclick` a quote could break out of; `POST /api/keys` validates key names server-side (`[A-Za-z0-9 ._-]{1,64}`).
|
||||
- **#124** — Dashboard status/plan summary cards escaped too (uniform defense-in-depth over all `innerHTML` sinks).
|
||||
- **#111** — Streaming error paths strip filesystem paths from claude error text / stderr before sending them to clients (`sanitizeError`), matching the non-streaming path.
|
||||
|
||||
### Reliability / correctness
|
||||
|
||||
- **#110** — Non-array `messages` is rejected with a 400 (was silently hanging the connection until socket timeout); OpenAI array `content` is flattened into the prompt instead of dumped as raw JSON; a streamed upstream error now emits an SSE `error` frame instead of a success-looking `finish_reason:"stop"`.
|
||||
- **#111** — `res.on("close")` escalates SIGTERM→SIGKILL on client disconnect (closes a narrow re-occurrence of the #37 concurrency-slot leak on the hottest exit path); `overallTimer` is cleared on semantic completion so a slow-exiting child can't record a spurious post-success timeout; per-key quota is documented as best-effort (bounded overshoot ≤ `MAX_CONCURRENT`, cache hits uncounted).
|
||||
- **#113** — CLI/installer hardening: `ocp-plugin` restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe `pkill` fallback; `ocp-connect` quotes + `chmod 600`s the persisted key; `setup.mjs` XML-escapes and newline-validates injected service-unit secrets.
|
||||
|
||||
### Alignment / governance
|
||||
|
||||
- **#112** — OAuth token-refresh host (`platform.claude.com/v1/oauth/token`) re-verified against the compiled cli.js v2.1.154 (`strings`, no live probe) and recorded in `ALIGNMENT.md`; usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
|
||||
- **#123** — The legacy `console.anthropic.com/v1/oauth/token` host is pinned in the `alignment.yml` blacklist so a future OAuth-host drift hard-fails CI; the blacklist now documents its dual purpose (known hallucinations + pinned wrong-host variants of a verified Class A endpoint).
|
||||
|
||||
### TUI
|
||||
|
||||
- **#115** — The TUI LAN gate refuses any non-loopback bind (not just literal `0.0.0.0`); the achieved `cc_entrypoint` is asserted each turn and a `tui_entrypoint_mismatch` warning is logged on a silent degrade to the metered sdk-cli pool.
|
||||
|
||||
### Refactor
|
||||
|
||||
- **#125** — `isLoopbackBind` extracted to `lib/net.mjs`, shared by `server.mjs` and the test suite (was duplicated via a copy-paste mirror).
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `PROXY_ADVERTISE_ANON_KEY` — opt-in (default off); advertise `PROXY_ANONYMOUS_KEY` on the public `/health` body for remote zero-config discovery (#109).
|
||||
|
||||
## v3.17.1 — 2026-05-31
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
|
||||
|
||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
|
||||
### Related: OLP — Open LLM Proxy
|
||||
|
||||
OCP is Claude-only by design. If you want to spread across **multiple LLM providers** (not just Claude), see the sibling project **[OLP — Open LLM Proxy](https://github.com/dtzp555-max/olp)**: the same spawn-the-provider-CLI approach, but across several provider CLIs behind one OpenAI-compatible endpoint, with intelligent fallback chains. It grew out of OCP in response to Anthropic's 2026-06-15 billing split — the idea being to spread subscription/quota risk across more than one provider. OCP remains the focused, Claude-only option; OLP is the multi-provider one.
|
||||
|
||||
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
|
||||
|
||||
## Supported Tools
|
||||
@@ -281,7 +285,7 @@ chmod +x ocp-connect
|
||||
./ocp-connect <server-ip>
|
||||
```
|
||||
|
||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (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:
|
||||
**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>
|
||||
@@ -366,7 +370,7 @@ OCP Connect v1.3.0
|
||||
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+)
|
||||
- **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)
|
||||
|
||||
@@ -405,10 +409,22 @@ ocp keys revoke son-ipad # Revoke a key
|
||||
|------|-----|----------|
|
||||
| `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 with usage tracking (recommended) |
|
||||
| `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.
|
||||
|
||||
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode).)
|
||||
|
||||
### 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.
|
||||
@@ -424,7 +440,7 @@ 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). 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.
|
||||
**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.
|
||||
|
||||
@@ -470,6 +486,8 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
||||
- 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)
|
||||
@@ -707,7 +725,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
||||
|----------|--------|-------------|
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||
| `/health` | GET | Comprehensive health check |
|
||||
| `/health` | GET | Comprehensive health check (includes a `tui` block for TUI-mode drift/concurrency monitoring) |
|
||||
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||
| `/status` | GET | Combined overview (usage + health) |
|
||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||
@@ -873,12 +891,16 @@ Future `ocp update` invocations sync automatically.
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
||||
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
|
||||
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
||||
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
||||
|
||||
### Streaming heartbeat
|
||||
|
||||
@@ -942,7 +964,27 @@ Then restart OCP. At boot you will see:
|
||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### Monitoring drift via `/health`
|
||||
|
||||
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Kill-switch
|
||||
|
||||
@@ -953,6 +995,13 @@ unset CLAUDE_TUI_MODE
|
||||
|
||||
The stream-json path is restored immediately. No other change is needed.
|
||||
|
||||
### 2026-06-15 operator checklist
|
||||
|
||||
Every host serving traffic must be flipped to TUI-mode **and** canary-verified before 2026-06-15, or it will bill the metered Agent SDK credit pool instead of the subscription.
|
||||
|
||||
- **[Flip/rollback runbook](docs/runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
|
||||
- **[615-canary runbook](docs/runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
|
||||
|
||||
### Architecture and design decisions
|
||||
|
||||
See [`docs/adr/0007-tui-interactive-mode.md`](docs/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.
|
||||
|
||||
+22
-15
@@ -132,6 +132,10 @@ function fmtChars(n) {
|
||||
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
function barColor(pct) {
|
||||
if (pct >= 80) return "bar-red";
|
||||
if (pct >= 50) return "bar-amber";
|
||||
@@ -144,8 +148,8 @@ async function refreshStatus() {
|
||||
const r = data.requests || {};
|
||||
|
||||
document.getElementById("status-cards").innerHTML = `
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${escapeHtml(p.status || '?')}</span></div><div class="sub">v${escapeHtml(p.version || '?')}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${escapeHtml(p.uptime || '?')}</div></div>
|
||||
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
|
||||
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
|
||||
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
|
||||
@@ -160,15 +164,15 @@ async function refreshStatus() {
|
||||
document.getElementById("plan-cards").innerHTML = `
|
||||
<div class="card">
|
||||
<div class="label">Session (5h)</div>
|
||||
<div class="value">${s.percent || '?'}</div>
|
||||
<div class="value">${escapeHtml(s.percent || '?')}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
|
||||
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
|
||||
<div class="sub">Resets in ${escapeHtml(s.resetsIn || '?')}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Weekly (7d)</div>
|
||||
<div class="value">${w.percent || '?'}</div>
|
||||
<div class="value">${escapeHtml(w.percent || '?')}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
|
||||
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
|
||||
<div class="sub">Resets in ${escapeHtml(w.resetsIn || '?')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -181,21 +185,21 @@ async function refreshUsage() {
|
||||
const tbody = document.querySelector("#key-usage-table tbody");
|
||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.key_name}</td>
|
||||
<td>${escapeHtml(k.key_name)}</td>
|
||||
<td>${k.requests}</td>
|
||||
<td>${k.successes}</td>
|
||||
<td>${k.errors}</td>
|
||||
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
||||
<td class="mono">${k.last_request || '-'}</td>
|
||||
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
|
||||
</tr>
|
||||
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
||||
|
||||
const rtbody = document.querySelector("#recent-table tbody");
|
||||
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
||||
<tr>
|
||||
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
|
||||
<td>${r.key_name}</td>
|
||||
<td>${r.model}</td>
|
||||
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
|
||||
<td>${escapeHtml(r.key_name)}</td>
|
||||
<td>${escapeHtml(r.model)}</td>
|
||||
<td>${fmtChars(r.prompt_chars)}</td>
|
||||
<td>${fmtChars(r.response_chars)}</td>
|
||||
<td>${fmtTime(r.elapsed_ms)}</td>
|
||||
@@ -216,13 +220,16 @@ async function refreshKeys() {
|
||||
const tbody = document.querySelector("#keys-table tbody");
|
||||
tbody.innerHTML = (data.keys || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.name}</td>
|
||||
<td class="mono">${k.keyPreview}</td>
|
||||
<td class="mono">${k.created_at}</td>
|
||||
<td>${escapeHtml(k.name)}</td>
|
||||
<td class="mono">${escapeHtml(k.keyPreview)}</td>
|
||||
<td class="mono">${escapeHtml(k.created_at)}</td>
|
||||
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" data-revoke="${escapeHtml(k.name)}">Revoke</button>`}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
|
||||
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
|
||||
);
|
||||
} catch(e) { /* not admin */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,101 @@ B-path is **deferred** and is not implemented in this ADR. Until B-path lands, T
|
||||
|
||||
---
|
||||
|
||||
## Observability and concurrency (PR-B amendment)
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Accepted — amends ADR 0007.
|
||||
**Motivation:** the post-PR-A code audit, findings C-4 (P1) and C-5 (P1).
|
||||
|
||||
### C-4 — independent concurrency bound for the TUI path
|
||||
|
||||
The global `MAX_CONCURRENT` gate lives in `spawnClaudeProcess()` (the `-p` / stream-json
|
||||
path). `callClaudeTui()` never calls `spawnClaudeProcess` — it calls `runTuiTurn()`, which
|
||||
cold-boots a full interactive `claude` inside a fresh tmux session. So the TUI path had **no**
|
||||
concurrency bound: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||
processes. On a small host (e.g. a Pi 4 serving a family) a burst of ~5 is an OOM risk and
|
||||
also multiplies subscription rate-limit pressure.
|
||||
|
||||
PR-B adds an **independent** limiter for the TUI path (`lib/tui/semaphore.mjs`,
|
||||
`TuiSemaphore`):
|
||||
|
||||
- **`OCP_TUI_MAX_CONCURRENT`, default `2`.** Rationale: a TUI turn is heavy — a per-request
|
||||
cold-boot of tmux+claude plus up to `CLAUDE_TUI_WALLCLOCK_MS` (120 s) of wallclock — so a
|
||||
small host cannot run many at once. `2` is the conservative default that keeps a Pi-class
|
||||
host alive under a family burst while still allowing some overlap. It is deliberately **not**
|
||||
the same knob as `MAX_CONCURRENT` (default 8): the two pools have different shapes (a
|
||||
stream-json spawn is cheap and fast; a TUI turn is a heavy cold-boot + long wallclock), so
|
||||
coupling them would mis-size one of the two paths.
|
||||
- **Queue, don't reject.** The limiter **queues** (awaits a slot), mirroring the spirit of
|
||||
`MAX_CONCURRENT` — requests are not dropped on contention. To bound memory against a runaway
|
||||
client, the wait queue itself is capped (`maxQueue`, default 32× the limit); when the queue
|
||||
is full `run()` rejects with `tui_queue_full`, surfaced as a 503 — deterministic backpressure
|
||||
rather than silent OOM.
|
||||
- **Slot released in a `finally`.** `TuiSemaphore.run(fn)` releases the slot in a `finally`, so
|
||||
any throw — PR-A's honesty gates (`tui_wallclock_truncated`, `tui_upstream_error`), a
|
||||
`tui_paste_not_landed`, or a `tui_spawn_failed` — can never leak a slot.
|
||||
|
||||
This limiter has **zero effect when `TUI_MODE` is off**: `callClaudeTui` is never reached, so
|
||||
the semaphore is never entered. The default stream-json path is untouched.
|
||||
|
||||
### C-5 — operator-visible drift surface on `/health` (additive)
|
||||
|
||||
The `tui_entrypoint_mismatch` warning only reached journald. After the 2026-06-15 flip, a
|
||||
silent `sdk-cli` drift (the documented top risk in this ADR — a lost TTY flipping the
|
||||
self-classification to the metered Agent SDK pool) would drain metered credits **invisibly**.
|
||||
PR-B adds a `tui` block to the `/health` JSON response so an operator can poll it:
|
||||
|
||||
```
|
||||
tui: {
|
||||
enabled: <TUI_MODE>,
|
||||
entrypointMode: <OCP_TUI_ENTRYPOINT>, // cli | auto | off
|
||||
lastEntrypoint: <last observed cc_entrypoint, e.g. "cli", or null>,
|
||||
entrypointMismatches: <count of cli-expected-but-got-other turns>,
|
||||
inflight: <current concurrent TUI turns>,
|
||||
queued: <turns waiting for a slot>,
|
||||
maxConcurrent: <OCP_TUI_MAX_CONCURRENT>
|
||||
}
|
||||
```
|
||||
|
||||
`lastEntrypoint` is recorded and `entrypointMismatches` incremented inside `callClaudeTui` in
|
||||
the same mismatch branch that already emits the journald warning (via `recordTuiEntrypoint`).
|
||||
`inflight` / `queued` / `maxConcurrent` come from the C-4 semaphore. When `TUI_MODE` is off the
|
||||
block still appears with `enabled:false` (cheap, harmless) so the response shape is stable for
|
||||
consumers regardless of mode.
|
||||
|
||||
### ALIGNMENT authorization for the `/health` change
|
||||
|
||||
`/health` is a **grandfathered B.2 endpoint** under ADR 0006, frozen at its v3.16.4 behaviour.
|
||||
`ALIGNMENT.md`'s grandfather provision states: *"Any change to the contract (request shape,
|
||||
response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization
|
||||
request and requires either a behaviour-preserving refactor PR or its own ADR."*
|
||||
|
||||
This amendment **is** that authorization. The argument:
|
||||
|
||||
- The change is **additive**: it adds one new top-level field (`tui`) containing only new
|
||||
sub-fields. **No existing `/health` field is changed, renamed, removed, or re-typed**, and no
|
||||
existing semantics change. Existing `/health` consumers (the dashboard, `ocp-connect`,
|
||||
monitoring) read the fields they already read and are unaffected — the change is
|
||||
**behaviour-preserving** for them, which is exactly the bar the grandfather provision sets for
|
||||
a non-ADR contract change.
|
||||
- The TUI observability surface is an **intrinsic part of the TUI feature** whose authorizing
|
||||
authority is **this ADR (0007)**, not a brand-new B.2 endpoint. We are not adding a new B.2
|
||||
endpoint or a new method (which would each require their own fresh ADR under the New Class B
|
||||
endpoint procedure) — we are extending the response of an existing grandfathered endpoint with
|
||||
fields that report state owned by an ADR-0007 feature. ADR 0007 is the natural home for that
|
||||
authority, and this amendment records it explicitly.
|
||||
- `cli.js` does not perform this operation — `/health` is OCP-owned (Class B), so no `cli.js`
|
||||
citation applies; the citation is this ADR + ADR 0006 (grandfathered B.2) per
|
||||
`ALIGNMENT.md`'s Class B citation requirement.
|
||||
|
||||
### `OCP_TUI_MAX_CONCURRENT` summary
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | Max concurrent interactive TUI turns. Independent of `CLAUDE_MAX_CONCURRENT` (the stream-json path). Excess turns queue (bounded); a full queue yields a 503. |
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# 2026-06-15 Canary Runbook
|
||||
|
||||
**Purpose:** Confirm that a TUI-mode turn is billed to the **Pro/Max subscription pool** (not the Agent SDK credit pool) after Anthropic's 2026-06-15 billing split activates.
|
||||
|
||||
The billing classifier reading `cli` is **necessary but NOT sufficient** proof. (Note the naming: the value is stored in the JSONL transcript under the field name `entrypoint`, and sent to Anthropic on the wire as the `cc_entrypoint` header — they carry the same value after claude's startup classification. The commands below grep the transcript, so they match `entrypoint`.) A `cli` label tells you OCP sent the right classification; it does not tell you Anthropic billed the right pool. The only authoritative test is to observe whether the **Agent SDK credit balance** moves or not before and after the canary turn.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup in README](../../README.md#enabling-tui-mode-opt-in))
|
||||
- `tmux` installed on the host
|
||||
- No other OCP traffic during the canary (quiesce — see below)
|
||||
- Access to your Anthropic account billing page (manual step — see below)
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Quiesce the host
|
||||
|
||||
Stop any IDE or client that is actively sending requests through this OCP instance.
|
||||
|
||||
Confirm the proxy is idle:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep activeRequests
|
||||
# Expected: "activeRequests": 0
|
||||
```
|
||||
|
||||
Wait until `activeRequests` is `0` before proceeding. If you cannot quiesce (e.g. family members are actively using it), run the canary on a separate OCP instance or during a quiet window.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Read the Agent SDK credit balance BEFORE the canary
|
||||
|
||||
> **Manual step — no programmatic API available.**
|
||||
>
|
||||
> OCP's `/usage` endpoint reads `anthropic-ratelimit-unified-*` response headers from the Pro/Max plan quota (5-hour and 7-day subscription windows). These headers report **subscription usage**, not the Agent SDK credit pool balance. There is no known programmatic API to query the Agent SDK credit pool balance from outside the Anthropic web app.
|
||||
|
||||
To read the balance:
|
||||
|
||||
1. Open [https://claude.ai/settings/billing](https://claude.ai/settings/billing) (or your Anthropic Console billing page) in a browser.
|
||||
2. Find the **Agent SDK Credits** section (sometimes labeled "API Credits" or "Agent SDK usage").
|
||||
3. Note the current balance (e.g. `$18.43 remaining of $20.00`).
|
||||
|
||||
Write the value down — you will compare it after the canary turn.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Send the canary turn
|
||||
|
||||
With TUI-mode on and the host quiesced, send exactly one small request:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:3456/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
|
||||
"max_tokens": 10
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Use Haiku (the cheapest model) to minimize any hypothetical impact if the canary turns red.
|
||||
|
||||
Wait for the response to arrive completely (TUI-mode buffers the full response before returning — you will see a delay of several seconds, then the full reply).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Confirm the transcript shows `entrypoint:"cli"`
|
||||
|
||||
After the canary turn completes, inspect the most recent JSONL transcript for the billing-classifier label:
|
||||
|
||||
```bash
|
||||
# The canary was run quiesced (Step 1), so the most recent JSONL across ALL project
|
||||
# dirs IS the canary turn. We glob every projects subdir instead of recomputing
|
||||
# claude's cwd-encoding rule (it maps every "/" AND "." to "-", e.g. ~/.ocp-tui/work
|
||||
# => projects/-home-<user>--ocp-tui-work/; see lib/tui/transcript.mjs encodeCwd) —
|
||||
# a glob is robust even if that encoding changes in a future claude build.
|
||||
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||
echo "Transcript: $LATEST"
|
||||
grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1
|
||||
# Expected: "entrypoint":"cli"
|
||||
```
|
||||
|
||||
If the output shows `"entrypoint":"cli"`, the billing-classifier label is correct. If it shows `"entrypoint":"sdk-cli"`, the spawn did not get a real PTY — stop immediately and do not re-enable TUI-mode without investigation. Check `tmux new-session` manually and review ADR 0007 § spawn/PTY gate. (If the grep returns nothing, the transcript may not yet be flushed — re-run after a second, or confirm the turn completed.)
|
||||
|
||||
**Reminder: an `entrypoint:cli` label (the `cc_entrypoint=cli` wire header) is necessary but not sufficient.** It tells you OCP sent the right label to Anthropic. You must still check the credit balance in Step 5.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Re-read the Agent SDK credit balance AFTER the canary
|
||||
|
||||
Return to [https://claude.ai/settings/billing](https://claude.ai/settings/billing) and reload the page. Note the current balance again.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Green/Red decision
|
||||
|
||||
### Green (balance unchanged)
|
||||
|
||||
The Agent SDK credit balance did not decrease. The turn billed against the Pro/Max subscription pool as expected. TUI-mode is working correctly.
|
||||
|
||||
**Actions:**
|
||||
- Keep `CLAUDE_TUI_MODE=true` on this host.
|
||||
- Monitor the balance periodically for the first week to catch any delayed attribution.
|
||||
- Resume normal traffic.
|
||||
|
||||
### Red (Agent SDK credit balance decreased)
|
||||
|
||||
The Agent SDK credit balance decreased. The subscription pool is not being used for TUI-mode turns on this host, despite `cc_entrypoint=cli` being set. This may indicate a backend routing change on Anthropic's side, a TTY detection failure, or a policy change.
|
||||
|
||||
**Actions — immediate:**
|
||||
1. Unset `CLAUDE_TUI_MODE` (or set to any value other than `"true"`) in the service unit:
|
||||
- systemd: edit `/etc/ocp/ocp.env` (or the unit's `Environment=` line), then `sudo systemctl daemon-reload && sudo systemctl restart ocp.service`
|
||||
- launchd: edit the plist `EnvironmentVariables` section, then `launchctl bootout gui/$(id -u)/dev.ocp.proxy && launchctl bootstrap gui/$(id -u) <plist-path>`
|
||||
2. Restart OCP and confirm the `/health` response no longer shows TUI-mode active.
|
||||
3. If you share this OCP with family or other Max users: freeze their access temporarily until you understand the billing impact.
|
||||
4. Consider pivoting to OLP multi-provider (see [OLP](https://github.com/dtzp555-max/olp)) which can spread load across other providers to avoid the Agent SDK credit drain.
|
||||
|
||||
Per ALIGNMENT.md Rule 2 / ADR 0007 § Kill-switch: "Per the constitution, the response is to drop the Anthropic provider rather than escalate spoofing."
|
||||
|
||||
---
|
||||
|
||||
## Ongoing monitoring — self-classification mini-canary
|
||||
|
||||
To detect future drift (e.g. a claude CLI upgrade that changes TTY-detection behavior), you can run a periodic one-liner that sends a tiny TUI turn with `OCP_TUI_ENTRYPOINT=auto` (so claude self-classifies rather than having OCP pin the value) and alerts if the transcript self-classification is not `cli`:
|
||||
|
||||
```bash
|
||||
# Run with OCP temporarily configured OCP_TUI_ENTRYPOINT=auto
|
||||
# Then check the most recent transcript:
|
||||
# Glob the most recent transcript across all project dirs (robust to claude's
|
||||
# cwd-encoding rule; run this right after the auto-mode mini-canary turn).
|
||||
LATEST=$(ls -t "$HOME"/.claude/projects/*/*.jsonl 2>/dev/null | head -1)
|
||||
RESULT=$(grep -o '"entrypoint":"[^"]*"' "$LATEST" | tail -1)
|
||||
echo "Self-classified entrypoint: $RESULT"
|
||||
if echo "$RESULT" | grep -q '"entrypoint":"cli"'; then
|
||||
echo "OK — subscription pool"
|
||||
else
|
||||
echo "ALERT — not cli; check TTY and billing"
|
||||
fi
|
||||
```
|
||||
|
||||
Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's own `t$A` startup function determine the value from the actual TTY state (see ADR 0007 § Billing-classifier labeling).
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [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
|
||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||
@@ -0,0 +1,180 @@
|
||||
# TUI-Mode Flip and Rollback Runbook
|
||||
|
||||
**Purpose:** Step-by-step instructions for enabling (`CLAUDE_TUI_MODE=true`) or disabling TUI-mode on real OCP deployments managed by **systemd** (Linux) or **launchd** (macOS).
|
||||
|
||||
Run the [615-canary](./615-canary.md) runbook after any flip to confirm billing pool routing is correct.
|
||||
|
||||
---
|
||||
|
||||
## Critical pitfalls — read first
|
||||
|
||||
### systemd: `daemon-reload` is required after editing the unit
|
||||
|
||||
Editing the unit file (or EnvironmentFile) and then doing `systemctl restart ocp.service` **without** `daemon-reload` will restart the process with the **old** environment from the cached unit. Always run `daemon-reload` after editing any unit file.
|
||||
|
||||
### launchd: `launchctl kickstart -k` does NOT reload plist env
|
||||
|
||||
`launchctl kickstart -k gui/$(id -u)/dev.ocp.proxy` kills the running process and re-launches it, but it **re-uses the launchd-cached environment** — not the current plist file. If you edited the plist's `EnvironmentVariables` section, you must do a full `bootout` + `bootstrap` cycle for the change to take effect. `kickstart` is not sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Flip — enable TUI-mode
|
||||
|
||||
### systemd (Linux, e.g. Raspberry Pi, VPS)
|
||||
|
||||
**Option A — EnvironmentFile (recommended for clean separation)**
|
||||
|
||||
If your unit uses `EnvironmentFile=/etc/ocp/ocp.env` (or similar):
|
||||
|
||||
```bash
|
||||
# 1. Edit the environment file
|
||||
sudo nano /etc/ocp/ocp.env
|
||||
# Add or update:
|
||||
# CLAUDE_TUI_MODE=true
|
||||
#
|
||||
# If OCP binds to 0.0.0.0 AND you trust the network:
|
||||
# OCP_TUI_ALLOW_LAN=1
|
||||
# (WARNING: TUI-mode is single-user only — only enable OCP_TUI_ALLOW_LAN=1
|
||||
# if you fully trust every caller that can reach the OCP port on your network)
|
||||
|
||||
# 2. Reload the unit definition and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# 3. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||
# Expected: "tuiMode": true (or similar TUI indicator in the health response)
|
||||
```
|
||||
|
||||
**Option B — inline Environment= in the unit file**
|
||||
|
||||
```bash
|
||||
# 1. Edit the unit file
|
||||
sudo systemctl edit --full ocp.service
|
||||
# Add or update in the [Service] section:
|
||||
# Environment=CLAUDE_TUI_MODE=true
|
||||
|
||||
# 2. Reload and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# 3. Verify
|
||||
systemctl show ocp.service --property=Environment
|
||||
# Expected: Environment=CLAUDE_TUI_MODE=true ...
|
||||
```
|
||||
|
||||
### launchd (macOS)
|
||||
|
||||
Locate the OCP plist. The standard label is `dev.ocp.proxy`:
|
||||
|
||||
```bash
|
||||
# Find the plist path
|
||||
ls ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
**Edit the plist:**
|
||||
|
||||
```bash
|
||||
# 1. Stop the service first (bootout)
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||
|
||||
# 2. Edit the plist — add CLAUDE_TUI_MODE to EnvironmentVariables
|
||||
# Use your editor of choice:
|
||||
nano ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
Inside the plist, in the `<key>EnvironmentVariables</key>` `<dict>` block, add:
|
||||
|
||||
```xml
|
||||
<key>CLAUDE_TUI_MODE</key>
|
||||
<string>true</string>
|
||||
```
|
||||
|
||||
If `OCP_TUI_ALLOW_LAN=1` is also needed (only if OCP binds to `0.0.0.0` and you trust the network):
|
||||
|
||||
```xml
|
||||
<key>OCP_TUI_ALLOW_LAN</key>
|
||||
<string>1</string>
|
||||
```
|
||||
|
||||
```bash
|
||||
# 3. Bootstrap (reload from disk + start)
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
|
||||
# 4. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep -E "tui|version"
|
||||
```
|
||||
|
||||
**Confirm env was actually loaded** (not just set in your shell):
|
||||
|
||||
```bash
|
||||
ps aux | grep server.mjs | grep -v grep
|
||||
# Get the PID, then:
|
||||
# macOS: ps -E -p <PID> | tr ' ' '\n' | grep CLAUDE_TUI_MODE
|
||||
# Expected: CLAUDE_TUI_MODE=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback — disable TUI-mode
|
||||
|
||||
Rollback is the same procedure as flip, but you **remove** `CLAUDE_TUI_MODE` or set it to any value other than `"true"` (e.g. `false`, or simply omit it).
|
||||
|
||||
After rollback, OCP returns to the default `callClaude` / `callClaudeStreaming` stream-json path — byte-for-byte identical to the pre-TUI code path. No other change is required.
|
||||
|
||||
### systemd rollback
|
||||
|
||||
```bash
|
||||
# Option A — EnvironmentFile
|
||||
sudo nano /etc/ocp/ocp.env
|
||||
# Remove or comment out:
|
||||
# CLAUDE_TUI_MODE=true
|
||||
# OCP_TUI_ALLOW_LAN=1 (if set)
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ocp.service
|
||||
|
||||
# Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||
# Expected: "tuiMode": false (or the field absent)
|
||||
```
|
||||
|
||||
### launchd rollback
|
||||
|
||||
```bash
|
||||
# 1. Stop
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy
|
||||
|
||||
# 2. Edit plist — remove the CLAUDE_TUI_MODE and OCP_TUI_ALLOW_LAN entries from EnvironmentVariables
|
||||
|
||||
# 3. Bootstrap
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
|
||||
# 4. Verify
|
||||
curl -s http://127.0.0.1:3456/health | python3 -m json.tool | grep tui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Billing impact of staying on the default (non-TUI) path after 2026-06-15
|
||||
|
||||
If you do NOT flip to TUI-mode and keep `CLAUDE_TUI_MODE` unset (the default), OCP continues using `claude -p --output-format stream-json`, which sets `cc_entrypoint=sdk-cli`. After 2026-06-15, every OCP request on the default path will draw from the Agent SDK credit pool (approximately $20/month on a Pro plan, or $100/month on a Max plan) rather than the Pro/Max subscription. The subscription pool usage (5-hour and 7-day windows) will be unaffected, but the Agent SDK credit balance will drain with each request.
|
||||
|
||||
If you want to continue using OCP without TUI-mode after 2026-06-15, budget for the Agent SDK credit cost accordingly — or switch to [OLP](https://github.com/dtzp555-max/olp) for multi-provider fallback.
|
||||
|
||||
---
|
||||
|
||||
## Verify after any flip
|
||||
|
||||
1. Check `/health` shows the expected `tuiMode` state.
|
||||
2. Run the [615-canary](./615-canary.md) to confirm billing pool routing.
|
||||
3. If TUI-mode is ON: check `ocp logs 10` for any TUI spawn errors (`tui_spawn_failed`, tmux errors).
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [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
|
||||
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
|
||||
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
|
||||
@@ -0,0 +1,9 @@
|
||||
// OCP network helpers — shared so server.mjs and tests use one definition. (issue #125)
|
||||
|
||||
// A bind address is "loopback" only if it cannot be reached from another host.
|
||||
// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is
|
||||
// network-exposed and must trigger the TUI LAN gate.
|
||||
export function isLoopbackBind(addr) {
|
||||
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" ||
|
||||
addr === "::ffff:127.0.0.1" || /^127\./.test(addr);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb2222-3333-4444-5555-666677778888","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid authentication credentials"}]}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"What is 2 + 2?"}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"aaaa1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"Please run /login · API Error: 401 Invalid authentication credentials"}]}}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"type":"user","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"user","content":[{"type":"text","text":"Say PONG and nothing else."}]}}
|
||||
{"type":"assistant","entrypoint":"cli","cwd":"/tmp/tui-test","sessionId":"bbbb1111-2222-3333-4444-555566667777","version":"2.1.104","message":{"role":"assistant","model":"claude-haiku-4-5-20251001","stop_reason":"end_turn","content":[{"type":"text","text":"PONG"}]}}
|
||||
@@ -0,0 +1,102 @@
|
||||
// TUI-path concurrency limiter (audit finding C-4).
|
||||
//
|
||||
// WHY THIS EXISTS, SEPARATE FROM server.mjs's MAX_CONCURRENT:
|
||||
// The global MAX_CONCURRENT gate lives in spawnClaudeProcess() (the -p / stream-json
|
||||
// path). callClaudeTui() NEVER calls spawnClaudeProcess — it calls runTuiTurn(), which
|
||||
// boots a full interactive `claude` inside a fresh tmux session. So nothing bounded the
|
||||
// TUI path: N concurrent TUI requests spawned N simultaneous cold-boot tmux+claude
|
||||
// processes. On a small host (a Pi 4 serving a family) a burst of ~5 is an OOM risk, and
|
||||
// it also multiplies subscription rate-limit pressure. This is an INDEPENDENT limiter for
|
||||
// the TUI path that mirrors MAX_CONCURRENT's intent without coupling to it (the two pools
|
||||
// are different shapes: a stream-json spawn is cheap and fast; a TUI turn is a heavy
|
||||
// cold-boot + up to 120s wallclock).
|
||||
//
|
||||
// QUEUE vs REJECT: we QUEUE (await a slot), mirroring the spirit of MAX_CONCURRENT's
|
||||
// intent not to drop requests, rather than rejecting immediately. To avoid unbounded
|
||||
// memory growth from a runaway client, the wait queue itself is bounded by maxQueue
|
||||
// (default: a generous multiple of the concurrency limit). When the queue is full, run()
|
||||
// rejects with a tui_queue_full error (the caller surfaces it as a 503) — a deterministic
|
||||
// backpressure signal rather than silent OOM.
|
||||
//
|
||||
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
|
||||
|
||||
export class TuiSemaphore {
|
||||
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
|
||||
constructor(limit, { maxQueue } = {}) {
|
||||
this.limit = Math.max(1, parseInt(limit, 10) || 1);
|
||||
// Default queue cap: 32× the limit. Large enough that real family-burst traffic never
|
||||
// hits it, small enough that a pathological flood can't grow the queue without bound.
|
||||
this.maxQueue = Number.isFinite(maxQueue) ? maxQueue : this.limit * 32;
|
||||
this._inflight = 0;
|
||||
this._waiters = []; // FIFO queue of resolve callbacks waiting for a slot
|
||||
}
|
||||
|
||||
get inflight() { return this._inflight; }
|
||||
get queued() { return this._waiters.length; }
|
||||
|
||||
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
|
||||
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
|
||||
acquire() {
|
||||
if (this._inflight < this.limit) {
|
||||
this._inflight++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._waiters.length >= this.maxQueue) {
|
||||
return Promise.reject(new Error(
|
||||
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
|
||||
`(${this.maxQueue}) is full`));
|
||||
}
|
||||
return new Promise((resolve) => { this._waiters.push(resolve); });
|
||||
}
|
||||
|
||||
// Release a slot. If a waiter is queued, hand the slot directly to it (inflight stays
|
||||
// constant across the handoff); otherwise decrement.
|
||||
release() {
|
||||
const next = this._waiters.shift();
|
||||
if (next) {
|
||||
next(); // the woken waiter already "owns" the slot — inflight unchanged
|
||||
} else if (this._inflight > 0) {
|
||||
this._inflight--;
|
||||
}
|
||||
}
|
||||
|
||||
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
|
||||
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
|
||||
async run(fn) {
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TUI drift observability (audit C-5) — pure helpers, importable for testing ──
|
||||
|
||||
// Record an observed cc_entrypoint into the (mutable) tuiStats counter. Sets lastEntrypoint
|
||||
// unconditionally and increments entrypointMismatches when the spawn was supposed to be
|
||||
// subscription-pool ("cli") but the transcript reported something else (a silent drift to
|
||||
// the metered Agent SDK pool — the audit's top risk after the 6/15 billing flip).
|
||||
// Returns true iff this observation was a mismatch (so the caller can also emit a log).
|
||||
export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||
tuiStats.lastEntrypoint = observed ?? null;
|
||||
const mismatch = expectedMode === "cli" && observed !== "cli";
|
||||
if (mismatch) tuiStats.entrypointMismatches++;
|
||||
return mismatch;
|
||||
}
|
||||
|
||||
// Build the additive /health `tui` block (ADR 0007 PR-B amendment). Pure: given the
|
||||
// config + live counters, returns the exact object embedded in /health. New fields only —
|
||||
// behaviour-preserving for existing /health consumers (grandfathered B.2 under ADR 0006).
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore) {
|
||||
return {
|
||||
enabled,
|
||||
entrypointMode, // cli | auto | off
|
||||
lastEntrypoint: tuiStats.lastEntrypoint, // last observed cc_entrypoint, or null
|
||||
entrypointMismatches: tuiStats.entrypointMismatches,
|
||||
inflight: semaphore.inflight, // current concurrent TUI turns
|
||||
queued: semaphore.queued, // turns waiting for a slot
|
||||
maxConcurrent,
|
||||
};
|
||||
}
|
||||
+152
-26
@@ -39,11 +39,56 @@ export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||
|
||||
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
|
||||
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Capture the visible tmux pane as plain text (for readiness / paste verification).
|
||||
function tuiCapturePane(tmux, tmuxName) {
|
||||
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
|
||||
return (r && typeof r.stdout === "string") ? r.stdout : "";
|
||||
}
|
||||
|
||||
// True once claude's input bar is rendered and ready for keystrokes.
|
||||
function tuiInputReady(pane) {
|
||||
return /\? for shortcuts/.test(pane);
|
||||
}
|
||||
|
||||
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
|
||||
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
|
||||
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
|
||||
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
|
||||
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
|
||||
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
|
||||
// (b) the prompt's own leading text appears in the pane (short/literal paste).
|
||||
function tuiPromptLanded(pane, prompt) {
|
||||
const flatPane = pane.replace(/\s+/g, " ");
|
||||
if (flatPane.includes("[Pasted text")) return true;
|
||||
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||
// C-4/#133: threshold lowered 3 → 2. A prompt whose first non-blank line is 1–2
|
||||
// chars ("hi", "ok") previously NEVER matched (needle.length >= 3) and never
|
||||
// surfaced "[Pasted text", so EVERY short prompt 5s-failed with tui_paste_not_landed
|
||||
// (live-reproduced: "hi"). The input box starts EMPTY (the curly-quote placeholder
|
||||
// is excluded by the affirmative-signal design above), so a >=2-char needle present
|
||||
// in the pane is the pasted prompt, not placeholder noise — false-positive risk is
|
||||
// low. We keep >=2 rather than >=1 because a single visible char is more likely to
|
||||
// collide with incidental glyphs in claude's chrome (borders, the "❯" prompt mark);
|
||||
// 2 chars is the floor that lands real prompts while staying conservative.
|
||||
return needle.length >= 2 && flatPane.includes(needle);
|
||||
}
|
||||
|
||||
async function pollUntil(fn, { timeoutMs, intervalMs }) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try { if (fn()) return true; } catch { /* ignore, keep polling */ }
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single-quote escaper for sh -c arguments.
|
||||
function shq(s) {
|
||||
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||
@@ -149,25 +194,88 @@ export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
||||
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
||||
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
||||
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
||||
function buildTuiCmd(claudeBin, model, sessionId) {
|
||||
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
|
||||
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
|
||||
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
|
||||
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
|
||||
// passing {env} to spawnSync left the pane with only HOME). DISABLE_AUTOUPDATER pins the version
|
||||
// (no "What's new" splash that delayed input-readiness); CLAUDE_CODE_ENTRYPOINT labels the
|
||||
// billing pool (set below per entrypointMode).
|
||||
//
|
||||
// CLAUDE_CODE_DISABLE_CLAUDE_MDS + DISABLE_AUTO_MEMORY: OCP is a PROXY, not a Claude Code
|
||||
// session. The proxied client (OpenClaw / an IDE) owns its own context and memory; the HOST's
|
||||
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf.
|
||||
// Without these, claude loads the host's project/user CLAUDE.md + memory into every proxied
|
||||
// turn — verified live 2026-06-02: a cwd CLAUDE.md ("end every reply with QUACKMARKER_42") was
|
||||
// obeyed by the proxied turn until these flags were set, after which it was not. Unconditional
|
||||
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md
|
||||
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars.
|
||||
const sets = [
|
||||
`HOME=${shq(ehome)}`,
|
||||
"DISABLE_AUTOUPDATER=1",
|
||||
"CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL=1",
|
||||
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
||||
];
|
||||
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
||||
const envPrefix = ["env", ...unset.map((u) => `-u ${u}`), ...sets].join(" ");
|
||||
|
||||
// Tool surface.
|
||||
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
|
||||
// built-in tools stay on, acceptable for single-user A-path.
|
||||
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
|
||||
// (--allowedTools [+ --mcp-config] [+ --dangerously-skip-permissions]), so a
|
||||
// SINGLE-USER / trusted TUI deployment can run a tool-using agent (e.g. an OpenClaw
|
||||
// assistant that needs Bash/Read/Write/MCP) on the subscription pool. This mirrors
|
||||
// buildCliArgs() in server.mjs. Safe to gate ON only because TUI is hard-incompatible
|
||||
// with AUTH_MODE=multi (server.mjs refuses to boot), so it can never widen a guest's
|
||||
// surface. Env mirrors server.mjs's CLAUDE_ALLOWED_TOOLS / _SKIP_PERMISSIONS / _MCP_CONFIG.
|
||||
let toolArgs;
|
||||
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
|
||||
toolArgs = [];
|
||||
if (process.env.CLAUDE_SKIP_PERMISSIONS === "true") {
|
||||
toolArgs.push("--dangerously-skip-permissions");
|
||||
} else {
|
||||
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
|
||||
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
|
||||
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
|
||||
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
|
||||
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
|
||||
}
|
||||
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
|
||||
} else {
|
||||
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
|
||||
}
|
||||
return [
|
||||
envPrefix,
|
||||
shq(claudeBin),
|
||||
"--model", shq(model),
|
||||
"--session-id", sessionId,
|
||||
"--strict-mcp-config",
|
||||
"--disallowedTools", shq("mcp__*"),
|
||||
...toolArgs,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd.
|
||||
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key
|
||||
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does).
|
||||
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
||||
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||
// marker or wall-clock cap.
|
||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
@@ -213,34 +321,52 @@ export async function runTuiTurn({
|
||||
// is a no-op when the session never existed).
|
||||
const spawnResult = tmux(
|
||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sessionId)],
|
||||
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||
{ env },
|
||||
);
|
||||
if (!spawnResult || spawnResult.status !== 0) {
|
||||
throw new Error("tui_spawn_failed: tmux session not created");
|
||||
}
|
||||
await sleep(BOOT_MS);
|
||||
|
||||
// 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content —
|
||||
// then settle, then send a SEPARATE Enter key event to submit the line.
|
||||
//
|
||||
// The `-l` (literal) flag is required on the paste send-keys call so that
|
||||
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape")
|
||||
// is typed literally as text rather than being interpreted as a key binding.
|
||||
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a
|
||||
// real keypress (carriage return) to submit the prompt line.
|
||||
spawnSync(
|
||||
"sh",
|
||||
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`],
|
||||
{ env, encoding: "utf8" },
|
||||
);
|
||||
await sleep(PASTE_SETTLE_MS);
|
||||
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||
if (!ready) {
|
||||
// (readiness timed out; relying on paste-verify)
|
||||
console.error("[tui] input_not_ready", tmuxName);
|
||||
}
|
||||
|
||||
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
||||
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
|
||||
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
|
||||
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
|
||||
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
|
||||
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
|
||||
// per-session tmuxName, so concurrent turns never collide.
|
||||
tmux(["load-buffer", "-b", tmuxName, promptFile]);
|
||||
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
|
||||
|
||||
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
|
||||
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
|
||||
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
|
||||
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
|
||||
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
|
||||
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
|
||||
if (!landed) {
|
||||
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
|
||||
}
|
||||
|
||||
// Submit (separate Enter key event).
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 3. Block on the native transcript (resolved by session-id) until terminal.
|
||||
// 4. Block on the native transcript (resolved by session-id) until terminal.
|
||||
// Returns { text, entrypoint } from readTuiTranscript.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
} finally {
|
||||
// 4. Teardown — always, even on throw.
|
||||
// 5. Teardown — always, even on throw.
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
+184
-20
@@ -51,19 +51,29 @@ export function parseTranscriptLines(text) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// A line marks the assistant turn complete when it is the turn_duration system
|
||||
// event. That is the ONLY reliable terminal marker in interactive TUI mode.
|
||||
// A line marks the assistant turn complete when EITHER:
|
||||
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
|
||||
// (e.g. 2.1.159), OR
|
||||
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
|
||||
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
|
||||
// end-of-turn signal, present across claude builds whose transcripts do NOT
|
||||
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
|
||||
// it OCP can't detect completion on those builds and hangs to the wallclock,
|
||||
// then returns only partial text (issue #130, cloud/server-side symptom).
|
||||
//
|
||||
// Why tool_use is NOT a terminal marker:
|
||||
// In interactive claude, when the model decides to call a tool (stop_reason=
|
||||
// "tool_use"), claude handles the tool call internally and then continues
|
||||
// generating — the turn is NOT complete. The transcript advances to another
|
||||
// assistant entry after the tool result. Only {type:"system",
|
||||
// subtype:"turn_duration"} signals that claude has fully finished the turn.
|
||||
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
|
||||
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
||||
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
||||
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
||||
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
||||
// cross-version completion detection without bringing that bug back.)
|
||||
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
||||
export function isTerminalLine(obj) {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
return obj.type === "system" && obj.subtype === "turn_duration";
|
||||
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||
if (obj.type === "assistant" && obj.message && typeof obj.message === "object") {
|
||||
return TERMINAL_STOP_REASONS.has(obj.message.stop_reason);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||
@@ -90,22 +100,169 @@ export function extractLatestAssistantText(events) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
|
||||
// Returns the entrypoint string (e.g. "cli") used for the billing-pool assertion,
|
||||
// or null if absent. Lets callers assert the subscription-classified path.
|
||||
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
||||
//
|
||||
// Resolution order (C-3, issue #133):
|
||||
// 1. PREFER the turn_duration system line's `entrypoint` — the authoritative
|
||||
// end-of-turn classifier emitted by builds that produce turn_duration
|
||||
// (e.g. claude-2.1.104/2.1.157 on PI231).
|
||||
// 2. FALL BACK to the `entrypoint` field on ANY ordinary transcript line
|
||||
// (assistant / user / attachment / system) — present on BOTH emitting and
|
||||
// non-emitting builds. Some claude builds (e.g. certain Mac mini transcripts)
|
||||
// do NOT emit a turn_duration line at all; reading ONLY turn_duration made the
|
||||
// caller's tui_entrypoint_mismatch assertion (server.mjs) get got:null every
|
||||
// turn and go blind. The entrypoint value is identical across line types within
|
||||
// a single interactive session (fixture-confirmed: every line in
|
||||
// complete-haiku.jsonl carrying `entrypoint` reads "cli"), so the fallback
|
||||
// yields the same classifier. Last-writer-wins on the fallback.
|
||||
export function verifyEntrypoint(events) {
|
||||
let fallback = null;
|
||||
for (const ev of events) {
|
||||
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
||||
return ev.entrypoint != null ? ev.entrypoint : null;
|
||||
if (!ev || typeof ev !== "object") continue;
|
||||
if (ev.type === "system" && ev.subtype === "turn_duration" && ev.entrypoint != null) {
|
||||
return ev.entrypoint; // authoritative — short-circuit
|
||||
}
|
||||
if (ev.entrypoint != null) fallback = ev.entrypoint;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ── C-1: honest AUTH-FAILURE banner detection (issue #133) ───────────────
|
||||
// When the interactive `claude` CLI hits an in-session error it does NOT crash —
|
||||
// it renders the error as ordinary assistant text in the transcript. The specific
|
||||
// failure C-1 exists to catch is R-1: EXPIRED / INVALID credentials, where every
|
||||
// turn comes back as the same one-line auth-failure banner and OCP, none the wiser,
|
||||
// caches that banner (server.mjs setCachedResponse), shares it via singleflight, and
|
||||
// records a model SUCCESS — so a hard auth error is silently served (and cached for
|
||||
// the 5-min TTL) as a real answer. The two live-reproduced banners on PI231
|
||||
// (2026-06-10) are:
|
||||
// "Please run /login · API Error: 401 Invalid authentication credentials" (69 chars)
|
||||
// "Failed to authenticate. API Error: 401 Invalid authentication credentials" (73 chars)
|
||||
//
|
||||
// WHY THE SCOPE IS NARROW (conservatism — the load-bearing design choice).
|
||||
// An earlier generalised rule (^<short-prefix>?API Error:\s*\d{3}\b.*$) was TOO
|
||||
// BROAD: its unbounded `.*` tail let any short prefix + "API Error: NNN" + an
|
||||
// arbitrarily long sentence match, so it KILLED legitimate long answers that merely
|
||||
// DISCUSS an API error (e.g. "API Error: 500 happened because the server was
|
||||
// overloaded. To fix this, retry with exponential backoff …"). That is the worst
|
||||
// outcome: a false-positive costs the user a missing answer AND a double-burn retry,
|
||||
// whereas the rare false-negative (caching one transient error for the 5-min TTL) is
|
||||
// cheap and self-healing. So C-1 is reframed from "detect ANY API error" to "detect
|
||||
// a claude-CLI AUTHENTICATION-FAILURE banner", and when unsure it PASSES (does not
|
||||
// kill). Transient 5xx server errors are deliberately NOT detected — they are not the
|
||||
// R-1 case and the conservative choice is to let them through.
|
||||
//
|
||||
// THE SIGNAL — a turn is an auth-failure banner only if ALL of these hold over the
|
||||
// WHOLE trimmed assistant text (a conjunction; any one failing => PASS):
|
||||
// 1. SHORT whole-message. Real banners are one short line (the two live samples are
|
||||
// 69 and 73 chars). Cap = TUI_ERR_MAX_LEN (100) — headroom over 73 for a
|
||||
// slightly longer future banner, while still rejecting multi-sentence prose. A
|
||||
// long answer that happens to discuss auth (no code chars, e.g. 226 chars) is
|
||||
// rejected on length alone.
|
||||
// 2. Contains "API Error: 4\d{2}" — auth failures are 4xx (401/403). This rejects
|
||||
// transient 5xx ("API Error: 500/503 …") and bare "HTTP 401 means unauthorized."
|
||||
// (no "API Error:" core).
|
||||
// 3. Contains an auth KEYWORD — authenticat | /login | credential (case-insensitive).
|
||||
// This rejects answers that quote a 4xx but are not auth banners, e.g.
|
||||
// "To debug a 401: the server returns API Error: 401 Unauthorized …"
|
||||
// ("Unauthorized" is authoriz-, not authenticat-; no /login, no credential).
|
||||
// 4. Contains NO backtick or quote char (` ' "). A real CLI banner is plain text;
|
||||
// backticked/quoted text signals an answer that is QUOTING the error rather than
|
||||
// being the banner, e.g. "You'll see `API Error: 401` … run /login to fix it."
|
||||
// (75 chars — passes 1-3 but is excluded here). This is the conservative tie-
|
||||
// breaker for short instructional answers.
|
||||
//
|
||||
// Worked matrix (all required cases pass — see test-features.mjs C-1 block):
|
||||
// KILL: "Please run /login · API Error: 401 Invalid authentication credentials"
|
||||
// KILL: "Failed to authenticate. API Error: 401 Invalid authentication credentials"
|
||||
// PASS: "API Error: 500 happened because the server was overloaded. …" (not 4xx)
|
||||
// PASS: "Failed to parse the config. Here are the API Error: 401 details …" (too long + no auth-kw)
|
||||
// PASS: "To debug a 401: … API Error: 401 Unauthorized, then you refresh …" (no auth-kw)
|
||||
// PASS: "Here is the handler … It logs the string API Error: 503 …" (not 4xx)
|
||||
// PASS: "You'll see `API Error: 401` … run /login to fix it." (has backtick)
|
||||
// PASS: "HTTP 401 means unauthorized." (no API Error core)
|
||||
// PASS: "The capital of France is Paris." (nothing matches)
|
||||
//
|
||||
// OPERATOR OVERRIDE (unchanged): CLAUDE_TUI_ERROR_PATTERNS lets an operator REPLACE
|
||||
// the default auth-banner detector with their own newline- or `||`-separated JS regex
|
||||
// source strings (each auto-anchored ^…$ over the trimmed text, case-insensitive). A
|
||||
// non-empty override uses ONLY those regexes (the narrowed default is bypassed); an
|
||||
// empty / whitespace-only override DISABLES detection entirely (escape hatch).
|
||||
|
||||
// Whole-message length cap for the default auth-banner detector. Real banners are
|
||||
// 69/73 chars; 100 gives headroom while still rejecting multi-sentence prose.
|
||||
const TUI_ERR_MAX_LEN = 100;
|
||||
// 4xx "API Error:" core — auth failures are 4xx (401/403), never 5xx.
|
||||
const TUI_ERR_4XX = /API Error:\s*4\d{2}\b/i;
|
||||
// Auth keyword — the message must be about authentication, not just quote a 4xx.
|
||||
const TUI_ERR_AUTH_KW = /authenticat|\/login|credential/i;
|
||||
// Code/quote chars — their presence signals prose QUOTING an error, not the banner.
|
||||
const TUI_ERR_CODE_CHAR = /[`'"]/;
|
||||
|
||||
// Default detector: returns true iff `trimmed` IS a claude-CLI auth-failure banner
|
||||
// (all four signals above). Conservative — any signal failing => false (PASS).
|
||||
function isDefaultAuthFailureBanner(trimmed) {
|
||||
if (trimmed.length > TUI_ERR_MAX_LEN) return false; // 1. short whole-message
|
||||
if (!TUI_ERR_4XX.test(trimmed)) return false; // 2. 4xx API Error core
|
||||
if (!TUI_ERR_AUTH_KW.test(trimmed)) return false; // 3. auth keyword
|
||||
if (TUI_ERR_CODE_CHAR.test(trimmed)) return false; // 4. no code/quote chars
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compile an OPERATOR-SUPPLIED pattern set (override path only). Each source is
|
||||
// anchored ^…$ over the trimmed text and matched case-insensitively (`s` so `.` spans
|
||||
// a multi-line banner). A pattern that fails to compile is skipped (never throws into
|
||||
// the request path).
|
||||
function compileTuiErrorPatterns(raw) {
|
||||
const sources = String(raw).split(/\r?\n|\|\|/).map((s) => s.trim()).filter(Boolean);
|
||||
const out = [];
|
||||
for (const src of sources) {
|
||||
try { out.push(new RegExp(`^(?:${src})$`, "is")); } catch { /* skip bad pattern */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Returns the matched banner text (the trimmed assistant text) if `text` IS a claude-
|
||||
// CLI auth-failure banner in its entirety, else null. `patternsRaw` defaults to
|
||||
// process.env.CLAUDE_TUI_ERROR_PATTERNS:
|
||||
// - undefined → narrowed default auth-banner detector (isDefaultAuthFailureBanner).
|
||||
// - non-empty → operator regex override REPLACES the default.
|
||||
// - empty/ws → detection disabled (escape hatch).
|
||||
export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TUI_ERROR_PATTERNS) {
|
||||
if (typeof text !== "string") return null;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
if (patternsRaw == null) {
|
||||
return isDefaultAuthFailureBanner(trimmed) ? trimmed : null;
|
||||
}
|
||||
// Operator override path: empty/whitespace disables; otherwise use only their regexes.
|
||||
const patterns = compileTuiErrorPatterns(patternsRaw);
|
||||
if (patterns.length === 0) return null;
|
||||
for (const re of patterns) {
|
||||
if (re.test(trimmed)) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block until the session transcript is terminal (turn_duration) or
|
||||
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||
// partial text; on cap with no text at all, throws.
|
||||
// Block until the session transcript is terminal (turn_duration / final
|
||||
// stop_reason) or the wall-clock cap elapses, polling the file (no fs.watch —
|
||||
// robust over NFS / editors). Returns { text, entrypoint, truncated }:
|
||||
// - text: latest assistant text.
|
||||
// - entrypoint: billing-pool classifier (see verifyEntrypoint), or null.
|
||||
// - truncated: FALSE when a terminal marker was reached (the turn completed);
|
||||
// TRUE when the wall-clock cap was hit with partial text but NO
|
||||
// terminal marker (the turn is INCOMPLETE — what we have is a
|
||||
// cut-off prefix). (C-2, issue #133.)
|
||||
//
|
||||
// Why `truncated` matters: previously the terminal-marker path and the
|
||||
// cap-with-partial-text path BOTH returned `{text, entrypoint}` identically, so
|
||||
// callClaudeTui could not tell a complete answer from a truncated one and cached +
|
||||
// returned the partial as finish_reason:stop (silent success). The caller now
|
||||
// throws on `truncated` so a cut-off turn is neither cached nor counted as success.
|
||||
// The field is additive — existing call sites that ignore it keep working.
|
||||
//
|
||||
// On cap with NO text at all, still throws (unchanged) — there is nothing to return.
|
||||
//
|
||||
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||
@@ -115,15 +272,22 @@ export function verifyEntrypoint(events) {
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||
const deadline = Date.now() + wallclockMs;
|
||||
let lastText = "";
|
||||
let lastEntrypoint = null;
|
||||
while (Date.now() < deadline) {
|
||||
const resolved = p || findTranscriptPath(home, sessionId);
|
||||
if (resolved && existsSync(resolved)) {
|
||||
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||
lastText = extractLatestAssistantText(events) || lastText;
|
||||
if (events.some(isTerminalLine)) return lastText;
|
||||
const ep = verifyEntrypoint(events);
|
||||
if (ep != null) lastEntrypoint = ep;
|
||||
// Terminal marker reached → the turn is COMPLETE.
|
||||
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint, truncated: false };
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
if (lastText) return lastText;
|
||||
// Cap elapsed with no terminal marker. If we have partial text, flag it truncated
|
||||
// so the caller rejects it (don't cache / don't count as success). No text at all
|
||||
// → throw (nothing to return).
|
||||
if (lastText) return { text: lastText, entrypoint: lastEntrypoint, truncated: true };
|
||||
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||
}
|
||||
|
||||
+9
-5
@@ -506,9 +506,11 @@ main() {
|
||||
echo ""
|
||||
|
||||
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
||||
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
|
||||
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
|
||||
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
|
||||
# The server advertises anonymousKey in /health ONLY when the admin has set
|
||||
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
|
||||
# advertising exposes the shared key to any LAN-reachable device; issue #109).
|
||||
# Localhost callers always receive it regardless. When the field is absent,
|
||||
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
|
||||
if [[ -z "$key" ]]; then
|
||||
local anon_key
|
||||
anon_key=$(echo "$health_json" | python3 -c "
|
||||
@@ -632,11 +634,12 @@ PYEOF
|
||||
{
|
||||
echo ""
|
||||
echo "# OCP LAN (added by ocp connect)"
|
||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||
echo "export OPENAI_BASE_URL='$base_url/v1'"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY=$key"
|
||||
echo "export OPENAI_API_KEY='$key'"
|
||||
fi
|
||||
} >> "$rc_file"
|
||||
chmod 600 "$rc_file" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo " Shell config:"
|
||||
@@ -669,6 +672,7 @@ PYEOF
|
||||
echo "OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} > "$env_dir/ocp.conf"
|
||||
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
|
||||
echo ""
|
||||
echo " System-level (systemd):"
|
||||
echo " ✓ $env_dir/ocp.conf"
|
||||
|
||||
+10
-7
@@ -208,31 +208,34 @@ async function cmdTest() {
|
||||
async function cmdRestart(args) {
|
||||
const target = (args || "").trim().toLowerCase();
|
||||
const { execSync } = await import("node:child_process");
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
||||
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
|
||||
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
execSync(macGateway, { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else if (target === "all") {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
execSync(macProxy, { timeout: 15000 });
|
||||
// Gateway restart will kill this plugin too, so do it last
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
execSync(macGateway, { timeout: 15000 });
|
||||
return "✓ Proxy + Gateway restarted";
|
||||
} else {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
execSync(macProxy, { timeout: 15000 });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e) {
|
||||
// Try systemd for Linux
|
||||
// Linux: systemd user services
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else {
|
||||
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
|
||||
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e2) {
|
||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
|
||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.17.1",
|
||||
"version": "3.20.0",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||
// is stable enough for our hand-written templates in setup.mjs.
|
||||
|
||||
// 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.
|
||||
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||
|
||||
export function parsePlistEnv(plistContent) {
|
||||
|
||||
+165
-43
@@ -19,7 +19,8 @@
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 8)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8)
|
||||
* OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2)
|
||||
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
||||
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
||||
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
||||
@@ -36,7 +37,10 @@ import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||
import { isLoopbackBind } from "./lib/net.mjs";
|
||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
@@ -144,7 +148,7 @@ function extractSystemPrompt(messages) {
|
||||
return OCP_SYSTEM_PROMPT_WRAPPER;
|
||||
}
|
||||
const clientContent = systemMessages.map(m =>
|
||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
||||
contentToText(m.content)
|
||||
).join("\n\n");
|
||||
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
|
||||
}
|
||||
@@ -278,6 +282,12 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
||||
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
||||
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
||||
// When set to "1", advertise PROXY_ANONYMOUS_KEY in the public /health body so
|
||||
// remote `ocp-connect` devices can zero-config auto-discover it (issue #12 §14 Path A).
|
||||
// Default OFF: /health is unauthenticated, so advertising hands the shared key to any
|
||||
// LAN-reachable device (issue #109 P0). Localhost callers always see it regardless,
|
||||
// since localhost is already fully trusted by the auth path.
|
||||
const ADVERTISE_ANON_KEY = process.env.PROXY_ADVERTISE_ANON_KEY === "1";
|
||||
let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabled, value in ms
|
||||
|
||||
// ── TUI-mode (subscription-pool bridge) — opt-in; default OFF ───────────
|
||||
@@ -292,13 +302,29 @@ const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000
|
||||
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||
// Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is
|
||||
// HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of
|
||||
// wallclock), so a small host (e.g. a Pi 4 serving a family) cannot run many at once
|
||||
// without OOM + multiplied subscription rate-limit pressure. This is NOT the global
|
||||
// MAX_CONCURRENT gate (that lives in spawnClaudeProcess, the -p/stream-json path, which
|
||||
// callClaudeTui never reaches). See ADR 0007 PR-B amendment + lib/tui/semaphore.mjs.
|
||||
const TUI_MAX_CONCURRENT = parseInt(process.env.OCP_TUI_MAX_CONCURRENT || "2", 10);
|
||||
const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT);
|
||||
// Operator-visible TUI drift surface (audit C-5). lastEntrypoint + entrypointMismatches
|
||||
// let the operator poll /health to catch a silent metered-pool drift (the audit's top
|
||||
// risk: after the 6/15 flip a TTY-loss could flip cc_entrypoint cli→sdk-cli and drain
|
||||
// metered credits invisibly — the warning currently only reaches journald).
|
||||
const tuiStats = {
|
||||
lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null)
|
||||
entrypointMismatches: 0, // count of cli-expected-but-got-other turns
|
||||
};
|
||||
|
||||
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
|
||||
// 2. BIND_ADDRESS=0.0.0.0 — server is LAN-exposed; any LAN peer can send prompts
|
||||
// unless per-request trust is in place. Override with OCP_TUI_ALLOW_LAN=1
|
||||
// ONLY if you have a separate network-layer trust (firewall, VPN).
|
||||
// 2. a non-loopback BIND_ADDRESS — server is network-exposed; any reachable peer
|
||||
// can send prompts unless per-request trust is in place. Override with
|
||||
// OCP_TUI_ALLOW_LAN=1 ONLY if you have a separate network-layer trust (firewall, VPN).
|
||||
// 3. PROXY_ANONYMOUS_KEY set — anonymous callers can submit prompts without a key.
|
||||
// In all three cases TUI runs interactive claude with the OPERATOR's full filesystem
|
||||
// access — home is NOT isolation. Refuse to boot. See ADR 0007.
|
||||
@@ -311,11 +337,11 @@ if (TUI_MODE && AUTH_MODE === "multi") {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (TUI_MODE && BIND_ADDRESS === "0.0.0.0" && process.env.OCP_TUI_ALLOW_LAN !== "1") {
|
||||
if (TUI_MODE && !isLoopbackBind(BIND_ADDRESS) && process.env.OCP_TUI_ALLOW_LAN !== "1") {
|
||||
console.error(
|
||||
"FATAL: CLAUDE_TUI_MODE=true with CLAUDE_BIND=0.0.0.0 is unsafe.\n" +
|
||||
" TUI runs interactive claude with operator filesystem access; LAN-exposed without\n" +
|
||||
" per-request isolation means any LAN peer could drive the operator's claude session.\n" +
|
||||
`FATAL: CLAUDE_TUI_MODE=true with a non-loopback CLAUDE_BIND (${BIND_ADDRESS}) is unsafe.\n` +
|
||||
" TUI runs interactive claude with operator filesystem access; network-exposed without\n" +
|
||||
" per-request isolation means any reachable peer could drive the operator's claude session.\n" +
|
||||
" Either bind to 127.0.0.1 (default) or set OCP_TUI_ALLOW_LAN=1 if you have a\n" +
|
||||
" separate network-layer trust (firewall/VPN). See docs/adr/0007-tui-interactive-mode.md."
|
||||
);
|
||||
@@ -630,9 +656,22 @@ function buildCliArgs(cliModel, systemPrompt) {
|
||||
// This prevents runaway context from gateway-side conversation accumulation.
|
||||
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
|
||||
|
||||
// Flatten OpenAI content (string | array of parts) to plain text for the prompt.
|
||||
// Array content: concatenate text parts; replace non-text parts (e.g. image_url)
|
||||
// with a placeholder rather than dumping raw JSON. (issue #110)
|
||||
function contentToText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(p =>
|
||||
p && p.type === "text" && typeof p.text === "string" ? p.text : "[non-text content omitted]"
|
||||
).join("");
|
||||
}
|
||||
return content == null ? "" : JSON.stringify(content);
|
||||
}
|
||||
|
||||
function messagesToPrompt(messages) {
|
||||
const full = messages.map((m) => {
|
||||
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
||||
const text = contentToText(m.content);
|
||||
if (m.role === "system") return `[System] ${text}`;
|
||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||
return text;
|
||||
@@ -800,7 +839,12 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
return { proc, cliModel, conversationId, t0, cleanup, handleSessionFailure, markFirstByte };
|
||||
// Clear ONLY the request timer (not the slot accounting) when the response has
|
||||
// semantically completed (result/[DONE]) but the child hasn't exited yet — prevents
|
||||
// a spurious post-success timeout. cleanup() (on exit) still clears it idempotently. (issue #111)
|
||||
function clearOverallTimer() { clearTimeout(overallTimer); }
|
||||
|
||||
return { proc, cliModel, conversationId, t0, cleanup, clearOverallTimer, handleSessionFailure, markFirstByte };
|
||||
}
|
||||
|
||||
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
|
||||
@@ -892,7 +936,11 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||
recordModelRequest(cliModel, prompt.length);
|
||||
return runTuiTurn({
|
||||
// C-4: gate the heavy interactive boot behind the TUI semaphore. run() acquires a slot
|
||||
// (queuing if all are busy, up to maxQueue), then releases in a finally so any throw from
|
||||
// runTuiTurn (tmux spawn failure, paste-not-landed) OR from the honesty gates below
|
||||
// (truncation / error banner) can NEVER leak a slot. tuiSemaphore.inflight feeds /health.
|
||||
return tuiSemaphore.run(() => runTuiTurn({
|
||||
prompt,
|
||||
model: cliModel,
|
||||
claudeBin: CLAUDE,
|
||||
@@ -901,13 +949,47 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
||||
cwd: TUI_CWD,
|
||||
wallclockMs: TUI_WALLCLOCK_MS,
|
||||
entrypointMode: TUI_ENTRYPOINT,
|
||||
}).then((text) => {
|
||||
}).then(({ text, entrypoint, truncated }) => {
|
||||
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
|
||||
// A throw here propagates to the .catch below (recordModelError + reject), so the
|
||||
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
||||
|
||||
// C-2: the wall-clock cap hit with partial text and NO terminal marker — the turn
|
||||
// is INCOMPLETE. Returning the cut-off prefix would cache it and report it as
|
||||
// finish_reason:stop (a truncated answer served as a complete one). Reject instead.
|
||||
if (truncated) {
|
||||
logEvent("error", "tui_wallclock_truncated", { model: cliModel, chars: (text || "").length, wallclockMs: TUI_WALLCLOCK_MS });
|
||||
throw new Error("tui_wallclock_truncated: turn hit the wall-clock cap before completing; partial text dropped");
|
||||
}
|
||||
|
||||
// C-1: the interactive claude CLI renders in-session errors (expired/invalid
|
||||
// credentials, transient API failure) as ordinary assistant text. Returning that
|
||||
// banner would cache an error AS an answer and record a model SUCCESS. Detect a
|
||||
// known error banner (anchored whole-text match — see detectTuiUpstreamError) and
|
||||
// reject so it does NOT enter the cache and the client gets a 5xx.
|
||||
const banner = detectTuiUpstreamError(text);
|
||||
if (banner) {
|
||||
logEvent("error", "tui_upstream_error", { model: cliModel, banner: banner.slice(0, 200) });
|
||||
throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer");
|
||||
}
|
||||
|
||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
||||
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
||||
// return text but cost money — warn loudly so it's visible. (issue #115)
|
||||
// C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
|
||||
// unconditionally (operators can poll it to confirm cli) and increments
|
||||
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
|
||||
// journald warning already covers — so a silent metered-pool drift is visible on /health
|
||||
// without tailing logs.
|
||||
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
|
||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||
}
|
||||
return text;
|
||||
}).catch((err) => {
|
||||
recordModelError(cliModel, false);
|
||||
throw err;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
||||
@@ -954,10 +1036,10 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||
} catch (err) {
|
||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, clearOverallTimer, handleSessionFailure, markFirstByte } = ctx;
|
||||
let stderr = "";
|
||||
let headersSent = false;
|
||||
let totalChars = 0;
|
||||
@@ -1028,6 +1110,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
clearOverallTimer();
|
||||
|
||||
} else if (parsed.error) {
|
||||
// is_error result — emit error stop; do NOT set resultEventSeen (that would
|
||||
@@ -1037,12 +1120,12 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
logEvent("error", "claude_result_error", { model: cliModel, error: errStr.slice(0, 200) });
|
||||
trackError(errStr.slice(0, 200));
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: errStr, type: "provider_error" } });
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(errStr), type: "provider_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
}, hb);
|
||||
// Headers already sent (eager ensureHeaders) — can't send a JSON 500. Surface the
|
||||
// failure as an SSE error frame so the client can distinguish an upstream error
|
||||
// from a legitimately empty completion, instead of a success-looking finish_reason:"stop". (issue #110)
|
||||
sendSSE(res, { error: { message: sanitizeError(errStr), type: "provider_error" } }, hb);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
@@ -1064,7 +1147,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
// never record success or write cache for an errored response.
|
||||
if ((code !== 0 && !resultEventSeen) || errored) {
|
||||
recordModelError(cliModel, false);
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + contentToText(m.content).length, 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, errored, stderr: stderr.slice(0, 300) });
|
||||
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||
handleSessionFailure();
|
||||
@@ -1072,19 +1155,19 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
// If the error was already sent inline (parsed.error branch above), the
|
||||
// response may be writableEnded — nothing more to send.
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } });
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(stderr.slice(0, 300) || `claude exit ${code}`), type: "proxy_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
}, hb);
|
||||
// Headers already sent — surface the failure as an SSE error frame instead of a
|
||||
// success-looking finish_reason:"stop", so the client can tell the upstream crashed
|
||||
// rather than returned empty. (issue #110 — sibling of the parsed.error branch above.)
|
||||
sendSSE(res, { error: { message: sanitizeError(stderr.slice(0, 300) || `claude exit ${code}`), type: "proxy_error" } }, hb);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
} else {
|
||||
recordModelSuccess(cliModel, elapsed);
|
||||
breakerRecordSuccess(cliModel);
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + contentToText(m.content).length, 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||
// Cache write-back for streaming — only on true success (not errored)
|
||||
if (CACHE_TTL > 0 && authInfo.cacheHash) {
|
||||
@@ -1114,7 +1197,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
trackError(err.message);
|
||||
handleSessionFailure();
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
@@ -1123,12 +1206,27 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
// If client disconnects, kill the process to free resources
|
||||
res.on("close", () => {
|
||||
hb.stop();
|
||||
if (!proc.killed) {
|
||||
// Only escalate when the child is still alive. On the normal-success path res.end()
|
||||
// also fires "close", but the child has usually already exited — skip the spurious
|
||||
// SIGTERM and the 5s kill-timer entirely (a post-exit proc.once("exit") never fires,
|
||||
// so the timer would otherwise leak a closure over proc for 5s per request). (issue #111)
|
||||
if (!proc.killed && proc.exitCode === null && proc.signalCode === null) {
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
// Mirror the overallTimer escalation (server.mjs ~818): a SIGTERM-resistant child would
|
||||
// otherwise hold its concurrency slot until the request timeout — #37 on the disconnect path. (issue #111)
|
||||
const killTimer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
killTimer.unref();
|
||||
proc.once("exit", () => clearTimeout(killTimer));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Strip absolute filesystem paths from an error message before sending it to a client.
|
||||
// claude error_message / stderr routinely embed home-dir / credential-file paths. (issue #111)
|
||||
function sanitizeError(msg) {
|
||||
return String(msg || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
}
|
||||
|
||||
// ── Response helpers ────────────────────────────────────────────────────
|
||||
function jsonResponse(res, status, data) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
@@ -1182,6 +1280,12 @@ function streamStringAsSSE(res, id, model, content) {
|
||||
|
||||
let usageCache = { data: null, fetchedAt: 0 };
|
||||
const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 min
|
||||
// ALIGNMENT (Class A — OAuth bearer machinery). Verified against the compiled cli.js
|
||||
// (claude.exe v2.1.154) on 2026-05-31 via `strings`: both OAUTH_CLIENT_ID and
|
||||
// OAUTH_TOKEN_URL appear in the binary byte-for-byte; the legacy host
|
||||
// console.anthropic.com/v1/oauth is absent (0 hits). Re-verify on cli.js major bumps
|
||||
// using the compiled-binary protocol (strings on the Mach-O/ELF; no live OAuth probe —
|
||||
// a refresh-token grant would rotate the operator's real credentials). (issue #112)
|
||||
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
|
||||
@@ -1289,7 +1393,7 @@ async function fetchUsageFromApi() {
|
||||
// Minimal /v1/messages request — we only need the response headers.
|
||||
// Mirrors Claude Code cli.js vE4: headers anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}.
|
||||
const body = JSON.stringify({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
model: modelsConfig.aliases.haiku,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "." }],
|
||||
});
|
||||
@@ -1630,7 +1734,7 @@ async function handleChatCompletions(req, res) {
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
|
||||
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
|
||||
const model = parsed.model || "claude-sonnet-4-6";
|
||||
const model = parsed.model || modelsConfig.aliases.sonnet;
|
||||
const stream = parsed.stream;
|
||||
|
||||
// Validate model against known models
|
||||
@@ -1641,8 +1745,15 @@ async function handleChatCompletions(req, res) {
|
||||
// Session ID: from request body, header, or null (one-off)
|
||||
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
|
||||
|
||||
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return jsonResponse(res, 400, { error: { message: "'messages' must be a non-empty array", type: "invalid_request_error" } });
|
||||
}
|
||||
|
||||
// NOTE: quota is best-effort / eventually-consistent. The gate reads the recorded count
|
||||
// at entry and records only after the upstream completes, so concurrent requests at the
|
||||
// boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
|
||||
// recordUsage) are not counted. This is internal family rate-limiting, not a payment
|
||||
// boundary — bounded overshoot is acceptable. (issue #111)
|
||||
// Quota check — only for identified per-key users (not anonymous/admin/local)
|
||||
if (req._authKeyId) {
|
||||
let exceeded;
|
||||
@@ -1697,7 +1808,7 @@ async function handleChatCompletions(req, res) {
|
||||
// Default path (TUI_MODE===false) falls through to callClaudeStreaming below,
|
||||
// which is byte-for-byte unchanged from before this gate was added.
|
||||
const t0TuiStream = Date.now();
|
||||
const promptCharsTuiStream = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
const promptCharsTuiStream = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||
try {
|
||||
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
@@ -1709,8 +1820,7 @@ async function handleChatCompletions(req, res) {
|
||||
return;
|
||||
} catch (err) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
// Default: real stream-json streaming, unchanged.
|
||||
@@ -1718,7 +1828,7 @@ async function handleChatCompletions(req, res) {
|
||||
}
|
||||
|
||||
const t0Usage = Date.now();
|
||||
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
const promptChars = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||
|
||||
// Select upstream based on TUI_MODE flag. With TUI_MODE===false (default),
|
||||
// upstreamCall===callClaude — identical to the pre-TUI code path.
|
||||
@@ -1752,8 +1862,7 @@ async function handleChatCompletions(req, res) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1771,8 +1880,7 @@ async function handleChatCompletions(req, res) {
|
||||
return;
|
||||
}
|
||||
// Sanitize error: strip internal file paths before sending to client
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1918,7 +2026,7 @@ const server = createServer(async (req, res) => {
|
||||
claudeBinary: CLAUDE,
|
||||
claudeBinaryOk: binaryOk,
|
||||
authMode: AUTH_MODE,
|
||||
anonymousKey: PROXY_ANONYMOUS_KEY || null,
|
||||
...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: PROXY_ANONYMOUS_KEY || null } : {}),
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
@@ -1933,6 +2041,17 @@ const server = createServer(async (req, res) => {
|
||||
circuitBreaker: "disabled",
|
||||
sessions: sessionList,
|
||||
recentErrors: recentErrors.slice(-5),
|
||||
// ── TUI observability (audit C-5) — ADDITIVE block (ADR 0007 PR-B amendment) ──
|
||||
// /health is a grandfathered B.2 endpoint (ADR 0006). This block is NEW fields only;
|
||||
// every existing field above is byte-identical → behaviour-preserving for existing
|
||||
// consumers per ALIGNMENT.md's grandfather provision. When TUI_MODE is off the block
|
||||
// still appears with enabled:false (cheap, harmless) so the shape is stable.
|
||||
// entrypointMismatches/lastEntrypoint exist so an operator can poll /health to catch a
|
||||
// silent metered-pool drift (the audit's top risk after the 6/15 billing flip).
|
||||
tui: buildTuiHealthBlock(
|
||||
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT },
|
||||
tuiStats, tuiSemaphore,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1998,6 +2117,9 @@ const server = createServer(async (req, res) => {
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
const name = parsed.name || `key-${Date.now()}`;
|
||||
if (!/^[A-Za-z0-9 ._-]{1,64}$/.test(name)) {
|
||||
return jsonResponse(res, 400, { error: { message: "Invalid key name: 1-64 chars of letters, digits, space, dot, underscore, hyphen", type: "invalid_request_error" } });
|
||||
}
|
||||
const newKey = createKey(name);
|
||||
return jsonResponse(res, 201, newKey);
|
||||
}
|
||||
@@ -2220,7 +2342,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||
if (TUI_MODE) {
|
||||
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
|
||||
try {
|
||||
const n = reapStaleTuiSessions();
|
||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
||||
|
||||
@@ -65,6 +65,28 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
||||
// PROXY_ANONYMOUS_KEY — same pattern
|
||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||
|
||||
// ── Inject-value helpers ─────────────────────────────────────────────────
|
||||
// Escape a value for safe inclusion in a plist <string>…</string> body.
|
||||
function xmlEscape(v) {
|
||||
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
// Validate an injected service value: no control chars (a newline would inject a
|
||||
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
|
||||
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
|
||||
function assertSafeInjectValue(name, v) {
|
||||
if (v == null) return v;
|
||||
if (/[\x00-\x1f]/.test(String(v))) {
|
||||
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Validate all three INJECT values before they are written into any service unit.
|
||||
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
|
||||
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
|
||||
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
|
||||
|
||||
// ── Models: derived from models.json (single source of truth) ──────────
|
||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||
|
||||
@@ -119,18 +141,26 @@ try {
|
||||
}
|
||||
|
||||
// Check claude auth (quick test)
|
||||
try {
|
||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||
}).trim();
|
||||
if (out.length > 0) {
|
||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
|
||||
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
|
||||
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
|
||||
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
|
||||
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
|
||||
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
|
||||
} else {
|
||||
try {
|
||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||
}).trim();
|
||||
if (out.length > 0) {
|
||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
} catch (e) {
|
||||
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
|
||||
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||
@@ -403,17 +433,17 @@ if (!DRY_RUN) {
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>${PORT}</string>
|
||||
<string>${xmlEscape(PORT)}</string>
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
||||
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
|
||||
<key>CLAUDE_BIN</key>
|
||||
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||
<key>OCP_ADMIN_KEY</key>
|
||||
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||
<key>PROXY_ANONYMOUS_KEY</key>
|
||||
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
||||
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
+765
-7
@@ -4,6 +4,7 @@
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { isLoopbackBind } from "./lib/net.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
@@ -858,6 +859,74 @@ test("gcSnapshots keeps last N regardless of age", () => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── setup.mjs helpers: xmlEscape + assertSafeInjectValue ──
|
||||
// setup.mjs cannot be imported (top-level side effects run the installer).
|
||||
// Replicated verbatim from setup.mjs for unit-testing — keep in sync with source.
|
||||
console.log("\nsetup.mjs inject helpers:");
|
||||
|
||||
function xmlEscape(v) {
|
||||
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
function assertSafeInjectValueTest(name, v) {
|
||||
if (v == null) return v;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1f]/.test(String(v))) {
|
||||
throw new Error(`FATAL: ${name} contains a newline or control character`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
test("xmlEscape encodes all five special XML chars", () => {
|
||||
assert.equal(xmlEscape('a<b>&"\''), "a<b>&"'");
|
||||
});
|
||||
|
||||
test("xmlEscape leaves normal ocp_ token untouched", () => {
|
||||
assert.equal(xmlEscape("ocp_abc123"), "ocp_abc123");
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue rejects value with newline", () => {
|
||||
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\nb"), /FATAL/);
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue rejects value with carriage return", () => {
|
||||
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\rb"), /FATAL/);
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue rejects value with a tab (control char)", () => {
|
||||
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\tb"), /FATAL/);
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue ACCEPTS a path with a space (CLAUDE_BIN may legitimately contain one)", () => {
|
||||
assert.equal(assertSafeInjectValueTest("CLAUDE_BIN", "/Users/x/My Apps/node"), "/Users/x/My Apps/node");
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue accepts normal ocp_ token", () => {
|
||||
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "ocp_abc123"));
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue accepts null (omit path)", () => {
|
||||
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", null));
|
||||
});
|
||||
|
||||
test("plist-merge round-trips XML-escaped value correctly via mergePlistEnv", () => {
|
||||
// A value written with xmlEscape must survive a merge cycle — the [^<]* regex in
|
||||
// parsePlistEnv only sees the escaped form (no raw < reaches it), so round-trip is safe.
|
||||
const escaped = xmlEscape("a<b>&\"'"); // "a<b>&"'"
|
||||
const template = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${escaped}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`;
|
||||
// mergePlistEnv with no existing plist returns template unchanged.
|
||||
const merged = mergePlistEnv(null, template);
|
||||
assert.ok(merged.includes(escaped), "escaped value should survive unchanged through plist merge");
|
||||
});
|
||||
|
||||
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
||||
const dotOcp = testJoin(root, ".ocp");
|
||||
@@ -1271,7 +1340,7 @@ test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
|
||||
});
|
||||
|
||||
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint } from "./lib/tui/transcript.mjs";
|
||||
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint, detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
|
||||
import { tmpdir as tuiTmp0 } from "node:os";
|
||||
|
||||
@@ -1318,6 +1387,15 @@ test("isTerminalLine false on stop_reason tool_use (flat) — claude continues a
|
||||
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||
});
|
||||
// issue #130 cloud/server-side: claude builds (e.g. 2.1.114) that DON'T emit
|
||||
// turn_duration mark turn-end via assistant message.stop_reason — must be terminal.
|
||||
test("isTerminalLine true on assistant stop_reason end_turn (version-robust, e.g. 2.1.114)", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } }), true);
|
||||
});
|
||||
test("isTerminalLine true on assistant stop_reason stop_sequence / max_tokens", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "stop_sequence" } }), true);
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "max_tokens" } }), true);
|
||||
});
|
||||
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
|
||||
const evs = [
|
||||
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||
@@ -1358,6 +1436,173 @@ test("real complete fixture: verifyEntrypoint returns 'cli'", () => {
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
|
||||
// ── C-3 (#133): verifyEntrypoint is version-robust ───────────────────────
|
||||
// Some claude builds do NOT emit a turn_duration line; entrypoint lives on
|
||||
// ordinary lines on BOTH emitting and non-emitting builds. Reading ONLY
|
||||
// turn_duration made the server.mjs tui_entrypoint_mismatch assertion get null
|
||||
// every turn on non-emitting builds. verifyEntrypoint must fall back to ANY line.
|
||||
console.log("\nTUI transcript — verifyEntrypoint version-robustness (C-3, #133):");
|
||||
|
||||
test("verifyEntrypoint PREFERS the turn_duration line's entrypoint", () => {
|
||||
// turn_duration says "cli"; an earlier ordinary line says "sdk-cli" — the
|
||||
// authoritative turn_duration value must win, not last-writer-wins on the fallback.
|
||||
const evs = [
|
||||
{ type: "assistant", entrypoint: "sdk-cli", message: { content: [{ type: "text", text: "x" }] } },
|
||||
{ type: "system", subtype: "turn_duration", entrypoint: "cli" },
|
||||
];
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
test("verifyEntrypoint falls back to entrypoint on an ordinary assistant line when no turn_duration", () => {
|
||||
const evs = [
|
||||
{ type: "user", entrypoint: "cli", message: { content: "hi" } },
|
||||
{ type: "assistant", entrypoint: "cli", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
test("verifyEntrypoint returns null when NO line carries an entrypoint", () => {
|
||||
const evs = [
|
||||
{ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } },
|
||||
];
|
||||
assert.equal(verifyEntrypoint(evs), null);
|
||||
});
|
||||
test("real no-turn_duration fixture: verifyEntrypoint still resolves 'cli' (was null before C-3)", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/no-turn-duration.jsonl", "utf8"));
|
||||
// Sanity: the fixture genuinely lacks a turn_duration line (so this exercises the fallback).
|
||||
assert.ok(!evs.some((e) => e && e.type === "system" && e.subtype === "turn_duration"), "fixture must NOT emit turn_duration");
|
||||
assert.equal(verifyEntrypoint(evs), "cli");
|
||||
});
|
||||
|
||||
// ── C-1 (#133): honest AUTH-FAILURE banner detection ─────────────────────
|
||||
// The interactive claude CLI renders in-session errors as ordinary assistant text.
|
||||
// C-1 catches the R-1 case: expired/invalid creds, where EVERY turn returns the same
|
||||
// one-line auth-failure banner and OCP would cache it as a real answer. The detector
|
||||
// is deliberately NARROW/conservative: a false-positive (killing a real long answer
|
||||
// that merely DISCUSSES an API error) costs the user a missing answer + a double-burn
|
||||
// retry, which is worse than the rare false-negative (caching one transient error for
|
||||
// the 5-min TTL). Signal = ALL of: SHORT whole-message (≤100; live samples 69/73) AND
|
||||
// "API Error: 4xx" AND an auth keyword (authenticat | /login | credential) AND NO
|
||||
// backtick/quote char. When unsure → PASS. The earlier generalised rule
|
||||
// (^<short-prefix>?API Error:\d{3}.*$) was TOO BROAD: its unbounded .* tail killed
|
||||
// legit long answers; this block encodes the full narrowed matrix.
|
||||
console.log("\nTUI transcript — auth-failure banner detection (C-1, #133):");
|
||||
|
||||
// ---- Required matrix: MUST detect (kill) ----
|
||||
test("C-1 KILL: live /login 401 auth banner", () => {
|
||||
const banner = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
assert.equal(detectTuiUpstreamError(banner), banner);
|
||||
});
|
||||
test("C-1 KILL: live 'Failed to authenticate.' 401 banner variant", () => {
|
||||
// Second real PI231 banner: a different short auth-failure prefix before the same
|
||||
// "API Error: 4xx" core. Still short, still 4xx, still has 'authenticate'/'credentials'.
|
||||
const banner = "Failed to authenticate. API Error: 401 Invalid authentication credentials";
|
||||
assert.equal(detectTuiUpstreamError(banner), banner);
|
||||
});
|
||||
|
||||
// ---- Required matrix: MUST NOT kill (pass) ----
|
||||
test("C-1 PASS: long answer discussing a 500 (not 4xx, too long)", () => {
|
||||
// The exact false-positive the over-broad .* rule produced. 166 chars; 5xx.
|
||||
const legit = "API Error: 500 happened because the server was overloaded. To fix this, retry with exponential backoff and verify your rate limits before resending the request again.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: long answer with 'API Error: 401 details' (too long, no auth keyword)", () => {
|
||||
// 142 chars; the literal word 'authenticate'/'credential'/'/login' never appears, and
|
||||
// it is far over the length cap — rejected on length AND keyword.
|
||||
const legit = "Failed to parse the config. Here are the API Error: 401 details you asked about: the token expired and must be refreshed before the next call.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: 'To debug a 401 … API Error: 401 Unauthorized' (no auth keyword)", () => {
|
||||
// 91 chars (short!) and 4xx, but 'Unauthorized' is authoriz-, not authenticat-, and
|
||||
// there is no /login or credential — the auth-keyword signal rejects it.
|
||||
const legit = "To debug a 401: the server returns API Error: 401 Unauthorized, then you refresh the token.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: handler answer logging 'API Error: 503' (not 4xx)", () => {
|
||||
const legit = "Here is the handler you asked for. It logs the string API Error: 503 on failure and retries.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: short instructional answer quoting `API Error: 401` + /login (has backtick)", () => {
|
||||
// 75 chars: short, 4xx, has '/login' — passes signals 1-3. Rejected ONLY by the
|
||||
// backtick/quote constraint: it QUOTES the error in code formatting, it is not the banner.
|
||||
const legit = "You'll see `API Error: 401` when your token expires — run /login to fix it.";
|
||||
assert.equal(detectTuiUpstreamError(legit), null);
|
||||
});
|
||||
test("C-1 PASS: bare HTTP-status sentence (no 'API Error:' core)", () => {
|
||||
assert.equal(detectTuiUpstreamError("HTTP 401 means unauthorized."), null);
|
||||
});
|
||||
test("C-1 PASS: plain unrelated answer", () => {
|
||||
assert.equal(detectTuiUpstreamError("The capital of France is Paris."), null);
|
||||
});
|
||||
|
||||
// ---- Supporting / regression coverage ----
|
||||
test("C-1 PASS: transient 5xx banner is NOT detected (narrowed to 4xx auth only)", () => {
|
||||
// The old rule flagged any 3-digit code; the narrowed detector is 4xx-only by design
|
||||
// (5xx is transient/server-side, not the R-1 auth case). Accepted false-negative.
|
||||
assert.equal(detectTuiUpstreamError("API Error: 500 Internal Server Error"), null);
|
||||
});
|
||||
test("C-1 PASS: bare 4xx with no auth keyword is NOT detected", () => {
|
||||
// 'API Error: 403 Forbidden' alone — 4xx and short, but no authenticat/login/credential.
|
||||
assert.equal(detectTuiUpstreamError("API Error: 403 Forbidden"), null);
|
||||
});
|
||||
test("detectTuiUpstreamError trims surrounding whitespace before matching", () => {
|
||||
const out = detectTuiUpstreamError("\n\n Please run /login · API Error: 401 credential boom \n");
|
||||
assert.equal(out, "Please run /login · API Error: 401 credential boom");
|
||||
});
|
||||
test("detectTuiUpstreamError is case-insensitive on the banner keywords", () => {
|
||||
// lower-cased: /login + api error: 401 + 'credential' keyword, short, no code char.
|
||||
assert.ok(detectTuiUpstreamError("please run /login · api error: 401 bad credential") !== null);
|
||||
});
|
||||
test("detectTuiUpstreamError does NOT match prose that mentions an API error mid-paragraph (#133 regression guard)", () => {
|
||||
// A long, legit answer that merely discusses an API error — rejected on length alone.
|
||||
const para = "When integrating with the upstream service you may occasionally hit an API Error: 401 response if the bearer token has lapsed; the recommended remediation is to re-run the login flow and retry the request with a fresh credential, after which the 401 should clear.";
|
||||
assert.equal(detectTuiUpstreamError(para), null);
|
||||
});
|
||||
test("detectTuiUpstreamError does NOT match a long plain-text auth answer with NO code chars (length cap is load-bearing)", () => {
|
||||
// 226 chars, no backtick/quote, has 4xx + /login + credential + authenticate — passes
|
||||
// signals 2-4. ONLY the length cap rejects it. Guards against dropping the cap.
|
||||
const para = "If you call the endpoint without a bearer token the API Error: 401 response tells you the credential is missing; just authenticate again with /login and the request will succeed on the next attempt without any further changes.";
|
||||
assert.equal(detectTuiUpstreamError(para), null);
|
||||
});
|
||||
test("detectTuiUpstreamError returns null on empty / whitespace / non-string", () => {
|
||||
assert.equal(detectTuiUpstreamError(""), null);
|
||||
assert.equal(detectTuiUpstreamError(" \n "), null);
|
||||
assert.equal(detectTuiUpstreamError(null), null);
|
||||
assert.equal(detectTuiUpstreamError(undefined), null);
|
||||
assert.equal(detectTuiUpstreamError(42), null);
|
||||
});
|
||||
test("detectTuiUpstreamError respects CLAUDE_TUI_ERROR_PATTERNS override (custom banner)", () => {
|
||||
// Override with a single custom pattern; the default 401 banner no longer matches,
|
||||
// but the custom one does (anchored whole-text).
|
||||
assert.equal(detectTuiUpstreamError("Please run /login · API Error: 401 x", "Session expired, please re-auth"), null);
|
||||
assert.equal(detectTuiUpstreamError("Session expired, please re-auth", "Session expired, please re-auth"), "Session expired, please re-auth");
|
||||
});
|
||||
test("detectTuiUpstreamError with an empty override disables detection (escape hatch)", () => {
|
||||
assert.equal(detectTuiUpstreamError("API Error: 500 boom", ""), null);
|
||||
assert.equal(detectTuiUpstreamError("API Error: 500 boom", " "), null);
|
||||
});
|
||||
test("detectTuiUpstreamError override accepts '||'-separated patterns", () => {
|
||||
const raw = "First banner||Second banner";
|
||||
assert.equal(detectTuiUpstreamError("First banner", raw), "First banner");
|
||||
assert.equal(detectTuiUpstreamError("Second banner", raw), "Second banner");
|
||||
assert.equal(detectTuiUpstreamError("Third", raw), null);
|
||||
});
|
||||
test("real error fixture: latest assistant text IS the banner and detectTuiUpstreamError flags it", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401.jsonl", "utf8"));
|
||||
const text = extractLatestAssistantText(evs);
|
||||
assert.equal(text, "Please run /login · API Error: 401 Invalid authentication credentials");
|
||||
assert.ok(detectTuiUpstreamError(text) !== null, "error fixture's final turn must be flagged as an upstream error");
|
||||
});
|
||||
test("real error fixture (Failed-to-authenticate variant): final turn is flagged (#133 runtime gap)", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/error-401-failauth.jsonl", "utf8"));
|
||||
const text = extractLatestAssistantText(evs);
|
||||
assert.equal(text, "Failed to authenticate. API Error: 401 Invalid authentication credentials");
|
||||
assert.ok(detectTuiUpstreamError(text) !== null, "Failed-to-authenticate banner must be flagged as an upstream error");
|
||||
});
|
||||
test("real complete fixture: final answer is NOT flagged as an upstream error", () => {
|
||||
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||
const text = extractLatestAssistantText(evs);
|
||||
assert.equal(detectTuiUpstreamError(text), null);
|
||||
});
|
||||
|
||||
// ── TUI transcript — polling reader (async) ──────────────────────────────
|
||||
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
|
||||
@@ -1370,18 +1615,42 @@ await asyncTest("readTuiTranscript returns assistant text when terminal marker p
|
||||
const p = `${dir}/s.jsonl`;
|
||||
tuiWriteFile(p, [
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
||||
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
|
||||
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }),
|
||||
].join("\n") + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||
assert.equal(out, "hello world");
|
||||
assert.equal(out.text, "hello world");
|
||||
assert.equal(out.entrypoint, "cli");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||
// C-2 (#133): the terminal-marker path must signal a COMPLETE turn.
|
||||
await asyncTest("readTuiTranscript signals truncated:false when a terminal marker is hit (complete turn)", async () => {
|
||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
tuiWriteFile(p, [
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } }),
|
||||
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }),
|
||||
].join("\n") + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||
assert.equal(out.truncated, false);
|
||||
});
|
||||
|
||||
// C-2 (#133): cap-with-partial-text must be DISTINGUISHABLE from a complete turn.
|
||||
// Previously both returned {text, entrypoint} identically and the partial was cached
|
||||
// + returned as finish_reason:stop. The cap path now returns truncated:true so the
|
||||
// caller (callClaudeTui) can throw instead of serving a cut-off answer.
|
||||
await asyncTest("readTuiTranscript honours wall-clock cap and flags partial text truncated:true", async () => {
|
||||
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||
const p = `${dir}/s.jsonl`;
|
||||
// No terminal marker → reader will spin to the cap then return the partial.
|
||||
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
||||
assert.equal(out, "partial");
|
||||
assert.equal(out.text, "partial");
|
||||
assert.equal(out.truncated, true);
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => {
|
||||
const out = await readTuiTranscript({ transcriptPath: "./lib/tui/fixtures/complete-haiku.jsonl", wallclockMs: 2000, pollMs: 50 });
|
||||
assert.equal(out.entrypoint, "cli");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||
@@ -1394,7 +1663,7 @@ await asyncTest("readTuiTranscript throws when no text and cap elapses", async (
|
||||
});
|
||||
|
||||
// ── TUI session reaper ───────────────────────────────────────────────────
|
||||
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
||||
import { reapStaleTuiSessions, SESSION_PREFIX, buildTuiCmd } from "./lib/tui/session.mjs";
|
||||
|
||||
console.log("\nTUI session reaper:");
|
||||
|
||||
@@ -1402,6 +1671,70 @@ test("SESSION_PREFIX is ocp-tui-", () => {
|
||||
assert.equal(SESSION_PREFIX, "ocp-tui-");
|
||||
});
|
||||
|
||||
console.log("\nTUI command construction (proxy-purity / #4):");
|
||||
|
||||
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
|
||||
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
|
||||
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
|
||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
|
||||
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
|
||||
});
|
||||
|
||||
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
||||
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
|
||||
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
|
||||
assert.ok(cli.includes("CLAUDE_CODE_ENTRYPOINT=cli"), "cli mode labels the subscription pool");
|
||||
assert.ok(cli.includes("--strict-mcp-config") && cli.includes('mcp__*'), "MCP wall retained");
|
||||
// 'auto' mode must NOT pin the entrypoint (claude self-classifies via TTY).
|
||||
const auto = buildTuiCmd("/usr/bin/claude", "m", "sid-3", "/home/u", "auto");
|
||||
assert.ok(!/CLAUDE_CODE_ENTRYPOINT=/.test(auto), "auto mode leaves entrypoint unset");
|
||||
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
||||
});
|
||||
|
||||
test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => {
|
||||
const save = { ...process.env };
|
||||
const restore = () => {
|
||||
for (const k of ["OCP_TUI_FULL_TOOLS", "CLAUDE_SKIP_PERMISSIONS", "CLAUDE_MCP_CONFIG", "CLAUDE_ALLOWED_TOOLS"]) {
|
||||
if (k in save) process.env[k] = save[k]; else delete process.env[k];
|
||||
}
|
||||
};
|
||||
try {
|
||||
// default (gate off) keeps the MCP wall, no --allowedTools
|
||||
delete process.env.OCP_TUI_FULL_TOOLS;
|
||||
const off = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(off.includes("--strict-mcp-config") && !off.includes("--allowedTools"), "gate off = MCP wall");
|
||||
|
||||
// gate on: --allowedTools (default set incl Bash), MCP wall dropped
|
||||
process.env.OCP_TUI_FULL_TOOLS = "1";
|
||||
delete process.env.CLAUDE_SKIP_PERMISSIONS;
|
||||
delete process.env.CLAUDE_MCP_CONFIG;
|
||||
delete process.env.CLAUDE_ALLOWED_TOOLS;
|
||||
const full = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(full.includes("--allowedTools") && full.includes("Bash"), "full-tools grants --allowedTools incl Bash");
|
||||
assert.ok(!full.includes("--strict-mcp-config") && !/--disallowedTools/.test(full), "full-tools drops the MCP wall");
|
||||
|
||||
// skip-permissions supersedes --allowedTools
|
||||
process.env.CLAUDE_SKIP_PERMISSIONS = "true";
|
||||
const skip = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(skip.includes("--dangerously-skip-permissions") && !skip.includes("--allowedTools"), "skip-permissions honored");
|
||||
|
||||
// mcp-config threaded through
|
||||
delete process.env.CLAUDE_SKIP_PERMISSIONS;
|
||||
process.env.CLAUDE_MCP_CONFIG = "/tmp/mcp.json";
|
||||
const mcp = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(/--mcp-config '\/tmp\/mcp.json'/.test(mcp), "mcp-config passed through (shq'd)");
|
||||
|
||||
// operator-supplied scoped tool specifiers must be shell-quoted (no injection via ()*~)
|
||||
delete process.env.CLAUDE_MCP_CONFIG;
|
||||
process.env.CLAUDE_ALLOWED_TOOLS = "Bash(npm run test:*),Read";
|
||||
const scoped = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||
assert.ok(scoped.includes("'Bash(npm run test:*)'"), "scoped tool tokens are shq'd in the shell string");
|
||||
assert.ok(!/--allowedTools Bash\(npm/.test(scoped), "scoped token must NOT appear unquoted");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||
const killed = [];
|
||||
const fakeTmux = (args) => {
|
||||
@@ -1518,6 +1851,147 @@ test("default mode (no second arg) behaves like 'cli'", () => {
|
||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||
});
|
||||
|
||||
// ── TUI concurrency limiter + drift observability (PR-B: audit C-4 / C-5) ──
|
||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||
|
||||
console.log("\nTUI concurrency limiter (C-4):");
|
||||
|
||||
const deferred = () => { let resolve, reject; const p = new Promise((res, rej) => { resolve = res; reject = rej; }); return { p, resolve, reject }; };
|
||||
|
||||
await asyncTest("limit=1 serializes two overlapping calls (second waits for the first)", async () => {
|
||||
const sem = new TuiSemaphore(1);
|
||||
const order = [];
|
||||
const g1 = deferred();
|
||||
// First task acquires the only slot and blocks on g1.
|
||||
const t1 = sem.run(async () => { order.push("t1-start"); await g1.p; order.push("t1-end"); });
|
||||
await new Promise((r) => setImmediate(r)); // let t1 acquire
|
||||
assert.equal(sem.inflight, 1, "t1 holds the only slot");
|
||||
// Second task must QUEUE — it has not started yet.
|
||||
const t2 = sem.run(async () => { order.push("t2-start"); });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.queued, 1, "t2 is queued, not running");
|
||||
assert.deepEqual(order, ["t1-start"], "t2 has not started while t1 holds the slot");
|
||||
// Release t1 → t2 runs.
|
||||
g1.resolve();
|
||||
await t1; await t2;
|
||||
assert.deepEqual(order, ["t1-start", "t1-end", "t2-start"], "t2 ran only after t1 finished");
|
||||
assert.equal(sem.inflight, 0, "all slots released");
|
||||
assert.equal(sem.queued, 0, "queue drained");
|
||||
});
|
||||
|
||||
await asyncTest("limit=2 allows two concurrent, queues the third", async () => {
|
||||
const sem = new TuiSemaphore(2);
|
||||
const g = [deferred(), deferred(), deferred()];
|
||||
const started = [];
|
||||
const tasks = g.map((d, i) => sem.run(async () => { started.push(i); await d.p; }));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.inflight, 2, "exactly 2 run concurrently");
|
||||
assert.equal(sem.queued, 1, "the third is queued");
|
||||
assert.deepEqual(started.sort(), [0, 1], "only the first two started");
|
||||
g.forEach((d) => d.resolve());
|
||||
await Promise.all(tasks);
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
await asyncTest("slot is RELEASED on throw (finally) — a rejecting task never leaks its slot", async () => {
|
||||
const sem = new TuiSemaphore(1);
|
||||
await assert.rejects(sem.run(async () => { throw new Error("boom"); }), /boom/);
|
||||
assert.equal(sem.inflight, 0, "throwing task released its slot");
|
||||
// Prove the slot is reusable: a subsequent task acquires immediately.
|
||||
let ran = false;
|
||||
await sem.run(async () => { ran = true; });
|
||||
assert.equal(ran, true);
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
await asyncTest("wait queue is bounded — run() rejects with tui_queue_full when full (backpressure, not OOM)", async () => {
|
||||
const sem = new TuiSemaphore(1, { maxQueue: 1 });
|
||||
const g1 = deferred();
|
||||
const t1 = sem.run(async () => { await g1.p; }); // holds the slot
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const t2 = sem.run(async () => {}); // fills the 1-deep queue
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.queued, 1, "queue is full");
|
||||
await assert.rejects(sem.run(async () => {}), /tui_queue_full/, "third request rejects");
|
||||
g1.resolve();
|
||||
await t1; await t2;
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
console.log("\nTUI drift observability (C-5):");
|
||||
|
||||
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
const mism = recordTuiEntrypoint(ts, "cli", "cli");
|
||||
assert.equal(mism, false);
|
||||
assert.equal(ts.lastEntrypoint, "cli");
|
||||
assert.equal(ts.entrypointMismatches, 0);
|
||||
});
|
||||
|
||||
test("recordTuiEntrypoint: expected cli but observed 'sdk-cli' increments the mismatch counter (drift)", () => {
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||
assert.equal(ts.entrypointMismatches, 1);
|
||||
// A second drift increments again (counter accumulates across turns).
|
||||
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "cli"), true);
|
||||
assert.equal(ts.entrypointMismatches, 2);
|
||||
});
|
||||
|
||||
test("recordTuiEntrypoint: null observation → lastEntrypoint null, counts as mismatch when expected cli", () => {
|
||||
const ts = { lastEntrypoint: "cli", entrypointMismatches: 0 };
|
||||
assert.equal(recordTuiEntrypoint(ts, null, "cli"), true);
|
||||
assert.equal(ts.lastEntrypoint, null);
|
||||
assert.equal(ts.entrypointMismatches, 1);
|
||||
});
|
||||
|
||||
test("recordTuiEntrypoint: non-cli expected mode (auto) never counts a mismatch", () => {
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
assert.equal(recordTuiEntrypoint(ts, "sdk-cli", "auto"), false);
|
||||
assert.equal(ts.lastEntrypoint, "sdk-cli");
|
||||
assert.equal(ts.entrypointMismatches, 0);
|
||||
});
|
||||
|
||||
test("buildTuiHealthBlock: shape + live counters (the additive /health tui block)", () => {
|
||||
const sem = new TuiSemaphore(2);
|
||||
const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 };
|
||||
const block = buildTuiHealthBlock(
|
||||
{ enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||
assert.deepEqual(Object.keys(block).sort(),
|
||||
["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"]);
|
||||
assert.equal(block.enabled, true);
|
||||
assert.equal(block.entrypointMode, "cli");
|
||||
assert.equal(block.lastEntrypoint, "cli");
|
||||
assert.equal(block.entrypointMismatches, 3);
|
||||
assert.equal(block.inflight, 0);
|
||||
assert.equal(block.queued, 0);
|
||||
assert.equal(block.maxConcurrent, 2);
|
||||
});
|
||||
|
||||
test("buildTuiHealthBlock: TUI off → enabled:false but block still present (stable shape)", () => {
|
||||
const sem = new TuiSemaphore(2);
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
const block = buildTuiHealthBlock(
|
||||
{ enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||
assert.equal(block.enabled, false);
|
||||
assert.equal(block.lastEntrypoint, null);
|
||||
assert.equal(block.entrypointMismatches, 0);
|
||||
});
|
||||
|
||||
await asyncTest("buildTuiHealthBlock reflects live inflight/queued while turns are in flight", async () => {
|
||||
const sem = new TuiSemaphore(1);
|
||||
const ts = { lastEntrypoint: null, entrypointMismatches: 0 };
|
||||
const g1 = deferred();
|
||||
const t1 = sem.run(async () => { await g1.p; });
|
||||
const t2 = sem.run(async () => {}); // queued behind t1
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const block = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 1 }, ts, sem);
|
||||
assert.equal(block.inflight, 1, "one turn in flight");
|
||||
assert.equal(block.queued, 1, "one turn queued");
|
||||
g1.resolve();
|
||||
await t1; await t2;
|
||||
});
|
||||
|
||||
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
||||
console.log("\nTUI session driver:");
|
||||
|
||||
@@ -1532,7 +2006,7 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
||||
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||
wallclockMs: 120000,
|
||||
});
|
||||
assert.ok(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
|
||||
assert.ok(/PONG/i.test(out.text), `expected PONG, got: ${out.text.slice(0, 200)}`);
|
||||
});
|
||||
} else {
|
||||
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => {
|
||||
@@ -1540,6 +2014,290 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
||||
});
|
||||
}
|
||||
|
||||
// ── TUI readiness / paste-verify predicates (issue #130) ────────────────────
|
||||
// Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs.
|
||||
// Keep in sync with the definitions there.
|
||||
function _tuiInputReady(pane) {
|
||||
return /\? for shortcuts/.test(pane);
|
||||
}
|
||||
function _tuiPromptLanded(pane, prompt) {
|
||||
const flatPane = pane.replace(/\s+/g, " ");
|
||||
if (flatPane.includes("[Pasted text")) return true;
|
||||
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||
return needle.length >= 2 && flatPane.includes(needle); // C-4 (#133): 3 → 2 (see lib/tui/session.mjs)
|
||||
}
|
||||
|
||||
// Real captured pane samples (empirically confirmed via live capture-pane on PI231,
|
||||
// claude v2.1.114 and v2.1.159). Source: issue #130 spec.
|
||||
const TUI_READY_PANE = `❯ Try "how does <filepath> work?"
|
||||
? for shortcuts · ← for agents`;
|
||||
|
||||
const TUI_LANDED_PANE = `❯ Reply with exactly: PONG_TEST
|
||||
? for shortcuts · ← for agents`;
|
||||
|
||||
// Welcome splash shown before input bar is rendered — no `? for shortcuts`.
|
||||
const TUI_BOOT_PANE = `╭─ Claude Code v2.1.114 ─ Welcome back Tao! ─╮\n│ Tips for getting started │`;
|
||||
|
||||
console.log("\nTUI readiness + paste-verify predicates (issue #130):");
|
||||
|
||||
test("tuiInputReady(READY_PANE) === true (input bar rendered)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_READY_PANE), true);
|
||||
});
|
||||
test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_LANDED_PANE), true);
|
||||
});
|
||||
test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => {
|
||||
assert.equal(_tuiInputReady(TUI_BOOT_PANE), false);
|
||||
});
|
||||
|
||||
test("tuiPromptLanded(READY_PANE, 'Reply with exactly: PONG_TEST') === false (still placeholder)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "Reply with exactly: PONG_TEST"), false);
|
||||
});
|
||||
test("tuiPromptLanded(LANDED_PANE, 'Reply with exactly: PONG_TEST') === true (prompt prefix visible)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_LANDED_PANE, "Reply with exactly: PONG_TEST"), true);
|
||||
});
|
||||
test("tuiPromptLanded(READY_PANE, 'ping') === false (prompt text absent from placeholder pane)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "ping"), false);
|
||||
});
|
||||
test("tuiPromptLanded('❯ ping\\n ? for shortcuts', 'ping') === true (needle present, no placeholder)", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ ping\n ? for shortcuts", "ping"), true);
|
||||
});
|
||||
// C-4 (#133): short prompts (1–2 char first line) MUST be able to land. Threshold
|
||||
// lowered 3 → 2. A 2-char prompt ("hi") present in the pane now lands instead of
|
||||
// 5s-failing with tui_paste_not_landed every time (live-reproduced: "hi").
|
||||
test("tuiPromptLanded('❯ hi\\n ? for shortcuts', 'hi') === true (2-char prompt lands — C-4)", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ hi\n ? for shortcuts", "hi"), true);
|
||||
});
|
||||
// False-positive guard for the lowered threshold: a 2-char needle ABSENT from the
|
||||
// still-empty placeholder pane must NOT land (no spurious Enter into an empty box).
|
||||
test("tuiPromptLanded(READY_PANE, 'hi') === false (2-char prompt not yet visible — no false positive)", () => {
|
||||
assert.equal(_tuiPromptLanded(TUI_READY_PANE, "hi"), false);
|
||||
});
|
||||
// issue #130 root cause: a big bracketed paste shows "[Pasted text #N +M lines]" — must be landed.
|
||||
test("tuiPromptLanded(bracketed-paste pane, big prompt) === true", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ [Pasted text #1 +301 lines]\n ? for shortcuts", "[System] Context 0."), true);
|
||||
});
|
||||
// issue #130 false-positive guard: the EMPTY placeholder uses a CURLY quote (“) and randomized
|
||||
// example text — the old placeholder-gone heuristic wrongly reported landed=true here, so Enter
|
||||
// fired into an empty box. Must be FALSE (no positive signal: not [Pasted text], prompt not shown).
|
||||
test("tuiPromptLanded(curly-quote placeholder, big prompt) === false (no false-positive)", () => {
|
||||
assert.equal(_tuiPromptLanded("❯ Try “how do I log an error?”\n ? for shortcuts", "[System] Context 0."), false);
|
||||
});
|
||||
|
||||
// ── /health anonymousKey gate (issue #109) ──────────────────────────────────
|
||||
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
|
||||
// verbatim to avoid importing server.mjs (top-level server.listen() would
|
||||
// start a live HTTP server, per the stream-JSON parser tests convention above).
|
||||
console.log("\n/health anonymousKey gate (issue #109):");
|
||||
|
||||
// Replicate the gating predicate from server.mjs line ~286/1927:
|
||||
// ...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: ... } : {})
|
||||
function shouldAdvertiseAnonKey(isLocalhost, advertise) { return isLocalhost || advertise; }
|
||||
|
||||
test("(localhost=false, flag=false) → omit key", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(false, false), false);
|
||||
});
|
||||
test("(localhost=true, flag=false) → include key (localhost always exempt)", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(true, false), true);
|
||||
});
|
||||
test("(localhost=false, flag=true) → include key (opt-in set)", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(false, true), true);
|
||||
});
|
||||
test("(localhost=true, flag=true) → include key (both true)", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(true, true), true);
|
||||
});
|
||||
|
||||
// ── contentToText helper tests (issue #110) ──────────────────────────────────
|
||||
// MIRRORS server.mjs contentToText — copied verbatim to avoid importing server.mjs
|
||||
// (top-level server.listen() would start a live HTTP server).
|
||||
// Keep in sync with the definition in server.mjs above messagesToPrompt.
|
||||
console.log("\ncontentToText helper (issue #110):");
|
||||
|
||||
function contentToText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(p =>
|
||||
p && p.type === "text" && typeof p.text === "string" ? p.text : "[non-text content omitted]"
|
||||
).join("");
|
||||
}
|
||||
return content == null ? "" : JSON.stringify(content);
|
||||
}
|
||||
|
||||
test("contentToText: string input returned unchanged", () => {
|
||||
assert.equal(contentToText("hello"), "hello");
|
||||
});
|
||||
|
||||
test("contentToText: array of text parts concatenated", () => {
|
||||
assert.equal(
|
||||
contentToText([{ type: "text", text: "hello" }, { type: "text", text: " world" }]),
|
||||
"hello world"
|
||||
);
|
||||
});
|
||||
|
||||
test("contentToText: non-text part (image_url) replaced with placeholder", () => {
|
||||
assert.equal(
|
||||
contentToText([{ type: "image_url", image_url: { url: "https://example.com/img.png" } }]),
|
||||
"[non-text content omitted]"
|
||||
);
|
||||
});
|
||||
|
||||
test("contentToText: empty array returns empty string", () => {
|
||||
assert.equal(contentToText([]), "");
|
||||
});
|
||||
|
||||
test("contentToText: null returns empty string", () => {
|
||||
assert.equal(contentToText(null), "");
|
||||
});
|
||||
|
||||
// ── messages guard predicate truth-table (issue #110) ────────────────────────
|
||||
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
|
||||
console.log("\nmessages guard predicate (issue #110):");
|
||||
|
||||
function isValidMessages(x) { return Array.isArray(x) && x.length > 0; }
|
||||
|
||||
test("messages guard: string 'x' → invalid (non-array)", () => {
|
||||
assert.equal(isValidMessages("x"), false);
|
||||
});
|
||||
|
||||
test("messages guard: empty array [] → invalid", () => {
|
||||
assert.equal(isValidMessages([]), false);
|
||||
});
|
||||
|
||||
test("messages guard: [{role:'user',content:'hi'}] → valid", () => {
|
||||
assert.equal(isValidMessages([{ role: "user", content: "hi" }]), true);
|
||||
});
|
||||
|
||||
// ── sanitizeError helper (issue #111) ────────────────────────────────────
|
||||
// Replicated verbatim from server.mjs (cannot import server.mjs).
|
||||
// The SIGKILL-escalation and timer changes are process-lifecycle and are not
|
||||
// unit-testable here (no live-server harness).
|
||||
console.log("\nsanitizeError (issue #111):");
|
||||
|
||||
function sanitizeError(msg) {
|
||||
return String(msg || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
}
|
||||
|
||||
test("sanitizeError: strips home-dir path from message", () => {
|
||||
const result = sanitizeError("failed at /Users/foo/.claude/creds.json");
|
||||
assert.ok(result.includes("[path]"), `expected [path] in: ${result}`);
|
||||
assert.ok(!result.includes("/Users/foo"), `expected /Users/foo stripped, got: ${result}`);
|
||||
});
|
||||
|
||||
test("sanitizeError: null input returns 'Internal error'", () => {
|
||||
assert.equal(sanitizeError(null), "Internal error");
|
||||
});
|
||||
|
||||
test("sanitizeError: message with no path passes through unchanged", () => {
|
||||
assert.equal(sanitizeError("no path here"), "no path here");
|
||||
});
|
||||
|
||||
test("sanitizeError: multiple paths all stripped", () => {
|
||||
const result = sanitizeError("err /a/b and /c/d");
|
||||
assert.ok(!result.includes("/a/b"), `expected /a/b stripped, got: ${result}`);
|
||||
assert.ok(!result.includes("/c/d"), `expected /c/d stripped, got: ${result}`);
|
||||
assert.ok(result.includes("[path]"), `expected [path] in: ${result}`);
|
||||
});
|
||||
|
||||
// ── models.json SPOT wiring (issue #112) ────────────────────────────────────
|
||||
// Asserts that the alias values used by server.mjs (usage probe + default model)
|
||||
// match the expected IDs. A future alias rename that silently breaks these
|
||||
// code paths is caught here.
|
||||
import { readFileSync as spotReadFileSync } from "node:fs";
|
||||
import { fileURLToPath as spotFileURLToPath } from "node:url";
|
||||
import { dirname as spotDirname, join as spotJoin } from "node:path";
|
||||
|
||||
console.log("\nmodels.json SPOT aliases (issue #112):");
|
||||
|
||||
const _spotDir = spotDirname(spotFileURLToPath(import.meta.url));
|
||||
const _spotModels = JSON.parse(spotReadFileSync(spotJoin(_spotDir, "models.json"), "utf8"));
|
||||
|
||||
test("models.json aliases.haiku === 'claude-haiku-4-5-20251001' (usage-probe SPOT)", () => {
|
||||
assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001");
|
||||
});
|
||||
|
||||
test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model SPOT)", () => {
|
||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||
// Replicated verbatim from dashboard.html so tests run without a browser.
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
const KEY_NAME_RE = /^[A-Za-z0-9 ._-]{1,64}$/;
|
||||
|
||||
console.log("\nescapeHtml (issue #114):");
|
||||
|
||||
test("escapeHtml: XSS payload → <img not <img", () => {
|
||||
const out = escapeHtml('<img src=x onerror=alert(1)>');
|
||||
assert.ok(out.includes("<img"), `expected <img in: ${out}`);
|
||||
assert.ok(!out.includes("<img"), `expected no raw <img in: ${out}`);
|
||||
});
|
||||
|
||||
test("escapeHtml: single-quote, double-quote, ampersand all escaped", () => {
|
||||
assert.equal(escapeHtml("a'b\"c&d"), "a'b"c&d");
|
||||
});
|
||||
|
||||
test("escapeHtml: null → empty string", () => {
|
||||
assert.equal(escapeHtml(null), "");
|
||||
});
|
||||
|
||||
console.log("\nKey-name validator (issue #114):");
|
||||
|
||||
test("KEY_NAME_RE: 'wife-laptop' → valid", () => {
|
||||
assert.ok(KEY_NAME_RE.test("wife-laptop"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: 'key-1700000000000' → valid", () => {
|
||||
assert.ok(KEY_NAME_RE.test("key-1700000000000"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: '<script>' → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("<script>"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: \"a'); DROP\" → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("a'); DROP"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: empty string → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test(""));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: 65-char string → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("x".repeat(65)));
|
||||
});
|
||||
|
||||
// ── isLoopbackBind helper (issue #115, extracted to lib/net.mjs via #125) ──────
|
||||
// Tests the imported lib/net.mjs helper — the real shared definition used by server.mjs.
|
||||
console.log("\nisLoopbackBind helper (issue #115):");
|
||||
|
||||
test("isLoopbackBind: '127.0.0.1' → true", () => {
|
||||
assert.equal(isLoopbackBind("127.0.0.1"), true);
|
||||
});
|
||||
test("isLoopbackBind: '::1' → true", () => {
|
||||
assert.equal(isLoopbackBind("::1"), true);
|
||||
});
|
||||
test("isLoopbackBind: 'localhost' → true", () => {
|
||||
assert.equal(isLoopbackBind("localhost"), true);
|
||||
});
|
||||
test("isLoopbackBind: '127.0.0.5' → true (127.x.x.x range)", () => {
|
||||
assert.equal(isLoopbackBind("127.0.0.5"), true);
|
||||
});
|
||||
test("isLoopbackBind: '0.0.0.0' → false (any-interface)", () => {
|
||||
assert.equal(isLoopbackBind("0.0.0.0"), false);
|
||||
});
|
||||
test("isLoopbackBind: '192.168.1.5' → false (LAN IP)", () => {
|
||||
assert.equal(isLoopbackBind("192.168.1.5"), false);
|
||||
});
|
||||
test("isLoopbackBind: '::' → false (IPv6 any-interface)", () => {
|
||||
assert.equal(isLoopbackBind("::"), false);
|
||||
});
|
||||
test("isLoopbackBind: '100.64.0.1' → false (Tailscale IP)", () => {
|
||||
assert.equal(isLoopbackBind("100.64.0.1"), false);
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user