mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dced52215 | ||
|
|
d291331998 | ||
|
|
9568411bcb | ||
|
|
1f577c075f | ||
|
|
6dff36959a | ||
|
|
1b02f181fa | ||
|
|
0000926358 | ||
|
|
aa1c65beb1 | ||
|
|
879b40fe93 | ||
|
|
68d58e7df4 | ||
|
|
4a7d79c330 | ||
|
|
c3b1f32c86 | ||
|
|
4458490caa | ||
|
|
36be723198 | ||
|
|
7b065600aa |
@@ -29,10 +29,14 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
|
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
|
||||||
# Each token is matched as a fixed string against server.mjs only.
|
# (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=(
|
BLACKLIST=(
|
||||||
"api.anthropic.com/api/oauth/usage"
|
"api.anthropic.com/api/oauth/usage"
|
||||||
|
"console.anthropic.com/v1/oauth/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
FAIL=0
|
FAIL=0
|
||||||
@@ -51,8 +55,8 @@ jobs:
|
|||||||
============================================================
|
============================================================
|
||||||
server.mjs contains a token on the OCP alignment blacklist.
|
server.mjs contains a token on the OCP alignment blacklist.
|
||||||
|
|
||||||
These tokens were introduced by LLM hallucinations and do
|
These tokens are either LLM hallucinations that never appeared in cli.js,
|
||||||
not appear in cli.js at any shipped Claude Code version.
|
or pinned wrong-host variants of a verified Class A endpoint (a drift).
|
||||||
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
||||||
(commit b87992f) for the full incident record.
|
(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.
|
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
|
## Historical Lesson: The 2026-04-11 Drift
|
||||||
|
|||||||
+42
-1
@@ -1,6 +1,47 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
## 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.
|
**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).
|
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
|
## Supported Tools
|
||||||
@@ -281,7 +285,7 @@ chmod +x ocp-connect
|
|||||||
./ocp-connect <server-ip>
|
./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
|
```bash
|
||||||
./ocp-connect <server-ip>
|
./ocp-connect <server-ip>
|
||||||
@@ -366,7 +370,7 @@ OCP Connect v1.3.0
|
|||||||
The script automatically:
|
The script automatically:
|
||||||
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
||||||
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
- 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)
|
- 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)
|
- 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 |
|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
||||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
| `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.
|
> **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)
|
### 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.
|
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.
|
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.
|
**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
|
- Admin and anonymous users are never subject to quotas
|
||||||
- PATCH is a partial update — omitted fields are left unchanged
|
- 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
|
### Important Notes
|
||||||
|
|
||||||
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||||
@@ -873,7 +891,8 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
| `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_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_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. |
|
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||||
@@ -942,6 +961,7 @@ 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.
|
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
- **No real token streaming.** 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.
|
- **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.
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
|
||||||
### Kill-switch
|
### Kill-switch
|
||||||
|
|||||||
+22
-15
@@ -132,6 +132,10 @@ function fmtChars(n) {
|
|||||||
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
function barColor(pct) {
|
function barColor(pct) {
|
||||||
if (pct >= 80) return "bar-red";
|
if (pct >= 80) return "bar-red";
|
||||||
if (pct >= 50) return "bar-amber";
|
if (pct >= 50) return "bar-amber";
|
||||||
@@ -144,8 +148,8 @@ async function refreshStatus() {
|
|||||||
const r = data.requests || {};
|
const r = data.requests || {};
|
||||||
|
|
||||||
document.getElementById("status-cards").innerHTML = `
|
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">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">${p.uptime || '?'}</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">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">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>
|
<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 = `
|
document.getElementById("plan-cards").innerHTML = `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">Session (5h)</div>
|
<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="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>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">Weekly (7d)</div>
|
<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="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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -181,21 +185,21 @@ async function refreshUsage() {
|
|||||||
const tbody = document.querySelector("#key-usage-table tbody");
|
const tbody = document.querySelector("#key-usage-table tbody");
|
||||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${k.key_name}</td>
|
<td>${escapeHtml(k.key_name)}</td>
|
||||||
<td>${k.requests}</td>
|
<td>${k.requests}</td>
|
||||||
<td>${k.successes}</td>
|
<td>${k.successes}</td>
|
||||||
<td>${k.errors}</td>
|
<td>${k.errors}</td>
|
||||||
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
||||||
<td class="mono">${k.last_request || '-'}</td>
|
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
||||||
|
|
||||||
const rtbody = document.querySelector("#recent-table tbody");
|
const rtbody = document.querySelector("#recent-table tbody");
|
||||||
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
|
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
|
||||||
<td>${r.key_name}</td>
|
<td>${escapeHtml(r.key_name)}</td>
|
||||||
<td>${r.model}</td>
|
<td>${escapeHtml(r.model)}</td>
|
||||||
<td>${fmtChars(r.prompt_chars)}</td>
|
<td>${fmtChars(r.prompt_chars)}</td>
|
||||||
<td>${fmtChars(r.response_chars)}</td>
|
<td>${fmtChars(r.response_chars)}</td>
|
||||||
<td>${fmtTime(r.elapsed_ms)}</td>
|
<td>${fmtTime(r.elapsed_ms)}</td>
|
||||||
@@ -216,13 +220,16 @@ async function refreshKeys() {
|
|||||||
const tbody = document.querySelector("#keys-table tbody");
|
const tbody = document.querySelector("#keys-table tbody");
|
||||||
tbody.innerHTML = (data.keys || []).map(k => `
|
tbody.innerHTML = (data.keys || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${k.name}</td>
|
<td>${escapeHtml(k.name)}</td>
|
||||||
<td class="mono">${k.keyPreview}</td>
|
<td class="mono">${escapeHtml(k.keyPreview)}</td>
|
||||||
<td class="mono">${k.created_at}</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><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>
|
</tr>
|
||||||
`).join("");
|
`).join("");
|
||||||
|
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
|
||||||
|
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
|
||||||
|
);
|
||||||
} catch(e) { /* not admin */ }
|
} catch(e) { /* not admin */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+112
-24
@@ -39,11 +39,47 @@ export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
|||||||
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||||
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
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));
|
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);
|
||||||
|
return needle.length >= 3 && 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.
|
// Single-quote escaper for sh -c arguments.
|
||||||
function shq(s) {
|
function shq(s) {
|
||||||
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||||
@@ -149,8 +185,35 @@ export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
|||||||
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
// 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
|
// (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.
|
// 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(" ");
|
||||||
return [
|
return [
|
||||||
|
envPrefix,
|
||||||
shq(claudeBin),
|
shq(claudeBin),
|
||||||
"--model", shq(model),
|
"--model", shq(model),
|
||||||
"--session-id", sessionId,
|
"--session-id", sessionId,
|
||||||
@@ -162,12 +225,19 @@ function buildTuiCmd(claudeBin, model, sessionId) {
|
|||||||
// Full per-request TUI lifecycle:
|
// Full per-request TUI lifecycle:
|
||||||
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
// 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).
|
// 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.
|
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||||
// 4. Submit the prompt via `send-keys -- "$(cat file)"` + a SEPARATE Enter key
|
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||||
// event (spec §5 / T3: literal "\n" in paste does NOT submit; Enter token does).
|
// 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
|
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||||
// marker or wall-clock cap.
|
// marker or wall-clock cap.
|
||||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
// 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({
|
export async function runTuiTurn({
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model,
|
||||||
@@ -213,34 +283,52 @@ export async function runTuiTurn({
|
|||||||
// is a no-op when the session never existed).
|
// is a no-op when the session never existed).
|
||||||
const spawnResult = tmux(
|
const spawnResult = tmux(
|
||||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
buildTuiCmd(claudeBin, model, sessionId)],
|
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||||
{ env },
|
{ env },
|
||||||
);
|
);
|
||||||
if (!spawnResult || spawnResult.status !== 0) {
|
if (!spawnResult || spawnResult.status !== 0) {
|
||||||
throw new Error("tui_spawn_failed: tmux session not created");
|
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 —
|
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||||
// then settle, then send a SEPARATE Enter key event to submit the line.
|
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||||
//
|
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||||
// The `-l` (literal) flag is required on the paste send-keys call so that
|
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||||
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape")
|
if (!ready) {
|
||||||
// is typed literally as text rather than being interpreted as a key binding.
|
// (readiness timed out; relying on paste-verify)
|
||||||
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a
|
console.error("[tui] input_not_ready", tmuxName);
|
||||||
// real keypress (carriage return) to submit the prompt line.
|
}
|
||||||
spawnSync(
|
|
||||||
"sh",
|
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||||
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`],
|
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||||
{ env, encoding: "utf8" },
|
// 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
|
||||||
await sleep(PASTE_SETTLE_MS);
|
// (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"]);
|
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 });
|
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||||
} finally {
|
} finally {
|
||||||
// 4. Teardown — always, even on throw.
|
// 5. Teardown — always, even on throw.
|
||||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-14
@@ -51,19 +51,29 @@ export function parseTranscriptLines(text) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A line marks the assistant turn complete when it is the turn_duration system
|
// A line marks the assistant turn complete when EITHER:
|
||||||
// event. That is the ONLY reliable terminal marker in interactive TUI mode.
|
// (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:
|
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
||||||
// In interactive claude, when the model decides to call a tool (stop_reason=
|
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
||||||
// "tool_use"), claude handles the tool call internally and then continues
|
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
||||||
// generating — the turn is NOT complete. The transcript advances to another
|
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
||||||
// assistant entry after the tool result. Only {type:"system",
|
// cross-version completion detection without bringing that bug back.)
|
||||||
// subtype:"turn_duration"} signals that claude has fully finished the turn.
|
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
||||||
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
|
|
||||||
export function isTerminalLine(obj) {
|
export function isTerminalLine(obj) {
|
||||||
if (!obj || typeof obj !== "object") return false;
|
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
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||||
@@ -104,8 +114,10 @@ export function verifyEntrypoint(events) {
|
|||||||
|
|
||||||
// Block until the session transcript is terminal (turn_duration) or
|
// Block until the session transcript is terminal (turn_duration) or
|
||||||
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
// 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
|
// editors). Returns { text, entrypoint } where text is the latest assistant text
|
||||||
// partial text; on cap with no text at all, throws.
|
// and entrypoint is the billing-pool classifier from the turn_duration line (e.g.
|
||||||
|
// "cli"), or null if not yet present. On cap with text, returns the partial result;
|
||||||
|
// on cap with no text at all, throws.
|
||||||
//
|
//
|
||||||
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
// 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).
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||||
@@ -115,15 +127,18 @@ export function verifyEntrypoint(events) {
|
|||||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||||
const deadline = Date.now() + wallclockMs;
|
const deadline = Date.now() + wallclockMs;
|
||||||
let lastText = "";
|
let lastText = "";
|
||||||
|
let lastEntrypoint = null;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const resolved = p || findTranscriptPath(home, sessionId);
|
const resolved = p || findTranscriptPath(home, sessionId);
|
||||||
if (resolved && existsSync(resolved)) {
|
if (resolved && existsSync(resolved)) {
|
||||||
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||||
lastText = extractLatestAssistantText(events) || lastText;
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
if (events.some(isTerminalLine)) return lastText;
|
const ep = verifyEntrypoint(events);
|
||||||
|
if (ep != null) lastEntrypoint = ep;
|
||||||
|
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
|
||||||
}
|
}
|
||||||
await sleep(pollMs);
|
await sleep(pollMs);
|
||||||
}
|
}
|
||||||
if (lastText) return lastText;
|
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
|
||||||
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -506,9 +506,11 @@ main() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
# 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
|
# The server advertises anonymousKey in /health ONLY when the admin has set
|
||||||
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
|
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
|
||||||
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
|
# 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
|
if [[ -z "$key" ]]; then
|
||||||
local anon_key
|
local anon_key
|
||||||
anon_key=$(echo "$health_json" | python3 -c "
|
anon_key=$(echo "$health_json" | python3 -c "
|
||||||
@@ -632,11 +634,12 @@ PYEOF
|
|||||||
{
|
{
|
||||||
echo ""
|
echo ""
|
||||||
echo "# OCP LAN (added by ocp connect)"
|
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
|
if [[ -n "$key" ]]; then
|
||||||
echo "export OPENAI_API_KEY=$key"
|
echo "export OPENAI_API_KEY='$key'"
|
||||||
fi
|
fi
|
||||||
} >> "$rc_file"
|
} >> "$rc_file"
|
||||||
|
chmod 600 "$rc_file" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|
||||||
echo " Shell config:"
|
echo " Shell config:"
|
||||||
@@ -669,6 +672,7 @@ PYEOF
|
|||||||
echo "OPENAI_API_KEY=$key"
|
echo "OPENAI_API_KEY=$key"
|
||||||
fi
|
fi
|
||||||
} > "$env_dir/ocp.conf"
|
} > "$env_dir/ocp.conf"
|
||||||
|
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
|
||||||
echo ""
|
echo ""
|
||||||
echo " System-level (systemd):"
|
echo " System-level (systemd):"
|
||||||
echo " ✓ $env_dir/ocp.conf"
|
echo " ✓ $env_dir/ocp.conf"
|
||||||
|
|||||||
+10
-7
@@ -208,31 +208,34 @@ async function cmdTest() {
|
|||||||
async function cmdRestart(args) {
|
async function cmdRestart(args) {
|
||||||
const target = (args || "").trim().toLowerCase();
|
const target = (args || "").trim().toLowerCase();
|
||||||
const { execSync } = await import("node:child_process");
|
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 {
|
try {
|
||||||
if (target === "gateway") {
|
if (target === "gateway") {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
execSync(macGateway, { timeout: 15000 });
|
||||||
return "✓ Gateway restarted";
|
return "✓ Gateway restarted";
|
||||||
} else if (target === "all") {
|
} 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
|
// 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";
|
return "✓ Proxy + Gateway restarted";
|
||||||
} else {
|
} else {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
execSync(macProxy, { timeout: 15000 });
|
||||||
return "✓ Proxy restarted";
|
return "✓ Proxy restarted";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Try systemd for Linux
|
// Linux: systemd user services
|
||||||
try {
|
try {
|
||||||
if (target === "gateway") {
|
if (target === "gateway") {
|
||||||
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
||||||
return "✓ Gateway restarted";
|
return "✓ Gateway restarted";
|
||||||
} else {
|
} 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";
|
return "✓ Proxy restarted";
|
||||||
}
|
}
|
||||||
} catch (e2) {
|
} 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",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.17.1",
|
"version": "3.19.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.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||||
// is stable enough for our hand-written templates in setup.mjs.
|
// is stable enough for our hand-written templates in setup.mjs.
|
||||||
|
|
||||||
|
// 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;
|
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||||
|
|
||||||
export function parsePlistEnv(plistContent) {
|
export function parsePlistEnv(plistContent) {
|
||||||
|
|||||||
+99
-39
@@ -36,6 +36,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
|
import { isLoopbackBind } from "./lib/net.mjs";
|
||||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -144,7 +145,7 @@ function extractSystemPrompt(messages) {
|
|||||||
return OCP_SYSTEM_PROMPT_WRAPPER;
|
return OCP_SYSTEM_PROMPT_WRAPPER;
|
||||||
}
|
}
|
||||||
const clientContent = systemMessages.map(m =>
|
const clientContent = systemMessages.map(m =>
|
||||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
contentToText(m.content)
|
||||||
).join("\n\n");
|
).join("\n\n");
|
||||||
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
|
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
|
||||||
}
|
}
|
||||||
@@ -278,6 +279,12 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
|||||||
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
||||||
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||||
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
||||||
|
// 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
|
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 ───────────
|
// ── TUI-mode (subscription-pool bridge) — opt-in; default OFF ───────────
|
||||||
@@ -296,9 +303,9 @@ const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off
|
|||||||
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||||
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
|
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
|
||||||
// 2. BIND_ADDRESS=0.0.0.0 — server is LAN-exposed; any LAN peer can send prompts
|
// 2. a non-loopback BIND_ADDRESS — server is network-exposed; any reachable peer
|
||||||
// unless per-request trust is in place. Override with OCP_TUI_ALLOW_LAN=1
|
// can send prompts unless per-request trust is in place. Override with
|
||||||
// ONLY if you have a separate network-layer trust (firewall, VPN).
|
// 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.
|
// 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
|
// 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.
|
// access — home is NOT isolation. Refuse to boot. See ADR 0007.
|
||||||
@@ -311,11 +318,11 @@ if (TUI_MODE && AUTH_MODE === "multi") {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
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(
|
console.error(
|
||||||
"FATAL: CLAUDE_TUI_MODE=true with CLAUDE_BIND=0.0.0.0 is unsafe.\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; LAN-exposed without\n" +
|
" TUI runs interactive claude with operator filesystem access; network-exposed without\n" +
|
||||||
" per-request isolation means any LAN peer could drive the operator's claude session.\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" +
|
" 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."
|
" separate network-layer trust (firewall/VPN). See docs/adr/0007-tui-interactive-mode.md."
|
||||||
);
|
);
|
||||||
@@ -630,9 +637,22 @@ function buildCliArgs(cliModel, systemPrompt) {
|
|||||||
// This prevents runaway context from gateway-side conversation accumulation.
|
// This prevents runaway context from gateway-side conversation accumulation.
|
||||||
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
|
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) {
|
function messagesToPrompt(messages) {
|
||||||
const full = messages.map((m) => {
|
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 === "system") return `[System] ${text}`;
|
||||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||||
return text;
|
return text;
|
||||||
@@ -800,7 +820,12 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
}
|
}
|
||||||
}, TIMEOUT);
|
}, 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) ─────────────────────────────────────
|
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
|
||||||
@@ -901,8 +926,14 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
|||||||
cwd: TUI_CWD,
|
cwd: TUI_CWD,
|
||||||
wallclockMs: TUI_WALLCLOCK_MS,
|
wallclockMs: TUI_WALLCLOCK_MS,
|
||||||
entrypointMode: TUI_ENTRYPOINT,
|
entrypointMode: TUI_ENTRYPOINT,
|
||||||
}).then((text) => {
|
}).then(({ text, entrypoint }) => {
|
||||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||||
|
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
||||||
|
// (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)
|
||||||
|
if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") {
|
||||||
|
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||||
|
}
|
||||||
return text;
|
return text;
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
recordModelError(cliModel, false);
|
recordModelError(cliModel, false);
|
||||||
@@ -954,10 +985,10 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||||
} catch (err) {
|
} 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 stderr = "";
|
||||||
let headersSent = false;
|
let headersSent = false;
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
@@ -1028,6 +1059,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
res.write("data: [DONE]\n\n");
|
res.write("data: [DONE]\n\n");
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
|
clearOverallTimer();
|
||||||
|
|
||||||
} else if (parsed.error) {
|
} else if (parsed.error) {
|
||||||
// is_error result — emit error stop; do NOT set resultEventSeen (that would
|
// is_error result — emit error stop; do NOT set resultEventSeen (that would
|
||||||
@@ -1037,12 +1069,12 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
logEvent("error", "claude_result_error", { model: cliModel, error: errStr.slice(0, 200) });
|
logEvent("error", "claude_result_error", { model: cliModel, error: errStr.slice(0, 200) });
|
||||||
trackError(errStr.slice(0, 200));
|
trackError(errStr.slice(0, 200));
|
||||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
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) {
|
} else if (!res.writableEnded && !res.destroyed) {
|
||||||
sendSSE(res, {
|
// Headers already sent (eager ensureHeaders) — can't send a JSON 500. Surface the
|
||||||
id, object: "chat.completion.chunk", created, model,
|
// failure as an SSE error frame so the client can distinguish an upstream error
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
// from a legitimately empty completion, instead of a success-looking finish_reason:"stop". (issue #110)
|
||||||
}, hb);
|
sendSSE(res, { error: { message: sanitizeError(errStr), type: "provider_error" } }, hb);
|
||||||
res.write("data: [DONE]\n\n");
|
res.write("data: [DONE]\n\n");
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
@@ -1064,7 +1096,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
// never record success or write cache for an errored response.
|
// never record success or write cache for an errored response.
|
||||||
if ((code !== 0 && !resultEventSeen) || errored) {
|
if ((code !== 0 && !resultEventSeen) || errored) {
|
||||||
recordModelError(cliModel, false);
|
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) });
|
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}`);
|
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||||
handleSessionFailure();
|
handleSessionFailure();
|
||||||
@@ -1072,19 +1104,19 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
// If the error was already sent inline (parsed.error branch above), the
|
// If the error was already sent inline (parsed.error branch above), the
|
||||||
// response may be writableEnded — nothing more to send.
|
// response may be writableEnded — nothing more to send.
|
||||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
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) {
|
} else if (!res.writableEnded && !res.destroyed) {
|
||||||
sendSSE(res, {
|
// Headers already sent — surface the failure as an SSE error frame instead of a
|
||||||
id, object: "chat.completion.chunk", created, model,
|
// success-looking finish_reason:"stop", so the client can tell the upstream crashed
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
// rather than returned empty. (issue #110 — sibling of the parsed.error branch above.)
|
||||||
}, hb);
|
sendSSE(res, { error: { message: sanitizeError(stderr.slice(0, 300) || `claude exit ${code}`), type: "proxy_error" } }, hb);
|
||||||
res.write("data: [DONE]\n\n");
|
res.write("data: [DONE]\n\n");
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
recordModelSuccess(cliModel, elapsed);
|
recordModelSuccess(cliModel, elapsed);
|
||||||
breakerRecordSuccess(cliModel);
|
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" });
|
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)
|
// Cache write-back for streaming — only on true success (not errored)
|
||||||
if (CACHE_TTL > 0 && authInfo.cacheHash) {
|
if (CACHE_TTL > 0 && authInfo.cacheHash) {
|
||||||
@@ -1114,7 +1146,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
trackError(err.message);
|
trackError(err.message);
|
||||||
handleSessionFailure();
|
handleSessionFailure();
|
||||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
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) {
|
} else if (!res.writableEnded && !res.destroyed) {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
@@ -1123,12 +1155,27 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
// If client disconnects, kill the process to free resources
|
// If client disconnects, kill the process to free resources
|
||||||
res.on("close", () => {
|
res.on("close", () => {
|
||||||
hb.stop();
|
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 {}
|
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 ────────────────────────────────────────────────────
|
// ── Response helpers ────────────────────────────────────────────────────
|
||||||
function jsonResponse(res, status, data) {
|
function jsonResponse(res, status, data) {
|
||||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||||
@@ -1182,6 +1229,12 @@ function streamStringAsSSE(res, id, model, content) {
|
|||||||
|
|
||||||
let usageCache = { data: null, fetchedAt: 0 };
|
let usageCache = { data: null, fetchedAt: 0 };
|
||||||
const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 min
|
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_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||||
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||||
|
|
||||||
@@ -1289,7 +1342,7 @@ async function fetchUsageFromApi() {
|
|||||||
// Minimal /v1/messages request — we only need the response headers.
|
// Minimal /v1/messages request — we only need the response headers.
|
||||||
// Mirrors Claude Code cli.js vE4: headers anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}.
|
// Mirrors Claude Code cli.js vE4: headers anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}.
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
model: "claude-haiku-4-5-20251001",
|
model: modelsConfig.aliases.haiku,
|
||||||
max_tokens: 1,
|
max_tokens: 1,
|
||||||
messages: [{ role: "user", content: "." }],
|
messages: [{ role: "user", content: "." }],
|
||||||
});
|
});
|
||||||
@@ -1630,7 +1683,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
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 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;
|
const stream = parsed.stream;
|
||||||
|
|
||||||
// Validate model against known models
|
// Validate model against known models
|
||||||
@@ -1641,8 +1694,15 @@ async function handleChatCompletions(req, res) {
|
|||||||
// Session ID: from request body, header, or null (one-off)
|
// 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;
|
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)
|
// Quota check — only for identified per-key users (not anonymous/admin/local)
|
||||||
if (req._authKeyId) {
|
if (req._authKeyId) {
|
||||||
let exceeded;
|
let exceeded;
|
||||||
@@ -1697,7 +1757,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
// Default path (TUI_MODE===false) falls through to callClaudeStreaming below,
|
// Default path (TUI_MODE===false) falls through to callClaudeStreaming below,
|
||||||
// which is byte-for-byte unchanged from before this gate was added.
|
// which is byte-for-byte unchanged from before this gate was added.
|
||||||
const t0TuiStream = Date.now();
|
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 {
|
try {
|
||||||
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
||||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||||
@@ -1709,8 +1769,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
|
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: sanitizeError(err.message), type: "proxy_error" } });
|
||||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default: real stream-json streaming, unchanged.
|
// Default: real stream-json streaming, unchanged.
|
||||||
@@ -1718,7 +1777,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const t0Usage = Date.now();
|
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),
|
// Select upstream based on TUI_MODE flag. With TUI_MODE===false (default),
|
||||||
// upstreamCall===callClaude — identical to the pre-TUI code path.
|
// upstreamCall===callClaude — identical to the pre-TUI code path.
|
||||||
@@ -1752,8 +1811,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
try { res.end(); } catch {}
|
try { res.end(); } catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1771,8 +1829,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Sanitize error: strip internal file paths before sending to client
|
// 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: sanitizeError(err.message), type: "proxy_error" } });
|
||||||
jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1918,7 +1975,7 @@ const server = createServer(async (req, res) => {
|
|||||||
claudeBinary: CLAUDE,
|
claudeBinary: CLAUDE,
|
||||||
claudeBinaryOk: binaryOk,
|
claudeBinaryOk: binaryOk,
|
||||||
authMode: AUTH_MODE,
|
authMode: AUTH_MODE,
|
||||||
anonymousKey: PROXY_ANONYMOUS_KEY || null,
|
...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: PROXY_ANONYMOUS_KEY || null } : {}),
|
||||||
auth: authStatus,
|
auth: authStatus,
|
||||||
config: {
|
config: {
|
||||||
timeout: TIMEOUT,
|
timeout: TIMEOUT,
|
||||||
@@ -1998,6 +2055,9 @@ const server = createServer(async (req, res) => {
|
|||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||||
const name = parsed.name || `key-${Date.now()}`;
|
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);
|
const newKey = createKey(name);
|
||||||
return jsonResponse(res, 201, newKey);
|
return jsonResponse(res, 201, newKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,28 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
|||||||
// PROXY_ANONYMOUS_KEY — same pattern
|
// PROXY_ANONYMOUS_KEY — same pattern
|
||||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
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) ──────────
|
// ── Models: derived from models.json (single source of truth) ──────────
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||||
|
|
||||||
@@ -403,17 +425,17 @@ if (!DRY_RUN) {
|
|||||||
<key>EnvironmentVariables</key>
|
<key>EnvironmentVariables</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
<key>CLAUDE_PROXY_PORT</key>
|
||||||
<string>${PORT}</string>
|
<string>${xmlEscape(PORT)}</string>
|
||||||
<key>CLAUDE_BIND</key>
|
<key>CLAUDE_BIND</key>
|
||||||
<string>${BIND_ADDRESS}</string>
|
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
<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>
|
<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>
|
<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>
|
<key>PROXY_ANONYMOUS_KEY</key>
|
||||||
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
+382
-5
@@ -4,6 +4,7 @@
|
|||||||
* Tests database layer functions directly — no server needed.
|
* 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 { 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 { createHash } from "node:crypto";
|
||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { unlinkSync } from "node:fs";
|
import { unlinkSync } from "node:fs";
|
||||||
@@ -858,6 +859,74 @@ test("gcSnapshots keeps last N regardless of age", () => {
|
|||||||
rmSync(root, { recursive: true, force: true });
|
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", () => {
|
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
@@ -1318,6 +1387,15 @@ test("isTerminalLine false on stop_reason tool_use (flat) — claude continues a
|
|||||||
test("isTerminalLine false on ordinary assistant text line", () => {
|
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||||
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
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", () => {
|
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
|
||||||
const evs = [
|
const evs = [
|
||||||
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||||
@@ -1370,10 +1448,11 @@ await asyncTest("readTuiTranscript returns assistant text when terminal marker p
|
|||||||
const p = `${dir}/s.jsonl`;
|
const p = `${dir}/s.jsonl`;
|
||||||
tuiWriteFile(p, [
|
tuiWriteFile(p, [
|
||||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
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");
|
].join("\n") + "\n");
|
||||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
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 () => {
|
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||||
@@ -1381,7 +1460,12 @@ await asyncTest("readTuiTranscript honours wall-clock cap and returns partial te
|
|||||||
const p = `${dir}/s.jsonl`;
|
const p = `${dir}/s.jsonl`;
|
||||||
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
||||||
assert.equal(out, "partial");
|
assert.equal(out.text, "partial");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
await asyncTest("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||||
@@ -1394,7 +1478,7 @@ await asyncTest("readTuiTranscript throws when no text and cap elapses", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── TUI session reaper ───────────────────────────────────────────────────
|
// ── 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:");
|
console.log("\nTUI session reaper:");
|
||||||
|
|
||||||
@@ -1402,6 +1486,26 @@ test("SESSION_PREFIX is ocp-tui-", () => {
|
|||||||
assert.equal(SESSION_PREFIX, "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("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||||
const killed = [];
|
const killed = [];
|
||||||
const fakeTmux = (args) => {
|
const fakeTmux = (args) => {
|
||||||
@@ -1532,7 +1636,7 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
|||||||
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||||
wallclockMs: 120000,
|
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 {
|
} else {
|
||||||
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => {
|
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => {
|
||||||
@@ -1540,6 +1644,279 @@ 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 >= 3 && flatPane.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (needle <3 chars, placeholder present)", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
// 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 ──
|
// ── Cleanup ──
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user