mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dced52215 | ||
|
|
d291331998 | ||
|
|
9568411bcb | ||
|
|
1f577c075f | ||
|
|
6dff36959a | ||
|
|
1b02f181fa | ||
|
|
0000926358 | ||
|
|
aa1c65beb1 | ||
|
|
879b40fe93 | ||
|
|
68d58e7df4 | ||
|
|
4a7d79c330 | ||
|
|
c3b1f32c86 | ||
|
|
4458490caa | ||
|
|
36be723198 | ||
|
|
7b065600aa | ||
|
|
1b5a742711 | ||
|
|
05a984df89 | ||
|
|
a30b20978c | ||
|
|
cd98b51b96 | ||
|
|
74260d7f6f | ||
|
|
885f62addf |
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,98 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Fix — code-audit P1/P2 hardening
|
||||||
|
|
||||||
|
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
|
||||||
|
|
||||||
|
**Availability / correctness (P1):**
|
||||||
|
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
|
||||||
|
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
|
||||||
|
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
|
||||||
|
|
||||||
|
**Security gates / cache integrity (P2):**
|
||||||
|
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
|
||||||
|
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
|
||||||
|
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
|
||||||
|
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
|
||||||
|
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.17.0 — 2026-05-31
|
||||||
|
|
||||||
|
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
|
||||||
|
|
||||||
|
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
|
||||||
|
|
||||||
|
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
|
||||||
|
|
||||||
|
From 2026-06-15 Anthropic routes `claude -p` / `--output-format` invocations to the Agent SDK credit pool (`cc_entrypoint=sdk-cli`). This feature adds an opt-in bridge: when `CLAUDE_TUI_MODE=true`, OCP serves each request via a real interactive `claude` session (no `-p`, no `--output-format`) so it carries `cc_entrypoint=cli` and bills against the Pro/Max subscription.
|
||||||
|
|
||||||
|
The complete string response is read from claude's native JSONL session transcript and replayed to callers as a normal OpenAI completion or chunked SSE. Clients see no API change. The default stream-json path is byte-for-byte unchanged when `CLAUDE_TUI_MODE` is unset.
|
||||||
|
|
||||||
|
**Security:** single-user / single-operator only. Never enable on a multi-user OCP. See ADR 0007 and README § "Subscription-pool (TUI) mode".
|
||||||
|
|
||||||
|
New env vars: `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`, `OCP_TUI_HOME`.
|
||||||
|
New ADR: `docs/adr/0007-tui-interactive-mode.md`.
|
||||||
|
New modules: `lib/tui/transcript.mjs`, `lib/tui/session.mjs` (shipped in preceding commits on this branch).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Model — add claude-opus-4-8
|
||||||
|
|
||||||
|
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v3.16.4 — 2026-05-13
|
## v3.16.4 — 2026-05-13
|
||||||
|
|
||||||
### Refactor — port-literal SPOT + CI guardrail
|
### Refactor — port-literal SPOT + CI guardrail
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -116,7 +120,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
installed and logged in (`claude auth status`). Install missing pieces
|
installed and logged in (`claude auth status`). Install missing pieces
|
||||||
using my system's package manager.
|
using my system's package manager.
|
||||||
2. git clone the repo, cd in, and run `node setup.mjs`.
|
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models).
|
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models).
|
||||||
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
||||||
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||||
|
|
||||||
@@ -142,7 +146,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
||||||
6. Run `ocp lan` to show me the LAN IP and connect command.
|
6. Run `ocp lan` to show me the LAN IP and connect command.
|
||||||
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models.
|
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models.
|
||||||
|
|
||||||
Tell me each step before running it. On error, diagnose before retrying.
|
Tell me each step before running it. On error, diagnose before retrying.
|
||||||
```
|
```
|
||||||
@@ -165,7 +169,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
chmod +x ocp-connect
|
chmod +x ocp-connect
|
||||||
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||||
3. Follow any IDE-specific manual hints it prints.
|
3. Follow any IDE-specific manual hints it prints.
|
||||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
|
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
|
||||||
5. Tell me to reload my shell + restart any IDE that was already running.
|
5. Tell me to reload my shell + restart any IDE that was already running.
|
||||||
|
|
||||||
Don't auto-retry on error. Tell me the failure mode first.
|
Don't auto-retry on error. Tell me the failure mode first.
|
||||||
@@ -235,7 +239,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
|||||||
**Verify:**
|
**Verify:**
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3456/v1/models
|
curl http://127.0.0.1:3456/v1/models
|
||||||
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Headless install notes
|
#### Headless install notes
|
||||||
@@ -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>
|
||||||
@@ -314,7 +318,7 @@ OCP Connect v1.3.0
|
|||||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||||
|
|
||||||
Testing API access...
|
Testing API access...
|
||||||
✓ API accessible (4 models available)
|
✓ API accessible (5 models available)
|
||||||
|
|
||||||
Shell config:
|
Shell config:
|
||||||
✓ .bashrc
|
✓ .bashrc
|
||||||
@@ -344,6 +348,7 @@ OCP Connect v1.3.0
|
|||||||
✓ OpenClaw configured
|
✓ OpenClaw configured
|
||||||
Provider: ocp
|
Provider: ocp
|
||||||
Models:
|
Models:
|
||||||
|
• ocp/claude-opus-4-8
|
||||||
• ocp/claude-opus-4-7
|
• ocp/claude-opus-4-7
|
||||||
• ocp/claude-opus-4-6
|
• ocp/claude-opus-4-6
|
||||||
• ocp/claude-sonnet-4-6
|
• ocp/claude-sonnet-4-6
|
||||||
@@ -365,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)
|
||||||
|
|
||||||
@@ -404,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.
|
||||||
@@ -423,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.
|
||||||
|
|
||||||
@@ -469,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)
|
||||||
@@ -674,17 +693,18 @@ Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored loca
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
```
|
```
|
||||||
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
|
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
|
||||||
```
|
```
|
||||||
|
|
||||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||||
|
|
||||||
## Available Models
|
## Available Models
|
||||||
|
|
||||||
| Model ID | Notes |
|
| Model ID | Notes |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||||
| `claude-opus-4-6` | Previous Opus, retained for pinning |
|
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||||
|
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||||
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||||
|
|
||||||
@@ -871,7 +891,13 @@ 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_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||||
|
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||||
|
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||||
|
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||||
|
|
||||||
### Streaming heartbeat
|
### Streaming heartbeat
|
||||||
|
|
||||||
@@ -883,6 +909,74 @@ Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. I
|
|||||||
|
|
||||||
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
|
||||||
|
|
||||||
|
## Subscription-pool (TUI) mode
|
||||||
|
|
||||||
|
> **SECURITY — read before enabling.**
|
||||||
|
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
|
||||||
|
|
||||||
|
### What it is and why
|
||||||
|
|
||||||
|
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
|
||||||
|
|
||||||
|
| Launch method | `cc_entrypoint` | Billing pool |
|
||||||
|
|---------------|-----------------|-------------|
|
||||||
|
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
|
||||||
|
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
|
||||||
|
|
||||||
|
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
|
||||||
|
|
||||||
|
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
|
||||||
|
|
||||||
|
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
|
||||||
|
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
|
||||||
|
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
|
||||||
|
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
|
||||||
|
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
|
||||||
|
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
|
||||||
|
|
||||||
|
### Enabling TUI-mode (opt-in)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites
|
||||||
|
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
||||||
|
# tmux must be installed: brew install tmux / apt install tmux
|
||||||
|
|
||||||
|
# Enable
|
||||||
|
export CLAUDE_TUI_MODE=true
|
||||||
|
# Optionally tune:
|
||||||
|
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||||
|
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||||
|
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart OCP. At boot you will see:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||||
|
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
|
||||||
|
```
|
||||||
|
|
||||||
|
### What changes / what doesn't
|
||||||
|
|
||||||
|
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||||
|
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||||
|
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||||
|
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
||||||
|
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||||
|
|
||||||
|
### Kill-switch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unset CLAUDE_TUI_MODE
|
||||||
|
# restart OCP
|
||||||
|
```
|
||||||
|
|
||||||
|
The stream-json path is restored immediately. No other change is needed.
|
||||||
|
|
||||||
|
### Architecture and design decisions
|
||||||
|
|
||||||
|
See [`docs/adr/0007-tui-interactive-mode.md`](docs/adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
Top-level files a contributor or operator may need to know:
|
Top-level files a contributor or operator may need to know:
|
||||||
|
|||||||
+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,166 @@
|
|||||||
|
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||||
|
|
||||||
|
**Date:** 2026-05-31
|
||||||
|
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
|
||||||
|
**Deciders:** project maintainer
|
||||||
|
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
On 2026-05-14 Anthropic announced (effective 2026-06-15) a billing split that routes requests by `cc_entrypoint`:
|
||||||
|
|
||||||
|
| `cc_entrypoint` value | Billing pool |
|
||||||
|
|-----------------------|-------------|
|
||||||
|
| `cli` | Pro/Max subscription pool |
|
||||||
|
| `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro = easily exhausted) |
|
||||||
|
|
||||||
|
OCP's existing path (`claude --output-format stream-json -p`) sets `cc_entrypoint=sdk-cli`. After 2026-06-15 every OCP request will draw from the Agent SDK pool rather than the subscription.
|
||||||
|
|
||||||
|
The structural response: add an opt-in mode that drives a real **interactive** `claude` session (no `-p`, no `--output-format`), which carries `cc_entrypoint=cli` and therefore bills against the subscription. The response text is read from claude's native JSONL transcript instead of from `stdout`.
|
||||||
|
|
||||||
|
This is a personal-use A-path feature (single-user, single-subscription host). It is **not** a multi-tenant isolation layer.
|
||||||
|
|
||||||
|
### Source-verified entrypoint mechanism (PR-4 amendment)
|
||||||
|
|
||||||
|
Claude CLI's `main()` calls a startup function (`t$A` in the compiled bundle) that sets
|
||||||
|
`process.env.CLAUDE_CODE_ENTRYPOINT` **only if unset** to:
|
||||||
|
|
||||||
|
```
|
||||||
|
(argv has -p/--print/--init-only/--sdk-url OR !process.stdout.isTTY) ? "sdk-cli" : "cli"
|
||||||
|
```
|
||||||
|
|
||||||
|
The billing header reads `cc_entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT ?? "unknown"`.
|
||||||
|
The `"unknown"` branch is dead code for any real `main()` spawn — the startup function always
|
||||||
|
sets a value on unset env. The **real risk** is not `"unknown"`: it is a **lost TTY** (e.g. stdout
|
||||||
|
redirected or a non-PTY spawn) silently flipping the self-classification to `"sdk-cli"` and
|
||||||
|
drawing from the metered pool.
|
||||||
|
|
||||||
|
`cc_entrypoint` is one of ~6 upstream run-mode signals. The **dominant discriminator** is the
|
||||||
|
system-prompt identity block ("official CLI" vs "Claude Agent SDK"), which is driven by genuine
|
||||||
|
interactivity (no `-p`, no `--output-format`, real PTY) and is overridable by no env var. This
|
||||||
|
is the real reason the tmux/no-`-p` approach works: the spawn is genuinely interactive, not just
|
||||||
|
labelled as such.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. Each request spawns a fresh tmux session running `claude --model <M> --session-id <UUID> --strict-mcp-config --disallowedTools 'mcp__*'` (no `-p`, no `--output-format`).
|
||||||
|
2. The spawn result is checked immediately: if `tmux new-session` returns a non-zero exit status (or a falsy result), the request is aborted with `tui_spawn_failed: tmux session not created` **before** the boot sleep. This is the spawn/PTY gate — OCP must not issue a billing request without a verified interactive session.
|
||||||
|
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
|
||||||
|
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
|
||||||
|
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
|
||||||
|
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
|
||||||
|
|
||||||
|
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
|
||||||
|
|
||||||
|
`CLAUDE_CODE_ENTRYPOINT` on the spawn env is managed by `resolveTuiEntrypointEnv(env, mode)`
|
||||||
|
(exported from `lib/tui/session.mjs`, pure, testable). The function **always deletes any
|
||||||
|
inherited value first** so a stray env var from OCP's own parent process can never leak in and
|
||||||
|
mislabel the billing header. Then:
|
||||||
|
|
||||||
|
| `OCP_TUI_ENTRYPOINT` | Behaviour |
|
||||||
|
|----------------------|-----------|
|
||||||
|
| `cli` (default) | Sets `CLAUDE_CODE_ENTRYPOINT=cli` deterministically — subscription-pool classification. **Honest only because the spawn is a genuine interactive PTY** (tmux pane, no `-p`, stdout not redirected, `new-session` verified). |
|
||||||
|
| `auto` | Deletes the key → claude self-classifies via `t$A` (TTY → `cli`). Use to observe/diagnose the real TTY-derived value. |
|
||||||
|
| `off` | Leaves the env exactly as inherited — diagnostics / honesty audit only. |
|
||||||
|
|
||||||
|
**Governing rule (verbatim):** *OCP may make a true value deterministic; it may never assert a
|
||||||
|
value the spawn's real state contradicts. When it cannot make the claim true (e.g. cannot
|
||||||
|
guarantee a PTY), it fails/drops the request — it does not force the signal.*
|
||||||
|
|
||||||
|
This is why the spawn/PTY gate (step 2 above) is load-bearing for `mode="cli"`: if `new-session`
|
||||||
|
fails, there is no PTY, so asserting `cli` would be dishonest. Abort rather than lie.
|
||||||
|
|
||||||
|
OCP never suppresses the billing header (anti-fingerprinting: we do not mask the spawn).
|
||||||
|
|
||||||
|
### 2026-06-15 verification protocol
|
||||||
|
|
||||||
|
Run one quiesced canary request in TUI-mode and watch the **Agent SDK credit balance** (not the
|
||||||
|
request header). If the balance drops, the subscription pool is unreachable via spawn. Per the
|
||||||
|
constitution (`ALIGNMENT.md`), the response is to **drop the Anthropic provider** rather than
|
||||||
|
escalate spoofing.
|
||||||
|
|
||||||
|
Version caveat: mechanism verified on cli.js v2.1.104 + live on v2.1.158. Re-verify after any
|
||||||
|
major cli.js upgrade.
|
||||||
|
|
||||||
|
### Default behaviour is unchanged
|
||||||
|
|
||||||
|
When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeTui` or `runTuiTurn`. `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — byte-for-byte identical to the pre-TUI code path.
|
||||||
|
|
||||||
|
### Kill-switch
|
||||||
|
|
||||||
|
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||||||
|
|
||||||
|
### Home strategy (real-home default)
|
||||||
|
|
||||||
|
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
|
||||||
|
|
||||||
|
- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||||
|
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use scratch-home only with a dedicated OAuth or for ephemeral testing.
|
||||||
|
|
||||||
|
### Working directory
|
||||||
|
|
||||||
|
`TUI_CWD = OCP_TUI_CWD || $HOME/.ocp-tui/work` (dedicated scratch cwd). Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/` — a stable, single location separate from the operator's real project histories. The directory is created automatically on first request.
|
||||||
|
|
||||||
|
### MCP hard-disable
|
||||||
|
|
||||||
|
`--strict-mcp-config` (no `--mcp-config` argument) prevents account-attached managed MCP servers from connecting. Belt-and-braces: `--disallowedTools 'mcp__*'` blocks any MCP tool invocation even if a server were somehow loaded. Built-in tools (Bash, Read, etc.) are left enabled on the A-path (single-user, acceptable).
|
||||||
|
|
||||||
|
### Session namespace
|
||||||
|
|
||||||
|
All tmux sessions use the prefix `ocp-tui-`. The prefix-scoped reaper (`reapStaleTuiSessions`) kills only `ocp-tui-*` sessions, never `olp-tui-*` or any other prefix. A stale-session cleanup runs once at OCP boot when `TUI_MODE` is on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECURITY — PROMINENT WARNING
|
||||||
|
|
||||||
|
**TUI-mode is SINGLE-USER / SINGLE-OPERATOR ONLY.**
|
||||||
|
|
||||||
|
`claude` runs as the OCP process owner with full filesystem access regardless of `HOME` setting. Home selection is **not** user isolation. If OCP is serving multiple users or guest API keys:
|
||||||
|
|
||||||
|
- A guest prompt would run `claude` with the **operator's** filesystem access.
|
||||||
|
- An adversarial prompt could exfiltrate files, run shell commands, or exhaust the subscription.
|
||||||
|
|
||||||
|
**Never enable `CLAUDE_TUI_MODE=true` on an OCP instance that serves untrusted callers or multiple users.**
|
||||||
|
|
||||||
|
The B-path (multi-tenant isolation) requires:
|
||||||
|
1. `--tools ""` (no built-in tools)
|
||||||
|
2. Per-key ephemeral `HOME` (isolated credentials + no cross-key project pollution)
|
||||||
|
3. Sandbox runtime (e.g. `@anthropic-ai/sandbox-runtime`)
|
||||||
|
|
||||||
|
B-path is **deferred** and is not implemented in this ADR. Until B-path lands, TUI-mode must only be enabled on a personal single-user OCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- After 2026-06-15, requests in TUI-mode bill against the Pro/Max subscription pool (`cc_entrypoint=cli`) rather than the Agent SDK credit pool.
|
||||||
|
- Kill-switch is immediate (unset env var + restart); zero code change required.
|
||||||
|
- Default stream-json path is untouched — no regression risk for existing deployments.
|
||||||
|
|
||||||
|
### Negative / trade-offs
|
||||||
|
|
||||||
|
- **No token streaming:** responses are buffered then replayed as chunked SSE. Clients see a delay then the full response arrives; real-time token streaming is not available in TUI-mode.
|
||||||
|
- **Billing unmeasurable until 2026-06-15:** the `cc_entrypoint=cli` signal is verified, but the credit deduction from the correct pool cannot be confirmed until the billing split activates.
|
||||||
|
- **tmux dependency:** the host must have `tmux` installed. CI / Docker images that lack tmux cannot use TUI-mode (the default stream-json path is unaffected).
|
||||||
|
- **Wall-clock cap:** long Opus thinking turns may hit the 120 s cap. Increase `CLAUDE_TUI_WALLCLOCK_MS` if needed (no quiescence heuristic — the reader polls until terminal marker or cap).
|
||||||
|
- **Grey-area usage:** running an interactive `claude` session headlessly to serve HTTP requests is not an officially documented use case. If Anthropic policy changes to block this pattern, OCP must fall back to the stream-json path (unset `CLAUDE_TUI_MODE`).
|
||||||
|
|
||||||
|
### Coexistence
|
||||||
|
|
||||||
|
- tmux prefix `ocp-tui-` is registered. Any co-hosted OLP test instance must use `olp-tui-`. Never run two TUI proxies on the same OAuth concurrently — stop one instance during integration testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
|
||||||
|
|
||||||
|
**Status:** Forward-looking planning doc (not yet a decision)
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Audience:** future OCP maintainer / session picking up multi-tenant security work
|
||||||
|
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this doc exists
|
||||||
|
|
||||||
|
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
|
||||||
|
|
||||||
|
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
|
||||||
|
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
|
||||||
|
|
||||||
|
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
|
||||||
|
|
||||||
|
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The multi-tenant gap (OCP-specific)
|
||||||
|
|
||||||
|
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
|
||||||
|
|
||||||
|
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
|
||||||
|
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
|
||||||
|
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
|
||||||
|
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
|
||||||
|
|
||||||
|
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
|
||||||
|
|
||||||
|
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
|
||||||
|
|
||||||
|
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
|
||||||
|
|
||||||
|
### 3.1 Anthropic's design doesn't expect external sandboxing
|
||||||
|
|
||||||
|
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
|
||||||
|
|
||||||
|
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
|
||||||
|
|
||||||
|
### 3.2 `~/.claude.json` upstream status is "closed not planned"
|
||||||
|
|
||||||
|
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
|
||||||
|
|
||||||
|
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
|
||||||
|
|
||||||
|
### 3.3 The threat model doesn't justify the cost
|
||||||
|
|
||||||
|
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
|
||||||
|
|
||||||
|
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
|
||||||
|
|
||||||
|
### 3.4 OLP-specific reason that does NOT apply to OCP
|
||||||
|
|
||||||
|
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.1–3.3) are sufficient on their own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Three viable approaches for OCP
|
||||||
|
|
||||||
|
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
|
||||||
|
|
||||||
|
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
|
||||||
|
|
||||||
|
Per-spawn setup:
|
||||||
|
|
||||||
|
```
|
||||||
|
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
|
||||||
|
mkdir -p $ephemeralRoot/.claude
|
||||||
|
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
|
||||||
|
HOME=$ephemeralRoot claude -p --output-format stream-json ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Mechanics:
|
||||||
|
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
|
||||||
|
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
|
||||||
|
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
|
||||||
|
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
|
||||||
|
|
||||||
|
Threat coverage:
|
||||||
|
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
|
||||||
|
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
|
||||||
|
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
|
||||||
|
|
||||||
|
5-minute spike before adopting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
|
||||||
|
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
|
||||||
|
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
|
||||||
|
```
|
||||||
|
|
||||||
|
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
|
||||||
|
|
||||||
|
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
|
||||||
|
|
||||||
|
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
bwrap \
|
||||||
|
--ro-bind / / \
|
||||||
|
--tmpfs /home/<ocp-user> \
|
||||||
|
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
|
||||||
|
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
|
||||||
|
--dev /dev --proc /proc --tmpfs /tmp \
|
||||||
|
claude -p ...
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
|
||||||
|
|
||||||
|
Threat coverage:
|
||||||
|
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
|
||||||
|
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
|
||||||
|
- ✅ `~/.ssh` and similar identity material absent from sandbox.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
- bwrap dependency: install `bubblewrap` apt package on host.
|
||||||
|
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
|
||||||
|
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
|
||||||
|
|
||||||
|
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
|
||||||
|
|
||||||
|
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
|
||||||
|
|
||||||
|
```
|
||||||
|
mount -t overlay overlay \
|
||||||
|
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
|
||||||
|
/tmp/ocp-spawn/<reqId>/merged-claude
|
||||||
|
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
|
||||||
|
# After spawn: umount + rm -rf
|
||||||
|
```
|
||||||
|
|
||||||
|
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
|
||||||
|
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
|
||||||
|
|
||||||
|
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
|
||||||
|
|
||||||
|
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cross-key isolation orthogonal layer
|
||||||
|
|
||||||
|
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
|
||||||
|
|
||||||
|
For that, two options compose with any of A/B/C:
|
||||||
|
|
||||||
|
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
|
||||||
|
|
||||||
|
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
|
||||||
|
|
||||||
|
```
|
||||||
|
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
|
||||||
|
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
|
||||||
|
filesystem: {
|
||||||
|
denyRead: [
|
||||||
|
...otherKeysWorkspaces, // all keys except current
|
||||||
|
'/home/<ocp-user>/.ssh',
|
||||||
|
'/home/<ocp-user>/.gnupg',
|
||||||
|
'/home/<ocp-user>/.aws',
|
||||||
|
],
|
||||||
|
allowWrite: [ephemeralRoot, '/tmp'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
|
||||||
|
|
||||||
|
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
|
||||||
|
|
||||||
|
### 5.2 Per-OS-user OCP spawning
|
||||||
|
|
||||||
|
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
|
||||||
|
|
||||||
|
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
- Setup script complexity (one-time per key).
|
||||||
|
- Linux-only.
|
||||||
|
- Doesn't fit Mac mini deployment.
|
||||||
|
|
||||||
|
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Trust model framing
|
||||||
|
|
||||||
|
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
|
||||||
|
|
||||||
|
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
|
||||||
|
|
||||||
|
| Tier | Trust Model | Sandbox requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
|
||||||
|
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
|
||||||
|
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
|
||||||
|
|
||||||
|
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Recommendation if/when this work starts
|
||||||
|
|
||||||
|
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
|
||||||
|
- ~50 LOC, no apt deps, works on Mac mini + Linux
|
||||||
|
- Solves the EROFS upgrade tax structurally
|
||||||
|
- Closes cross-key OAuth-credential-file lateral read
|
||||||
|
- Cost-effective hygiene improvement
|
||||||
|
|
||||||
|
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
|
||||||
|
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
|
||||||
|
- Default off (preserves Mac mini family deployment)
|
||||||
|
- Operator opts in on Linux cloud deployments
|
||||||
|
- Apt prereq documented in deployment guide
|
||||||
|
|
||||||
|
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
|
||||||
|
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
|
||||||
|
- Treat as separate ADR amendment with its own threat-model evidence
|
||||||
|
|
||||||
|
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Authority citations
|
||||||
|
|
||||||
|
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
|
||||||
|
|
||||||
|
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
|
||||||
|
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
|
||||||
|
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
|
||||||
|
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
|
||||||
|
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
|
||||||
|
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
|
||||||
|
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
|
||||||
|
- [Bubblewrap README](https://github.com/containers/bubblewrap)
|
||||||
|
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
|
||||||
|
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
|
||||||
|
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
|
||||||
|
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
|
||||||
|
|
||||||
|
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
|
||||||
|
|
||||||
|
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
|
||||||
|
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
|
||||||
|
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What this doc is NOT
|
||||||
|
|
||||||
|
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
|
||||||
|
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
|
||||||
|
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
|
||||||
|
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
# TUI-mode (OCP-first) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an opt-in `CLAUDE_TUI_MODE` to OCP that serves `/v1/chat/completions` by driving a *real interactive* `claude` session (no `-p`, no `--output-format`) so the request bills as `cc_entrypoint=cli` (subscription pool), reading the answer from claude's native JSONL transcript — while the default stream-json path stays byte-for-byte unchanged.
|
||||||
|
|
||||||
|
**Architecture:** Two new pure-ish modules under `lib/tui/` — a transcript **reader** (`transcript.mjs`, provider-agnostic, the shareable core) and a tmux **session driver** (`session.mjs`, OCP-specific). `server.mjs` gains a `callClaudeTui()` that returns `Promise<string>` and is gated into the existing dispatch by a single env flag; because OCP's entire downstream (singleflight → `setCachedResponse` → `completionResponse` / chunked-SSE-replay → `recordUsage`) already consumes a string from `callClaude`, TUI-mode is a drop-in. Streaming is buffered then replayed as chunked SSE (no token streaming — deliberately, "don't build fragile features").
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM (`.mjs`), `tmux` (interactive PTY host), `child_process` (`spawnSync`), `node:fs` polling (no `fs.watch`, no terminal-screen parsing). Test harness: `node test-features.mjs`.
|
||||||
|
|
||||||
|
**Source of truth for the TUI mechanism:** the OLP design spec `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md` (CLI-level, applies to both projects) + its 6 validation spikes (S1–S6, T1–T6) run on PI231 against `claude v2.1.158`. This plan is the OCP-grounded execution of that spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why OCP-first / scope decisions (read before coding)
|
||||||
|
|
||||||
|
- **OCP-first** because OCP has the users and its compute path is `callClaude → Promise<string>`, a near-perfect impedance match for a reader that also returns a string. OLP would additionally need a string→IR-chunk-array adapter. OLP-sync is **deferred entirely until the post-2026-06-15 fork decision** — do not spend cycles keeping OLP's TUI in lockstep.
|
||||||
|
- **A-path only.** Single-user / multi-device on one subscription. No per-key ephemeral isolation, no multi-tenant. (That is the OLP B-path, deferred.)
|
||||||
|
- **A-path isolation = real `$HOME` + dedicated scratch cwd + `--strict-mcp-config`.** OCP has *no* ISOLATION contract and we do not build one. We run interactive `claude` in the operator's real home (OAuth + onboarding already valid) but in a **dedicated scratch working directory** (`OCP_TUI_CWD`, default `$HOME/.ocp-tui/work`) so transcripts land under one stable `projects/<cwd>` folder instead of polluting the operator's genuine project histories, and the trust-folder dialog is granted once.
|
||||||
|
- **One `claude` session per request.** OCP is stateless (full conversation re-serialized each request via `messagesToPrompt`). TUI-mode mirrors this: per request, start a fresh interactive session with a fresh `--session-id`, submit one serialized prompt, await turn completion, read the transcript, extract the latest assistant text, tear the session down. Warm-pool / large-paste optimizations are explicitly out of v1 scope.
|
||||||
|
- **Billing is unmeasurable until 2026-06-15.** Spike S1 proved the `cc_entrypoint=cli` *signal*, not the billed pool. The pre-6/15 deliverable is "a tested, working transport that emits `cli`"; 6/16 we flip the flag and measure with a documented kill-switch.
|
||||||
|
- **Coexistence rule (PI231 runs an OLP test instance too).** All tmux sessions use the prefix `ocp-tui-`; the reaper kills **only** `ocp-tui-*`, never `olp-tui-*`. Never run two TUI proxies on the same OAuth concurrently — stop the OLP test instance during OCP integration.
|
||||||
|
- **Provenance.** TUI-mode originated in OCP PR #101 (author courtesy: jaekwon-park <insainty21@gmail.com>). The PR #101 author should be credited + notified on the shipping PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | New/Modified |
|
||||||
|
|------|----------------|--------------|
|
||||||
|
| `lib/tui/transcript.mjs` | Pure transcript parsing + the polling reader. Returns the latest assistant text once the turn is terminal or the wall-clock cap elapses. Provider-agnostic — the shareable core. | **Create** |
|
||||||
|
| `lib/tui/session.mjs` | tmux session lifecycle: boot interactive `claude`, answer the trust dialog, submit the prompt (file → `"$(cat)"` paste → separate Enter), await the reader, tear down. Plus the prefix-scoped reaper. OCP-specific. | **Create** |
|
||||||
|
| `lib/tui/fixtures/` | Real transcript JSONL harvested from PI231 + a few hand-crafted edge cases, for the reader's unit tests. | **Create** |
|
||||||
|
| `server.mjs` | `callClaudeTui()` (`Promise<string>`); `streamStringAsSSE()` helper (DRY refactor of the cache-replay block); single-flag dispatch gates; reaper hook at boot; env consts. | **Modify** (`:258` env consts, `:1018`–`:1023` helpers, `:1467` dispatch, boot block) |
|
||||||
|
| `test-features.mjs` | Suite for the reader (fixtures, runs in CI) + a live-only guarded suite for the driver (`OCP_TUI_LIVE=1`, skipped in CI). | **Modify** |
|
||||||
|
| `docs/adr/0007-tui-interactive-mode.md` | OCP ADR 0007 (OCP's next number) — TUI mode rationale, billing-signal authority, scope, kill-switch. | **Create** |
|
||||||
|
| `README.md` | New env vars (`CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD`), a "Subscription-pool (TUI) mode" section, troubleshooting + kill-switch. | **Modify** |
|
||||||
|
| `CHANGELOG.md` | Unreleased entry. | **Modify** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-1 — Transcript reader (`lib/tui/transcript.mjs`)
|
||||||
|
|
||||||
|
The shareable core. Pure functions + a polling reader. Fully unit-testable from committed fixtures; needs PI231 only once, to harvest realistic fixtures.
|
||||||
|
|
||||||
|
### Task 0: Harvest real fixtures from PI231
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/fixtures/complete-haiku.jsonl` (real, has `turn_duration`)
|
||||||
|
- Create: `lib/tui/fixtures/complete-sonnet-multiblock.jsonl` (real, multi content-block answer)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Drive one real interactive turn on PI231 and copy its transcript**
|
||||||
|
|
||||||
|
On PI231 (the only box with an authenticated interactive `claude`), run a single interactive turn in a scratch cwd, then locate its transcript:
|
||||||
|
|
||||||
|
Run (on PI231):
|
||||||
|
```bash
|
||||||
|
SID=$(uuidgen)
|
||||||
|
mkdir -p ~/.ocp-tui/work
|
||||||
|
# drive one turn by hand in tmux OR reuse a transcript already produced by the S-spikes:
|
||||||
|
ls -t ~/.claude/projects/-home-*-.ocp-tui-work/*.jsonl 2>/dev/null | head
|
||||||
|
# pick one complete transcript (must contain a line with "subtype":"turn_duration")
|
||||||
|
```
|
||||||
|
Expected: at least one `.jsonl` file whose tail contains `{"type":"system","subtype":"turn_duration",...}`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Copy 2 real transcripts into the repo as fixtures, scrubbed**
|
||||||
|
|
||||||
|
Run (from the workstation):
|
||||||
|
```bash
|
||||||
|
scp pi231:'~/.claude/projects/<encoded-cwd>/<sid>.jsonl' lib/tui/fixtures/complete-haiku.jsonl
|
||||||
|
# Scrub: the transcript may contain the prompt/answer text only (no OAuth token — tokens
|
||||||
|
# live in ~/.claude/.credentials.json, NOT in projects/*.jsonl). Confirm no credential
|
||||||
|
# material before committing:
|
||||||
|
grep -iE "sk-ant|oat01|bearer|authorization" lib/tui/fixtures/*.jsonl && echo "STOP: scrub" || echo "clean"
|
||||||
|
```
|
||||||
|
Expected: `clean`. (Transcripts hold conversation content + metadata, never the bearer token. If a fixture's prompt text is sensitive, replace it with a benign hand-edited turn that keeps the JSON shape.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the fixtures**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/fixtures/complete-haiku.jsonl lib/tui/fixtures/complete-sonnet-multiblock.jsonl
|
||||||
|
git commit -m "test(tui): real claude transcript fixtures harvested from PI231 (v2.1.158)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 1: `encodeCwd` + `transcriptPath` (the path formula)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs` (new Suite "TUI transcript")
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add to `test-features.mjs`:
|
||||||
|
```js
|
||||||
|
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||||
|
import { encodeCwd, transcriptPath } from "./lib/tui/transcript.mjs";
|
||||||
|
|
||||||
|
test("encodeCwd replaces every slash incl. leading", () => {
|
||||||
|
assertEqual(encodeCwd("/home/u/.ocp-tui/work"), "-home-u-.ocp-tui-work");
|
||||||
|
});
|
||||||
|
test("transcriptPath composes EHOME/.claude/projects/<enc>/<sid>.jsonl", () => {
|
||||||
|
assertEqual(
|
||||||
|
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
|
||||||
|
"/home/u/.claude/projects/-home-u-.ocp-tui-work/abc-123.jsonl"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript\|Cannot find module"`
|
||||||
|
Expected: FAIL — `Cannot find module './lib/tui/transcript.mjs'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Create `lib/tui/transcript.mjs`:
|
||||||
|
```js
|
||||||
|
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||||
|
// and returns the latest assistant turn's text once the turn is terminal.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 — interactive session transcript at
|
||||||
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||||
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Project-dir encoding: every "/" -> "-" (including the leading slash).
|
||||||
|
export function encodeCwd(cwd) {
|
||||||
|
return cwd.replace(/\//g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptPath(home, cwd, sessionId) {
|
||||||
|
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "tui transcript"`
|
||||||
|
Expected: PASS for both cases.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): transcript path formula (encodeCwd + transcriptPath)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: `parseTranscriptLines` + `isTerminalLine` + `extractLatestAssistantText`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { parseTranscriptLines, isTerminalLine, extractLatestAssistantText } from "./lib/tui/transcript.mjs";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||||
|
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
|
||||||
|
assertEqual(evs.length, 2);
|
||||||
|
assertEqual(evs[1].b, 2);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on turn_duration", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on stop_reason tool_use (message-wrapped + flat)", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
|
||||||
|
assertEqual(isTerminalLine({ stop_reason: "tool_use" }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine false on ordinary assistant/text lines", () => {
|
||||||
|
assertEqual(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||||
|
});
|
||||||
|
test("extractLatestAssistantText concatenates text blocks of the LAST assistant turn", () => {
|
||||||
|
const evs = [
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||||
|
{ type: "user", message: { content: "..." } },
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
|
||||||
|
];
|
||||||
|
assertEqual(extractLatestAssistantText(evs), "AB");
|
||||||
|
});
|
||||||
|
test("real complete fixture yields non-empty text and is terminal", () => {
|
||||||
|
const evs = parseTranscriptLines(readFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert(evs.some(isTerminalLine), "fixture must contain a terminal line");
|
||||||
|
assert(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "parseTranscript\|isTerminal\|extractLatest\|real complete fixture"`
|
||||||
|
Expected: FAIL — exports not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation** (append to `lib/tui/transcript.mjs`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||||
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||||
|
export function parseTranscriptLines(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line marks the assistant turn complete when it is the turn_duration system
|
||||||
|
// event, or an assistant message that stopped to hand off to a tool.
|
||||||
|
export function isTerminalLine(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||||
|
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
|
||||||
|
return sr === "tool_use";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||||
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||||
|
export function extractLatestAssistantText(events) {
|
||||||
|
let text = "";
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev || ev.type !== "assistant") continue;
|
||||||
|
const content = ev.message && ev.message.content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts = content
|
||||||
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map((b) => b.text);
|
||||||
|
if (parts.length) text = parts.join("");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -iE "parseTranscript|isTerminal|extractLatest|real complete fixture"`
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): transcript parsing + terminal detection + assistant-text extraction"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: `readTuiTranscript` (the polling reader with wall-clock cap)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/transcript.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||||
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
test("readTuiTranscript returns assistant text when terminal marker present", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
writeFileSync(p, [
|
||||||
|
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
||||||
|
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||||
|
assertEqual(out, "hello world");
|
||||||
|
});
|
||||||
|
test("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
writeFileSync(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 }); // never terminal
|
||||||
|
assertEqual(out, "partial");
|
||||||
|
});
|
||||||
|
test("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||||
|
const dir = mkdtempSync(`${tmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/missing.jsonl`; // file never appears
|
||||||
|
let threw = false;
|
||||||
|
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
|
||||||
|
catch { threw = true; }
|
||||||
|
assert(threw, "must throw on empty timeout");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||||
|
Expected: FAIL — export not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation** (append)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Block until the session transcript is terminal (turn_duration / tool_use) or
|
||||||
|
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||||
|
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||||
|
// partial text; on cap with no text at all, throws.
|
||||||
|
//
|
||||||
|
// 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).
|
||||||
|
export async function readTuiTranscript({ transcriptPath: p, wallclockMs = 120000, pollMs = 250 }) {
|
||||||
|
const deadline = Date.now() + wallclockMs;
|
||||||
|
let lastText = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
const events = parseTranscriptLines(readFileSync(p, "utf8"));
|
||||||
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
|
if (events.some(isTerminalLine)) return lastText;
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
if (lastText) return lastText;
|
||||||
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "readTuiTranscript"`
|
||||||
|
Expected: all 3 PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/transcript.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): polling transcript reader with wall-clock cap (no quiescence)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-2 — Session driver (`lib/tui/session.mjs`)
|
||||||
|
|
||||||
|
tmux lifecycle + the validated submission recipe. Cannot be unit-tested without a live authenticated `claude`; tested by a live-only guarded suite that runs on PI231.
|
||||||
|
|
||||||
|
### Task 4: `reapStaleTuiSessions` (prefix-scoped reaper)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/tui/session.mjs`
|
||||||
|
- Test: `test-features.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (pure — no live claude; inject a fake tmux runner)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { reapStaleTuiSessions, SESSION_PREFIX } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||||
|
const killed = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assertEqual(SESSION_PREFIX, "ocp-tui-");
|
||||||
|
assertEqual(n, 2);
|
||||||
|
assertEqual(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||||
|
Expected: FAIL — module/export missing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Minimal implementation**
|
||||||
|
|
||||||
|
Create `lib/tui/session.mjs`:
|
||||||
|
```js
|
||||||
|
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||||
|
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||||
|
// => cc_entrypoint=cli). Submission recipe + dialog handling validated by spikes
|
||||||
|
// T3/T6 on PI231. See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { transcriptPath, readTuiTranscript } from "./transcript.mjs";
|
||||||
|
|
||||||
|
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||||
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const defaultTmux = (args, opts = {}) => spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||||
|
|
||||||
|
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||||
|
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||||
|
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||||
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
let killed = 0;
|
||||||
|
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
if (name.startsWith(SESSION_PREFIX)) { tmux(["kill-session", "-t", name]); killed++; }
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | grep -i "reaper kills"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/session.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): prefix-scoped session reaper (ocp-tui-* only)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 5: `runTuiTurn` (boot → trust dialog → paste → Enter → read → teardown)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/tui/session.mjs`
|
||||||
|
- Test: `test-features.mjs` (live-only, guarded by `OCP_TUI_LIVE=1`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the live-only guarded test** (skipped in CI; run on PI231)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Live-only: requires an authenticated interactive `claude`. Skipped unless OCP_TUI_LIVE=1.
|
||||||
|
if (process.env.OCP_TUI_LIVE === "1") {
|
||||||
|
test("runTuiTurn drives a real interactive turn and returns text", async () => {
|
||||||
|
const { runTuiTurn } = await import("./lib/tui/session.mjs");
|
||||||
|
const out = await runTuiTurn({
|
||||||
|
prompt: "Reply with exactly the word PONG and nothing else.",
|
||||||
|
model: "claude-haiku-4-5-20251001",
|
||||||
|
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
|
||||||
|
home: process.env.HOME,
|
||||||
|
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||||
|
wallclockMs: 120000,
|
||||||
|
});
|
||||||
|
assert(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => { assert(true); });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails** (on a box, with the flag)
|
||||||
|
|
||||||
|
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||||
|
Expected: FAIL — `runTuiTurn` not exported yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implementation** (append to `lib/tui/session.mjs`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Boot wait + dialog timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "3500", 10);
|
||||||
|
const DIALOG_MS = parseInt(process.env.OCP_TUI_DIALOG_MS || "1200", 10);
|
||||||
|
const PASTE_SETTLE_MS = parseInt(process.env.OCP_TUI_PASTE_MS || "1800", 10);
|
||||||
|
|
||||||
|
const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // single-quote for sh -c
|
||||||
|
|
||||||
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||||
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||||
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||||
|
// belt-and-braces with --disallowedTools "mcp__*".
|
||||||
|
function buildTuiCmd(claudeBin, model, sessionId) {
|
||||||
|
return [
|
||||||
|
shq(claudeBin),
|
||||||
|
"--model", shq(model),
|
||||||
|
"--session-id", sessionId,
|
||||||
|
"--strict-mcp-config",
|
||||||
|
"--disallowedTools", shq("mcp__*"),
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTuiTurn({
|
||||||
|
prompt, model, claudeBin, home, cwd,
|
||||||
|
wallclockMs = 120000, tmux = defaultTmux,
|
||||||
|
}) {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
||||||
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||||
|
|
||||||
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
|
const promptFile = `${tmpDir}/prompt.txt`;
|
||||||
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||||
|
|
||||||
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
||||||
|
delete env.CLAUDECODE; delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_BASE_URL; delete env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
if (home) env.HOME = home;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Boot the interactive session inside tmux, in the dedicated scratch cwd.
|
||||||
|
tmux(["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
|
buildTuiCmd(claudeBin, model, sessionId)], { env });
|
||||||
|
await sleep(BOOT_MS);
|
||||||
|
|
||||||
|
// 2. Answer the trust-folder dialog defensively. The seeded bypass flag (if any)
|
||||||
|
// suppresses the *bypass-permissions* dialog but NOT the trust-folder dialog;
|
||||||
|
// "1" = "Yes, proceed". Harmless if the dialog is absent (cwd already trusted).
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "1"]);
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
await sleep(DIALOG_MS);
|
||||||
|
|
||||||
|
// 3. Submit the prompt. Body is pasted via `"$(cat file)"` so the content never
|
||||||
|
// touches the command line (no shell injection from prompt text), then a
|
||||||
|
// SEPARATE Enter key event submits it (Ink #15553: literal "\n" in a paste
|
||||||
|
// does not submit; the Enter key event does).
|
||||||
|
spawnSync("sh", ["-c",
|
||||||
|
`${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
|
||||||
|
{ env, encoding: "utf8" });
|
||||||
|
await sleep(PASTE_SETTLE_MS);
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
|
||||||
|
// 4. Read the answer from the native transcript.
|
||||||
|
const tpath = transcriptPath(home || process.env.HOME, cwd, sessionId);
|
||||||
|
return await readTuiTranscript({ transcriptPath: tpath, wallclockMs });
|
||||||
|
} finally {
|
||||||
|
// 5. Teardown — always. Kill the session, remove the temp prompt dir.
|
||||||
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes** (PI231, live)
|
||||||
|
|
||||||
|
Run (PI231): `OCP_TUI_LIVE=1 node test-features.mjs 2>&1 | grep -i "runTuiTurn"`
|
||||||
|
Expected: PASS — output contains `PONG`. Also confirm no orphan sessions: `tmux ls 2>/dev/null | grep ocp-tui- || echo "clean"` → `clean`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lib/tui/session.mjs test-features.mjs
|
||||||
|
git commit -m "feat(tui): runTuiTurn — interactive session driver (boot/trust/paste/Enter/read/teardown)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PR-3 — Wiring into `server.mjs`
|
||||||
|
|
||||||
|
Gate TUI-mode behind one env flag. Default path (`CLAUDE_TUI_MODE` unset) stays byte-for-byte identical.
|
||||||
|
|
||||||
|
### Task 6: env consts + `streamStringAsSSE` DRY refactor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (env consts near `:275`; refactor cache-replay block `:1524`–`:1539` into a helper near `:1023`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add TUI env consts + import** (near the other `const ... = process.env...` at `server.mjs:258`–`:275`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
// TUI-mode (subscription-pool bridge). Opt-in; default OFF keeps stream-json path.
|
||||||
|
// Authority: docs/adr/0007-tui-interactive-mode.md.
|
||||||
|
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
|
||||||
|
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
|
||||||
|
const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract the chunked-SSE-replay into a reusable helper** (near `completionResponse` at `:1023`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
||||||
|
// Extracted from the cache-hit replay block so TUI-mode streaming reuses it.
|
||||||
|
function streamStringAsSSE(res, id, model, content) {
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
||||||
|
const CHUNK = 80;
|
||||||
|
const codepoints = Array.from(content);
|
||||||
|
for (let i = 0; i < codepoints.length; i += CHUNK) {
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
|
||||||
|
}
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Point the cache-hit streaming replay (`:1524`–`:1539`) at the helper** (DRY — behavior identical)
|
||||||
|
|
||||||
|
Replace the inline block inside `if (stream) { ... }` of the cache hit with:
|
||||||
|
```js
|
||||||
|
if (stream) {
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
streamStringAsSSE(res, id, model, cached.response);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the full suite to verify no regression**
|
||||||
|
|
||||||
|
Run: `node test-features.mjs 2>&1 | tail -3`
|
||||||
|
Expected: all existing tests PASS (the refactor is behavior-preserving; cache-replay covered by existing D3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs
|
||||||
|
git commit -m "refactor(server): extract streamStringAsSSE helper + add TUI env consts"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: `callClaudeTui` + dispatch gates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (new `callClaudeTui` near `callClaude:735`; gates at the buffered dispatch `:1563`/`:1594` and streaming dispatch `:1551`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `callClaudeTui`** (near `callClaude`, after `:800`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// TUI-mode upstream: drive an interactive claude session, return the assistant
|
||||||
|
// text as a string — same contract as callClaude(), so all downstream
|
||||||
|
// (singleflight, cache write-back, completionResponse) is unchanged.
|
||||||
|
// System messages are rendered inline as [System] blocks by messagesToPrompt;
|
||||||
|
// we deliberately do NOT pass --system-prompt in interactive mode to avoid any
|
||||||
|
// flag that could perturb cc_entrypoint classification.
|
||||||
|
function callClaudeTui(model, messages, conversationId, keyName) {
|
||||||
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||||
|
recordModelRequest(cliModel, prompt.length);
|
||||||
|
return runTuiTurn({
|
||||||
|
prompt, model: cliModel, claudeBin: CLAUDE,
|
||||||
|
home: process.env.HOME, cwd: TUI_CWD, wallclockMs: TUI_WALLCLOCK_MS,
|
||||||
|
}).then((text) => {
|
||||||
|
recordModelSuccess(cliModel, 0);
|
||||||
|
return text;
|
||||||
|
}).catch((err) => {
|
||||||
|
recordModelError(cliModel, false);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Gate the buffered dispatch** — at `server.mjs:1563`–`:1597`, replace the two `callClaude(...)` call sites (inside the singleflight closure and the cache-disabled fallback) with a selected upstream:
|
||||||
|
|
||||||
|
Add once, just before the `if (CACHE_TTL > 0 && req._cacheHash)` block (~`:1563`):
|
||||||
|
```js
|
||||||
|
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
|
||||||
|
```
|
||||||
|
Then change `await callClaude(model, messages, conversationId, req._authKeyName)` → `await upstreamCall(model, messages, conversationId, req._authKeyName)` at **both** sites (`:1572` and `:1594`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Gate the streaming dispatch** — at `server.mjs:1551`–`:1553`, branch TUI streaming to buffer-then-replay:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (stream) {
|
||||||
|
if (TUI_MODE) {
|
||||||
|
// TUI has no token stream; buffer the turn, write-back to cache, replay as chunked SSE.
|
||||||
|
const t0Usage = Date.now();
|
||||||
|
try {
|
||||||
|
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
||||||
|
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||||
|
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
|
}
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
streamStringAsSSE(res, id, model, content);
|
||||||
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch {}
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {}; return; }
|
||||||
|
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||||
|
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default: real stream-json streaming, unchanged.
|
||||||
|
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify default path is untouched + TUI path selected only by flag**
|
||||||
|
|
||||||
|
Run: `CLAUDE_TUI_MODE= node -e "process.env.CLAUDE_TUI_MODE; import('./server.mjs')" 2>&1 | head -1 || true`
|
||||||
|
Then the regression suite: `node test-features.mjs 2>&1 | tail -3`
|
||||||
|
Expected: all PASS (no test sets `CLAUDE_TUI_MODE`, so `upstreamCall === callClaude` and streaming uses `callClaudeStreaming` — identical to today).
|
||||||
|
|
||||||
|
Live end-to-end (PI231, after Task 8 setup): with `CLAUDE_TUI_MODE=true` start OCP and `curl` both `stream:false` and `stream:true`:
|
||||||
|
```bash
|
||||||
|
curl -s localhost:3456/v1/chat/completions -H "Authorization: Bearer <key>" \
|
||||||
|
-d '{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"say PONG"}]}' | head
|
||||||
|
```
|
||||||
|
Expected: a normal OpenAI completion whose content contains `PONG`. Cross-check on PI231 that the spawned `claude` had no `-p`/`--output-format` (`ps -ef | grep claude`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs
|
||||||
|
git commit -m "feat(tui): gate interactive TUI upstream behind CLAUDE_TUI_MODE (buffered + streaming)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 8: reaper hook at boot + ADR + README + CHANGELOG
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server.mjs` (boot block — call `reapStaleTuiSessions()` once on startup when `TUI_MODE`)
|
||||||
|
- Create: `docs/adr/0007-tui-interactive-mode.md`
|
||||||
|
- Modify: `README.md`, `CHANGELOG.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Reaper on boot** (in the server start/`listen` block)
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (TUI_MODE) {
|
||||||
|
try { const n = reapStaleTuiSessions(); if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n }); } catch {}
|
||||||
|
console.log(` TUI-mode: ON (interactive claude → cc_entrypoint=cli). cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write ADR 0007** — `docs/adr/0007-tui-interactive-mode.md`
|
||||||
|
|
||||||
|
Context: 2026-06-15 billing split routes by `cc_entrypoint`; `-p`/`--output-format` ⇒ `sdk-cli` (Agent SDK credit pool, ~$20 on Pro = unusable). Decision: opt-in interactive driver ⇒ `cli` (subscription pool). Authority: spec §1/§4, claude v2.1.158. Scope: A-path single-user; MCP hard-disabled via `--strict-mcp-config`. Kill-switch: unset `CLAUDE_TUI_MODE` → stream-json path restored. Consequences: no token streaming (buffered+replayed); grey-area, billing unmeasurable until 6/15; reaper + tmux-prefix coexistence rules.
|
||||||
|
|
||||||
|
- [ ] **Step 3: README** — add `CLAUDE_TUI_MODE`, `CLAUDE_TUI_WALLCLOCK_MS`, `OCP_TUI_CWD` to the env-var table; add a "Subscription-pool (TUI) mode" section (what it is, opt-in, the 6/15 rationale, no-streaming caveat, the one-time `mkdir -p ~/.ocp-tui/work` + tmux dependency, and the `CLAUDE_TUI_MODE` unset kill-switch).
|
||||||
|
|
||||||
|
- [ ] **Step 4: CHANGELOG** — Unreleased: `feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool); default stream-json path unchanged.`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server.mjs docs/adr/0007-tui-interactive-mode.md README.md CHANGELOG.md
|
||||||
|
git commit -m "feat(tui): boot reaper + ADR 0007 + README + CHANGELOG (TUI-mode docs)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration & canary (post-implementation, on PI231)
|
||||||
|
|
||||||
|
1. Stop the OLP test instance (`:4567`) — clean shared OAuth + no tmux collision.
|
||||||
|
2. `git clone`/checkout this branch on PI231, `mkdir -p ~/.ocp-tui/work`, start OCP on `:3456` with `CLAUDE_TUI_MODE=true`.
|
||||||
|
3. Run the live driver suite: `OCP_TUI_LIVE=1 node test-features.mjs`.
|
||||||
|
4. End-to-end `curl` (buffered + streaming) through OCP; confirm spawned `claude` carries no `-p`/`--output-format`.
|
||||||
|
5. **Pre-6/15 deliverable = here.** Billing measurement waits for 6/15; document the kill-switch (unset `CLAUDE_TUI_MODE`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (against spec + the OCP-first execution review)
|
||||||
|
|
||||||
|
- **Spec coverage:** transcript path formula (§4 → Task 1), parsing/terminal/extract (§4 → Task 2), polling reader + wall-clock cap + no-quiescence (§4.3 → Task 3), submission recipe file→paste→Enter (§5/T3 → Task 5), trust-dialog handling (§5.2 → Task 5), MCP disable `--strict-mcp-config` (§5.2/T6 → Tasks 5 & buildTuiCmd), string-contract drop-in (→ Tasks 6–7), kill-switch + default-path-sacred (→ Task 7 Step 4), coexistence prefix + reaper (→ Tasks 4 & 8). ✅
|
||||||
|
- **Review findings folded:** OCP-first string match (Task 7); no ephemeral-home, real-home + scratch cwd (scope §); reader-only sharing, driver forked (file table); tmux prefix + scoped reaper + never-both-on-OAuth (Task 4, Integration §1); `TIMEOUT=600000 > 120s` cap verified (no SIGKILL-mid-turn); `--strict-mcp-config` added (Task 5); provenance jaekwon-park (Why §). OLP-sync deferred. ✅
|
||||||
|
- **Placeholder scan:** none — every code step carries real code; every run step an exact command + expected output. ✅
|
||||||
|
- **Type consistency:** `runTuiTurn`/`reapStaleTuiSessions`/`SESSION_PREFIX` exported in Task 4–5 match imports in Task 6–8; `streamStringAsSSE(res, id, model, content)` defined Task 6, used Tasks 6–7; `callClaudeTui(model, messages, conversationId, keyName)` mirrors `callClaude`'s signature. ✅
|
||||||
|
- **Open item for integration:** confirm on PI231 that the seeded `~/.claude.json` is unnecessary for real-home A (onboarding already complete); if a bypass-permissions dialog *does* appear in real home, add a one-line seed step (`bypassPermissionsModeAccepted:true`) — but the driver already answers the trust dialog defensively, so the turn still completes.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,335 @@
|
|||||||
|
// TUI-mode session driver: hosts an interactive `claude` in tmux, submits one
|
||||||
|
// serialized prompt, awaits the transcript reader, tears down. OCP-specific.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.158 interactive mode (no -p / no --output-format
|
||||||
|
// => cc_entrypoint=cli). Submission recipe validated by spikes T3/T6 on PI231.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md.
|
||||||
|
//
|
||||||
|
// Trust handling: rather than answer the trust-folder dialog interactively (which
|
||||||
|
// only appears on a cwd's FIRST encounter — sending a defensive "1" to an already
|
||||||
|
// trusted cwd would inject a stray prompt turn), we PRE-TRUST the scratch cwd by
|
||||||
|
// seeding <home>/.claude.json. Every turn then boots dialog-free and identical.
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, statSync, renameSync, symlinkSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readTuiTranscript } from "./transcript.mjs";
|
||||||
|
|
||||||
|
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
||||||
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||||
|
|
||||||
|
const defaultTmux = (args, opts = {}) =>
|
||||||
|
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||||
|
|
||||||
|
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
||||||
|
// OLP test instance's `olp-tui-*` sessions are never touched.
|
||||||
|
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
||||||
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
let killed = 0;
|
||||||
|
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
if (name.startsWith(SESSION_PREFIX)) {
|
||||||
|
tmux(["kill-session", "-t", name]);
|
||||||
|
killed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task 5: runTuiTurn ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Boot + paste-settle timing. Conservative defaults validated on PI231; env-tunable.
|
||||||
|
const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10); // max wait for input-ready
|
||||||
|
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||||
|
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Capture the visible tmux pane as plain text (for readiness / paste verification).
|
||||||
|
function tuiCapturePane(tmux, tmuxName) {
|
||||||
|
const r = tmux(["capture-pane", "-p", "-t", tmuxName]);
|
||||||
|
return (r && typeof r.stdout === "string") ? r.stdout : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once claude's input bar is rendered and ready for keystrokes.
|
||||||
|
function tuiInputReady(pane) {
|
||||||
|
return /\? for shortcuts/.test(pane);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust
|
||||||
|
// affirmative signals — NOT "the placeholder is gone", which is unreliable (claude's
|
||||||
|
// placeholder uses a curly quote `"`, randomized example text, and renders the big paste
|
||||||
|
// a beat after paste-buffer returns; a "placeholder-gone" heuristic false-positived on the
|
||||||
|
// still-empty box and made us submit Enter into nothing → issue #130 hang). Landed iff:
|
||||||
|
// (a) the bracketed-paste indicator "[Pasted text" is present (large/multi-line paste), OR
|
||||||
|
// (b) the prompt's own leading text appears in the pane (short/literal paste).
|
||||||
|
function tuiPromptLanded(pane, prompt) {
|
||||||
|
const flatPane = pane.replace(/\s+/g, " ");
|
||||||
|
if (flatPane.includes("[Pasted text")) return true;
|
||||||
|
const firstLine = String(prompt).split("\n").map(s => s.trim()).find(Boolean) || "";
|
||||||
|
const needle = firstLine.replace(/\s+/g, " ").slice(0, 24);
|
||||||
|
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.
|
||||||
|
function shq(s) {
|
||||||
|
return `'${String(s).replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-trust the scratch cwd by seeding the trust record in <home>/.claude.json so
|
||||||
|
// the trust-folder dialog never appears. Verified-live trust shape:
|
||||||
|
// projects["<cwd>"] = { hasTrustDialogAccepted: true, allowedTools: [], ... }
|
||||||
|
// Idempotent + best-effort: a missing/unreadable .claude.json must not abort a
|
||||||
|
// turn (a fresh cwd would then show the dialog once; the boot wait tolerates it).
|
||||||
|
// Must run BEFORE the session boots so claude reads the trusted record at startup.
|
||||||
|
export function ensureTuiCwdTrusted(home, cwd) {
|
||||||
|
if (!home || !cwd) return;
|
||||||
|
const path = `${home}/.claude.json`;
|
||||||
|
let j, mode;
|
||||||
|
try {
|
||||||
|
j = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
mode = statSync(path).mode & 0o777;
|
||||||
|
} catch { return; }
|
||||||
|
j.projects = j.projects || {};
|
||||||
|
const entry = j.projects[cwd] || {};
|
||||||
|
if (entry.hasTrustDialogAccepted === true) return; // already trusted, no rewrite
|
||||||
|
entry.hasTrustDialogAccepted = true;
|
||||||
|
if (!Array.isArray(entry.allowedTools)) entry.allowedTools = [];
|
||||||
|
j.projects[cwd] = entry;
|
||||||
|
// Atomic write (temp + rename on the same fs), preserving mode, so a crash
|
||||||
|
// mid-write can never truncate the user's real ~/.claude.json. We seed ONLY the
|
||||||
|
// per-project trust flag — NOT bypassPermissionsModeAccepted: the driver never
|
||||||
|
// passes --dangerously-skip-permissions, so the bypass dialog cannot appear, and
|
||||||
|
// onboarding completion is an A-path precondition (the host already runs claude).
|
||||||
|
// NOTE: when the A-path moves to a dedicated scratch HOME (task #26), this writes
|
||||||
|
// a file we fully own, removing the real-config-mutation concern entirely.
|
||||||
|
try {
|
||||||
|
const tmp = `${path}.ocp-tui.${process.pid}.tmp`;
|
||||||
|
writeFileSync(tmp, JSON.stringify(j, null, 2), { mode });
|
||||||
|
renameSync(tmp, path);
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the HOME claude runs under. Two modes:
|
||||||
|
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||||
|
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
|
||||||
|
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
|
||||||
|
// .credentials.json, with a seeded .claude.json (onboarded real config minus
|
||||||
|
// the user's project history; trusts only the scratch cwd) and its own
|
||||||
|
// projects/ dir — so the real ~/.claude is never mutated or polluted.
|
||||||
|
//
|
||||||
|
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
|
||||||
|
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
|
||||||
|
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
|
||||||
|
// refresh in the scratch home can invalidate the token the user's real-home claude
|
||||||
|
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
|
||||||
|
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
|
||||||
|
// which shares one .credentials.json — identical to how OCP already spawns claude.
|
||||||
|
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
|
||||||
|
// corrupts. Run BEFORE the session boots.
|
||||||
|
export function prepareTuiHome(realHome, tuiHome, cwd) {
|
||||||
|
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||||
|
try {
|
||||||
|
const claudeDir = `${tuiHome}/.claude`;
|
||||||
|
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||||
|
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
|
||||||
|
const link = `${claudeDir}/.credentials.json`;
|
||||||
|
if (!existsSync(link)) {
|
||||||
|
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
|
||||||
|
// drop the user's project history, trust only the scratch cwd. mode 0600.
|
||||||
|
const seedPath = `${tuiHome}/.claude.json`;
|
||||||
|
if (!existsSync(seedPath)) {
|
||||||
|
let base = {};
|
||||||
|
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||||
|
base.hasCompletedOnboarding = true;
|
||||||
|
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||||
|
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
// Ensure the cwd is trusted in the scratch config (idempotent; atomic).
|
||||||
|
ensureTuiCwdTrusted(tuiHome, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Billing-classifier labeling ─────────────────────────────────────────
|
||||||
|
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
|
||||||
|
// inherited value first (so a stray entrypoint from OCP's own parent env can never
|
||||||
|
// leak into / mislabel the billing header). Then:
|
||||||
|
// "cli" (default) → set "cli": deterministic subscription-pool classification.
|
||||||
|
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
|
||||||
|
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
|
||||||
|
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
|
||||||
|
// observe/diagnose the real TTY-derived value.
|
||||||
|
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
|
||||||
|
export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
||||||
|
if (mode === "off") return env;
|
||||||
|
delete env.CLAUDE_CODE_ENTRYPOINT;
|
||||||
|
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||||
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||||
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||||
|
// belt-and-braces with --disallowedTools "mcp__*".
|
||||||
|
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
||||||
|
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
||||||
|
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
||||||
|
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 [
|
||||||
|
envPrefix,
|
||||||
|
shq(claudeBin),
|
||||||
|
"--model", shq(model),
|
||||||
|
"--session-id", sessionId,
|
||||||
|
"--strict-mcp-config",
|
||||||
|
"--disallowedTools", shq("mcp__*"),
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full per-request TUI lifecycle:
|
||||||
|
// 1. Pre-trust the scratch cwd (no trust dialog will appear).
|
||||||
|
// 2. Write prompt to a 0600 temp file (no shell injection from prompt content).
|
||||||
|
// 3. Boot an interactive `claude` in a fresh tmux session in the scratch cwd; poll
|
||||||
|
// capture-pane until the `? for shortcuts` input bar appears (readiness-poll
|
||||||
|
// replaces the old blind boot sleep). BOOT_MS is the max wait, not a fixed delay.
|
||||||
|
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) —
|
||||||
|
// reliable for large multi-line prompts where send-keys -l is not (issue #130).
|
||||||
|
// Poll-verify the prompt landed in the input (placeholder gone / [Pasted text]);
|
||||||
|
// fast-fail with tui_paste_not_landed if it never lands (prevents the 120s
|
||||||
|
// wallclock "stuck typing" hang). Then submit with a SEPARATE Enter key event.
|
||||||
|
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||||
|
// marker or wall-clock cap.
|
||||||
|
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||||
|
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||||
|
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||||
|
export async function runTuiTurn({
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
claudeBin,
|
||||||
|
home,
|
||||||
|
realHome,
|
||||||
|
cwd,
|
||||||
|
wallclockMs = 120000,
|
||||||
|
entrypointMode = "cli",
|
||||||
|
tmux = defaultTmux,
|
||||||
|
}) {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
||||||
|
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
||||||
|
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
||||||
|
|
||||||
|
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||||
|
// cwd — before claude boots.
|
||||||
|
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||||
|
prepareTuiHome(rhome, ehome, cwd);
|
||||||
|
|
||||||
|
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||||
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
|
const promptFile = `${tmpDir}/prompt.txt`;
|
||||||
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||||
|
|
||||||
|
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
|
||||||
|
// env vars that might interfere with interactive-mode classification.
|
||||||
|
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
||||||
|
delete env.CLAUDECODE;
|
||||||
|
delete env.ANTHROPIC_API_KEY;
|
||||||
|
delete env.ANTHROPIC_BASE_URL;
|
||||||
|
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
|
||||||
|
resolveTuiEntrypointEnv(env, entrypointMode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
||||||
|
// Capture the result: if tmux new-session fails (status !== 0) there is no
|
||||||
|
// PTY, no interactive spawn — abort BEFORE the boot sleep rather than paste
|
||||||
|
// into a non-existent session or issue a billing request without a verified
|
||||||
|
// interactive context. The finally teardown is still harmless (kill-session
|
||||||
|
// is a no-op when the session never existed).
|
||||||
|
const spawnResult = tmux(
|
||||||
|
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||||
|
buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)],
|
||||||
|
{ env },
|
||||||
|
);
|
||||||
|
if (!spawnResult || spawnResult.status !== 0) {
|
||||||
|
throw new Error("tui_spawn_failed: tmux session not created");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wait until claude's input bar is actually ready (was: blind sleep(BOOT_MS)).
|
||||||
|
// BOOT_MS is now the MAX readiness wait, not a fixed delay.
|
||||||
|
const ready = await pollUntil(() => tuiInputReady(tuiCapturePane(tmux, tmuxName)),
|
||||||
|
{ timeoutMs: BOOT_MS, intervalMs: READY_POLL_MS });
|
||||||
|
if (!ready) {
|
||||||
|
// (readiness timed out; relying on paste-verify)
|
||||||
|
console.error("[tui] input_not_ready", tmuxName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Paste the prompt via a tmux PASTE BUFFER with bracketed paste (-p), NOT
|
||||||
|
// `send-keys -l`. send-keys of a large multi-line prompt is unreliable: the
|
||||||
|
// embedded newlines arrive as separate key events (effectively repeated Enter),
|
||||||
|
// so a big OpenClaw-style prompt never lands and the turn hangs to the wallclock
|
||||||
|
// (issue #130 — reproduced at ~300 lines; fixed by bracketed paste). load-buffer
|
||||||
|
// reads the file directly (no shell arg limit, no `"$(cat)"`), and paste-buffer -p
|
||||||
|
// wraps it in bracketed-paste markers so claude ingests it atomically as ONE paste
|
||||||
|
// ("[Pasted text #N +M lines]"). -d deletes the buffer afterward. Buffer name is the
|
||||||
|
// per-session tmuxName, so concurrent turns never collide.
|
||||||
|
tmux(["load-buffer", "-b", tmuxName, promptFile]);
|
||||||
|
tmux(["paste-buffer", "-b", tmuxName, "-t", tmuxName, "-p", "-d"]);
|
||||||
|
|
||||||
|
// Verify the prompt POSITIVELY landed before submitting; poll (a large bracketed paste
|
||||||
|
// takes a beat to render the "[Pasted text]" indicator). This is load-bearing: firing
|
||||||
|
// Enter before the paste renders submits an empty box → the turn hangs to the wallclock
|
||||||
|
// (issue #130). Fast-fail if it never lands → deterministic error in seconds.
|
||||||
|
const landed = await pollUntil(() => tuiPromptLanded(tuiCapturePane(tmux, tmuxName), prompt),
|
||||||
|
{ timeoutMs: PASTE_VERIFY_MS, intervalMs: READY_POLL_MS });
|
||||||
|
if (!landed) {
|
||||||
|
throw new Error("tui_paste_not_landed: prompt did not reach claude's input within " + PASTE_VERIFY_MS + "ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit (separate Enter key event).
|
||||||
|
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||||
|
|
||||||
|
// 4. Block on the native transcript (resolved by session-id) until terminal.
|
||||||
|
// Returns { text, entrypoint } from readTuiTranscript.
|
||||||
|
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||||
|
} finally {
|
||||||
|
// 5. Teardown — always, even on throw.
|
||||||
|
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||||
|
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Transcript reader for TUI-mode. Reads claude's native JSONL session transcript
|
||||||
|
// and returns the latest assistant turn's text once the turn is terminal.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI v2.1.157 — interactive session transcript at
|
||||||
|
// <HOME>/.claude/projects/<CWD with every "/" -> "-">/<--session-id>.jsonl
|
||||||
|
// Completion marker: a line {"type":"system","subtype":"turn_duration",...}.
|
||||||
|
// See docs/superpowers/specs/2026-05-30-tui-mode-production-design.md §4.
|
||||||
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
|
||||||
|
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
|
||||||
|
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
|
||||||
|
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
|
||||||
|
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
|
||||||
|
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
|
||||||
|
// immune to the exact encoding rule. This helper is kept for the known-path case.
|
||||||
|
export function encodeCwd(cwd) {
|
||||||
|
return cwd.replace(/[/.]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transcriptPath(home, cwd, sessionId) {
|
||||||
|
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate a session's transcript by its UUID across every projects subdir, without
|
||||||
|
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
||||||
|
// Returns the path, or null if not present yet (it appears once the turn starts).
|
||||||
|
export function findTranscriptPath(home, sessionId) {
|
||||||
|
if (!home || !sessionId) return null;
|
||||||
|
const root = `${home}/.claude/projects`;
|
||||||
|
let dirs;
|
||||||
|
try { dirs = readdirSync(root); } catch { return null; }
|
||||||
|
for (const d of dirs) {
|
||||||
|
const candidate = `${root}/${d}/${sessionId}.jsonl`;
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse NDJSON text into objects; skip blank lines and partial/forming lines
|
||||||
|
// (the live transcript is read mid-write, so the last line may be incomplete).
|
||||||
|
export function parseTranscriptLines(text) {
|
||||||
|
const out = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try { out.push(JSON.parse(t)); } catch { /* partial line being written */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A line marks the assistant turn complete when EITHER:
|
||||||
|
// (a) {type:"system", subtype:"turn_duration"} — emitted by newer claude builds
|
||||||
|
// (e.g. 2.1.159), OR
|
||||||
|
// (b) {type:"assistant"} whose message.stop_reason is a FINAL reason
|
||||||
|
// ("end_turn" / "stop_sequence" / "max_tokens"). This is the API-level
|
||||||
|
// end-of-turn signal, present across claude builds whose transcripts do NOT
|
||||||
|
// emit turn_duration (e.g. 2.1.114 — verified live on the cloud host). Without
|
||||||
|
// it OCP can't detect completion on those builds and hangs to the wallclock,
|
||||||
|
// then returns only partial text (issue #130, cloud/server-side symptom).
|
||||||
|
//
|
||||||
|
// stop_reason "tool_use" is deliberately NOT terminal: the model is mid-turn (it will
|
||||||
|
// run a tool and continue with a later assistant entry). Matching on a FINAL
|
||||||
|
// stop_reason — not on the mere presence of a tool_use — keeps tool-using turns intact.
|
||||||
|
// (The v3.17.1 narrowing dropped a buggy "tool_use is terminal" rule; this restores
|
||||||
|
// cross-version completion detection without bringing that bug back.)
|
||||||
|
const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
|
||||||
|
export function isTerminalLine(obj) {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
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
|
||||||
|
// (ignore thinking/tool_use blocks). Later assistant entries overwrite earlier.
|
||||||
|
// Fixture-confirmed shape: top-level type:"assistant", message.content[] array.
|
||||||
|
//
|
||||||
|
// Scoping: this returns the FINAL text-bearing assistant entry in the whole file,
|
||||||
|
// not "text since the matching user line" (spec §4.2). Those are equivalent ONLY
|
||||||
|
// under OCP's one-session-per-request model (a fresh --session-id => a fresh
|
||||||
|
// transcript holding one logical exchange). If a future warm-pool ever reuses a
|
||||||
|
// session WITHOUT a fresh session-id / clear, earlier-turn text could leak — that
|
||||||
|
// author must add user-line scoping here. See spec §7.2.
|
||||||
|
export function extractLatestAssistantText(events) {
|
||||||
|
let text = "";
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev || ev.type !== "assistant") continue;
|
||||||
|
const content = ev.message && ev.message.content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
const parts = content
|
||||||
|
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map((b) => b.text);
|
||||||
|
if (parts.length) text = parts.join("");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the entrypoint string from the turn_duration line (e.g. "cli"),
|
||||||
|
// or null if absent. Lets callers assert the subscription-classified path.
|
||||||
|
// Fixture-confirmed: entrypoint field lives directly on the turn_duration line.
|
||||||
|
export function verifyEntrypoint(events) {
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev && ev.type === "system" && ev.subtype === "turn_duration") {
|
||||||
|
return ev.entrypoint != null ? ev.entrypoint : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until the session transcript is terminal (turn_duration) or
|
||||||
|
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||||
|
// editors). Returns { text, entrypoint } where text is the latest assistant text
|
||||||
|
// 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
|
||||||
|
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||||
|
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
||||||
|
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
||||||
|
// file does not exist until the turn starts, so resolution happens inside the loop.
|
||||||
|
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||||
|
const deadline = Date.now() + wallclockMs;
|
||||||
|
let lastText = "";
|
||||||
|
let lastEntrypoint = null;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const resolved = p || findTranscriptPath(home, sessionId);
|
||||||
|
if (resolved && existsSync(resolved)) {
|
||||||
|
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||||
|
lastText = extractLatestAssistantText(events) || lastText;
|
||||||
|
const ep = verifyEntrypoint(events);
|
||||||
|
if (ep != null) lastEntrypoint = ep;
|
||||||
|
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
|
||||||
|
}
|
||||||
|
await sleep(pollMs);
|
||||||
|
}
|
||||||
|
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
|
||||||
|
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||||
|
}
|
||||||
+9
-1
@@ -2,6 +2,14 @@
|
|||||||
"$schema": "./models.schema.json",
|
"$schema": "./models.schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"models": [
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "claude-opus-4-8",
|
||||||
|
"displayName": "Claude Opus 4.8",
|
||||||
|
"openclawName": "Claude Opus 4.8 (via CLI)",
|
||||||
|
"reasoning": true,
|
||||||
|
"contextWindow": 200000,
|
||||||
|
"maxTokens": 16384
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-7",
|
"id": "claude-opus-4-7",
|
||||||
"displayName": "Claude Opus 4.7",
|
"displayName": "Claude Opus 4.7",
|
||||||
@@ -36,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"opus": "claude-opus-4-7",
|
"opus": "claude-opus-4-8",
|
||||||
"sonnet": "claude-sonnet-4-6",
|
"sonnet": "claude-sonnet-4-6",
|
||||||
"haiku": "claude-haiku-4-5-20251001"
|
"haiku": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
|
|||||||
+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.16.4",
|
"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) {
|
||||||
|
|||||||
+599
-131
File diff suppressed because it is too large
Load Diff
@@ -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/>
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -951,6 +1020,903 @@ await asyncTest("doctor --check oauth + 200 with null body → fix_service", asy
|
|||||||
assert.equal(result.fail_count, 1);
|
assert.equal(result.fail_count, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Stream-JSON parser tests ──────────────────────────────────────────────
|
||||||
|
// MIRRORS server.mjs parseStreamJsonLines/parseStreamJsonEvent — keep in sync.
|
||||||
|
// Copied verbatim to avoid importing server.mjs (top-level server.listen() would
|
||||||
|
// start a live HTTP server). The logEvent stub silences observability side-effects.
|
||||||
|
console.log("\nStream-JSON parsers:");
|
||||||
|
|
||||||
|
function logEvent() {} // stub — observability side-effect not needed in tests
|
||||||
|
|
||||||
|
function parseStreamJsonLines(buffered) {
|
||||||
|
const lines = buffered.split("\n");
|
||||||
|
const remainder = lines.pop(); // last element is the incomplete trailing line
|
||||||
|
const events = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") continue;
|
||||||
|
try {
|
||||||
|
events.push(JSON.parse(trimmed));
|
||||||
|
} catch {
|
||||||
|
console.error("[claude] NDJSON parse error on line:", trimmed.slice(0, 120));
|
||||||
|
events.push({ type: "parse_error", raw: trimmed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { events, remainder: remainder ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStreamJsonEvent(event, isFirstDelta) {
|
||||||
|
const t = event?.type;
|
||||||
|
|
||||||
|
// system/* — first-event init + other system meta (api_retry etc.)
|
||||||
|
if (t === "system") return null;
|
||||||
|
// user — echo of user message; consumed
|
||||||
|
if (t === "user") return null;
|
||||||
|
|
||||||
|
// stream_event — contains nested content_block_delta
|
||||||
|
if (t === "stream_event") {
|
||||||
|
const inner = event.event ?? event;
|
||||||
|
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
||||||
|
return { text: inner.delta.text ?? "" };
|
||||||
|
}
|
||||||
|
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assistant — aggregate message (fallback when no prior content_block_delta seen)
|
||||||
|
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
|
||||||
|
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
|
||||||
|
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
|
||||||
|
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
||||||
|
if (t === "assistant") {
|
||||||
|
if (isFirstDelta) {
|
||||||
|
const blocks = event.message?.content;
|
||||||
|
if (Array.isArray(blocks)) {
|
||||||
|
const text = blocks
|
||||||
|
.filter(b => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map(b => b.text)
|
||||||
|
.join("");
|
||||||
|
if (text) return { text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// result — terminal event
|
||||||
|
if (t === "result") {
|
||||||
|
if (event.is_error === true) {
|
||||||
|
return { error: event.error_message ?? event.result ?? "claude returned is_error" };
|
||||||
|
}
|
||||||
|
return { stop: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// rate_limit_event / usage — log for observability, don't forward
|
||||||
|
if (t === "rate_limit_event" || t === "usage") {
|
||||||
|
logEvent("info", "claude_stream_event", { type: t, data: JSON.stringify(event).slice(0, 200) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// control_request — per Anthropic stream-json docs
|
||||||
|
if (t === "control_request") {
|
||||||
|
console.error("[claude] stream_json control_request event (ignored):", JSON.stringify(event).slice(0, 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse_error — already logged by parseStreamJsonLines
|
||||||
|
if (t === "parse_error") return null;
|
||||||
|
|
||||||
|
// Unknown event type — log + skip; future-proof for new claude CLI events
|
||||||
|
if (t !== undefined) {
|
||||||
|
console.error("[claude] unknown stream_json event type:", t);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) content_block_delta deltas + assistant-aggregate fallback → assembled text with NO double-count
|
||||||
|
test("parseStreamJsonEvent: stream_event content_block_delta yields text", () => {
|
||||||
|
const event = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.deepEqual(result, { text: "Hello" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: assistant-aggregate used when isFirstDelta=true (no prior delta)", () => {
|
||||||
|
const event = {
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.deepEqual(result, { text: "Short answer." });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => {
|
||||||
|
const event = {
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: stream_event + assistant → assembled without double-count", () => {
|
||||||
|
// Simulate receiving a content_block_delta first, then an assistant aggregate
|
||||||
|
const delta = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Streaming text." } }
|
||||||
|
};
|
||||||
|
const agg = {
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: [{ type: "text", text: "Streaming text." }] }
|
||||||
|
};
|
||||||
|
// First event: isFirstDelta=true → yields text
|
||||||
|
const r1 = parseStreamJsonEvent(delta, true);
|
||||||
|
assert.deepEqual(r1, { text: "Streaming text." });
|
||||||
|
// Second event (aggregate): isFirstDelta is now false (content already emitted) → null
|
||||||
|
const r2 = parseStreamJsonEvent(agg, false);
|
||||||
|
assert.equal(r2, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (b) aggregate-only short response → assembles correctly
|
||||||
|
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
|
||||||
|
const event = {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Part one." },
|
||||||
|
{ type: "tool_use", id: "x" }, // non-text block — should be filtered
|
||||||
|
{ type: "text", text: " Part two." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.deepEqual(result, { text: "Part one. Part two." });
|
||||||
|
});
|
||||||
|
|
||||||
|
// (c) JSON line split across two parseStreamJsonLines calls → partial-line buffering
|
||||||
|
test("parseStreamJsonLines: partial line carried as remainder", () => {
|
||||||
|
const chunk1 = '{"type":"system","subtype":"init"}\n{"type":"stream_ev';
|
||||||
|
const { events: ev1, remainder: rem1 } = parseStreamJsonLines(chunk1);
|
||||||
|
assert.equal(ev1.length, 1);
|
||||||
|
assert.equal(ev1[0].type, "system");
|
||||||
|
assert.equal(rem1, '{"type":"stream_ev');
|
||||||
|
|
||||||
|
const chunk2 = rem1 + 'ent","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}}\n';
|
||||||
|
const { events: ev2, remainder: rem2 } = parseStreamJsonLines(chunk2);
|
||||||
|
assert.equal(ev2.length, 1);
|
||||||
|
assert.equal(ev2[0].type, "stream_event");
|
||||||
|
assert.equal(rem2, "");
|
||||||
|
// Verify the reassembled event parses through parseStreamJsonEvent correctly
|
||||||
|
const parsed = parseStreamJsonEvent(ev2[0], true);
|
||||||
|
assert.deepEqual(parsed, { text: "Hi" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
|
||||||
|
const { events, remainder } = parseStreamJsonLines("");
|
||||||
|
assert.equal(events.length, 0);
|
||||||
|
assert.equal(remainder, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// (d) is_error result event → surfaces the error
|
||||||
|
test("parseStreamJsonEvent: result is_error=true surfaces error_message", () => {
|
||||||
|
const event = { type: "result", is_error: true, error_message: "Rate limit hit" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { error: "Rate limit hit" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: result is_error=true falls back to result field when no error_message", () => {
|
||||||
|
const event = { type: "result", is_error: true, result: "error detail" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { error: "error detail" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: result is_error=true falls back to default string when no detail", () => {
|
||||||
|
const event = { type: "result", is_error: true };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { error: "claude returned is_error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: result is_error=false yields stop", () => {
|
||||||
|
const event = { type: "result", is_error: false, result: "success" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { stop: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// (e) malformed/non-JSON line → skipped without throwing
|
||||||
|
test("parseStreamJsonLines: malformed JSON line becomes parse_error event without throwing", () => {
|
||||||
|
const input = '{"type":"system"}\nnot-valid-json\n{"type":"result","is_error":false}\n';
|
||||||
|
const { events, remainder } = parseStreamJsonLines(input);
|
||||||
|
assert.equal(events.length, 3);
|
||||||
|
assert.equal(events[0].type, "system");
|
||||||
|
assert.equal(events[1].type, "parse_error");
|
||||||
|
assert.equal(events[1].raw, "not-valid-json");
|
||||||
|
assert.equal(events[2].type, "result");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: parse_error event returns null without throwing", () => {
|
||||||
|
const event = { type: "parse_error", raw: "garbage" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional edge cases
|
||||||
|
test("parseStreamJsonEvent: system event returns null", () => {
|
||||||
|
const result = parseStreamJsonEvent({ type: "system", subtype: "init" }, true);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: user event returns null", () => {
|
||||||
|
const result = parseStreamJsonEvent({ type: "user", message: {} }, true);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: stream_event non-text-delta (content_block_start) returns null", () => {
|
||||||
|
const event = { type: "stream_event", event: { type: "content_block_start", index: 0 } };
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: unknown event type returns null", () => {
|
||||||
|
const result = parseStreamJsonEvent({ type: "future_event_type" }, false);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
// ── Suite: streamStringAsSSE wire-format ────────────────────────────────
|
||||||
|
// streamStringAsSSE is not exported from server.mjs (internal helper), so we
|
||||||
|
// test the wire format contract using a local implementation with the same
|
||||||
|
// logic. This validates the protocol shape (role chunk → content chunks →
|
||||||
|
// stop → [DONE]) that both the cache-hit replay and TUI streaming paths rely on.
|
||||||
|
console.log("\nstreamStringAsSSE wire-format:");
|
||||||
|
|
||||||
|
function _testSendSSE(res, data) { res.write(`data: ${JSON.stringify(data)}\n\n`); }
|
||||||
|
|
||||||
|
function _testStreamStringAsSSE(res, id, model, content) {
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||||
|
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
||||||
|
const CHUNK = 80;
|
||||||
|
const codepoints = Array.from(content);
|
||||||
|
for (let i = 0; i < codepoints.length; i += CHUNK) {
|
||||||
|
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: codepoints.slice(i, i + CHUNK).join("") }, finish_reason: null }] });
|
||||||
|
}
|
||||||
|
_testSendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _makeFakeRes() {
|
||||||
|
const writes = [];
|
||||||
|
let headsSent = false;
|
||||||
|
return {
|
||||||
|
writes,
|
||||||
|
writeHead(status, headers) { headsSent = true; this._status = status; this._headers = headers; },
|
||||||
|
write(s) { writes.push(s); },
|
||||||
|
end() { this._ended = true; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("streamStringAsSSE emits role chunk + content chunks + stop + [DONE]", () => {
|
||||||
|
const res = _makeFakeRes();
|
||||||
|
const content = "hello world";
|
||||||
|
_testStreamStringAsSSE(res, "test-id", "claude-haiku", content);
|
||||||
|
assert.ok(res._status === 200, "writeHead(200) called");
|
||||||
|
assert.ok(res._ended, "res.end() called");
|
||||||
|
// First write: role delta
|
||||||
|
const firstEvent = JSON.parse(res.writes[0].replace(/^data: /, "").trim());
|
||||||
|
assert.equal(firstEvent.choices[0].delta.role, "assistant");
|
||||||
|
assert.equal(firstEvent.choices[0].finish_reason, null);
|
||||||
|
// Since content < 80 chars it fits in one chunk
|
||||||
|
const secondEvent = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
|
||||||
|
assert.equal(secondEvent.choices[0].delta.content, content);
|
||||||
|
// Second-to-last: stop chunk
|
||||||
|
const stopEvent = JSON.parse(res.writes[res.writes.length - 2].replace(/^data: /, "").trim());
|
||||||
|
assert.equal(stopEvent.choices[0].finish_reason, "stop");
|
||||||
|
// Last: [DONE]
|
||||||
|
assert.equal(res.writes[res.writes.length - 1], "data: [DONE]\n\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streamStringAsSSE splits content at 80 codepoints per chunk", () => {
|
||||||
|
const res = _makeFakeRes();
|
||||||
|
const content = "x".repeat(200); // 3 chunks: 80+80+40
|
||||||
|
_testStreamStringAsSSE(res, "test-id-2", "claude-haiku", content);
|
||||||
|
// writes: [role_chunk, content_chunk_1, content_chunk_2, content_chunk_3, stop_chunk, [DONE]]
|
||||||
|
assert.equal(res.writes.length, 6);
|
||||||
|
const c1 = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
|
||||||
|
assert.equal(c1.choices[0].delta.content.length, 80);
|
||||||
|
const c2 = JSON.parse(res.writes[2].replace(/^data: /, "").trim());
|
||||||
|
assert.equal(c2.choices[0].delta.content.length, 80);
|
||||||
|
const c3 = JSON.parse(res.writes[3].replace(/^data: /, "").trim());
|
||||||
|
assert.equal(c3.choices[0].delta.content.length, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
|
||||||
|
const res = _makeFakeRes();
|
||||||
|
_testStreamStringAsSSE(res, "test-id-3", "claude-haiku", "");
|
||||||
|
// writes: [role_chunk, stop_chunk, [DONE]]
|
||||||
|
assert.equal(res.writes.length, 3);
|
||||||
|
const stop = JSON.parse(res.writes[1].replace(/^data: /, "").trim());
|
||||||
|
assert.equal(stop.choices[0].finish_reason, "stop");
|
||||||
|
assert.equal(res.writes[2], "data: [DONE]\n\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||||
|
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint } from "./lib/tui/transcript.mjs";
|
||||||
|
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
|
||||||
|
import { tmpdir as tuiTmp0 } from "node:os";
|
||||||
|
|
||||||
|
console.log("\nTUI transcript — path formula:");
|
||||||
|
|
||||||
|
test("encodeCwd replaces every slash AND every dot with dash", () => {
|
||||||
|
// Verified live (claude v2.1.158): /home/u/.ocp-tui/work -> -home-u--ocp-tui-work
|
||||||
|
assert.equal(encodeCwd("/home/u/.ocp-tui/work"), "-home-u--ocp-tui-work");
|
||||||
|
assert.equal(encodeCwd("/tmp/tui-test"), "-tmp-tui-test"); // dot-free path still correct
|
||||||
|
});
|
||||||
|
test("transcriptPath composes HOME/.claude/projects/<enc>/<sid>.jsonl", () => {
|
||||||
|
assert.equal(
|
||||||
|
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
|
||||||
|
"/home/u/.claude/projects/-home-u--ocp-tui-work/abc-123.jsonl"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("findTranscriptPath locates <sid>.jsonl across projects subdirs by UUID", () => {
|
||||||
|
const home = tuiMkdtemp0(`${tuiTmp0()}/tui-home-`);
|
||||||
|
const sid = "11111111-2222-3333-4444-555555555555";
|
||||||
|
const proj = `${home}/.claude/projects/-some--weird-encoding`;
|
||||||
|
tuiMkdir0(proj, { recursive: true });
|
||||||
|
tuiWrite0(`${proj}/${sid}.jsonl`, "{}\n");
|
||||||
|
assert.equal(findTranscriptPath(home, sid), `${proj}/${sid}.jsonl`);
|
||||||
|
assert.equal(findTranscriptPath(home, "no-such-uuid"), null);
|
||||||
|
assert.equal(findTranscriptPath(null, sid), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nTUI transcript — parsing + terminal detection:");
|
||||||
|
|
||||||
|
test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||||
|
const evs = parseTranscriptLines('{"a":1}\n\n{bad json\n{"b":2}\n');
|
||||||
|
assert.equal(evs.length, 2);
|
||||||
|
assert.equal(evs[1].b, 2);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on turn_duration", () => {
|
||||||
|
assert.equal(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine false on stop_reason tool_use (message-wrapped) — tool_use is mid-turn in TUI mode", () => {
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), false);
|
||||||
|
});
|
||||||
|
test("isTerminalLine false on stop_reason tool_use (flat) — claude continues after tool, turn not done", () => {
|
||||||
|
assert.equal(isTerminalLine({ stop_reason: "tool_use" }), false);
|
||||||
|
});
|
||||||
|
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||||
|
});
|
||||||
|
// issue #130 cloud/server-side: claude builds (e.g. 2.1.114) that DON'T emit
|
||||||
|
// turn_duration mark turn-end via assistant message.stop_reason — must be terminal.
|
||||||
|
test("isTerminalLine true on assistant stop_reason end_turn (version-robust, e.g. 2.1.114)", () => {
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "end_turn", content: [{ type: "text", text: "ok" }] } }), true);
|
||||||
|
});
|
||||||
|
test("isTerminalLine true on assistant stop_reason stop_sequence / max_tokens", () => {
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "stop_sequence" } }), true);
|
||||||
|
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "max_tokens" } }), true);
|
||||||
|
});
|
||||||
|
test("extractLatestAssistantText concatenates text blocks of LAST assistant entry", () => {
|
||||||
|
const evs = [
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "first" }] } },
|
||||||
|
{ type: "user", message: { content: "..." } },
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "A" }, { type: "thinking", thinking: "x" }, { type: "text", text: "B" }] } },
|
||||||
|
];
|
||||||
|
assert.equal(extractLatestAssistantText(evs), "AB");
|
||||||
|
});
|
||||||
|
test("extractLatestAssistantText ignores thinking-only assistant entries", () => {
|
||||||
|
// Fixture shape: thinking block and text block are SEPARATE top-level entries sharing same msg id
|
||||||
|
const evs = [
|
||||||
|
{ type: "assistant", message: { content: [{ type: "thinking", thinking: "hmm" }] } },
|
||||||
|
{ type: "assistant", message: { content: [{ type: "text", text: "PONG" }] } },
|
||||||
|
];
|
||||||
|
assert.equal(extractLatestAssistantText(evs), "PONG");
|
||||||
|
});
|
||||||
|
test("real complete fixture: parseTranscriptLines yields >0 events", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert.ok(evs.length > 0, "fixture must parse to events");
|
||||||
|
});
|
||||||
|
test("real complete fixture: at least one isTerminalLine", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert.ok(evs.some(isTerminalLine), "fixture must contain a terminal line");
|
||||||
|
});
|
||||||
|
test("real complete fixture: extractLatestAssistantText returns non-empty text", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert.ok(extractLatestAssistantText(evs).length > 0, "fixture must yield assistant text");
|
||||||
|
});
|
||||||
|
test("real complete fixture: extractLatestAssistantText returns the FINAL text, not the first", () => {
|
||||||
|
// The fixture's first assistant text is "PONG"; it is followed by 8 later refusal
|
||||||
|
// turns. Pinning the exact FINAL string guards the overwrite-to-last semantic —
|
||||||
|
// a regression that returned the first text block would still pass a length check.
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert.equal(extractLatestAssistantText(evs), "I'm moving on. If you have a genuine task, let me know.");
|
||||||
|
});
|
||||||
|
test("real complete fixture: verifyEntrypoint returns 'cli'", () => {
|
||||||
|
const evs = parseTranscriptLines(tuiReadFileSync("./lib/tui/fixtures/complete-haiku.jsonl", "utf8"));
|
||||||
|
assert.equal(verifyEntrypoint(evs), "cli");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TUI transcript — polling reader (async) ──────────────────────────────
|
||||||
|
import { readTuiTranscript } from "./lib/tui/transcript.mjs";
|
||||||
|
import { mkdtempSync as tuiMkdtemp, writeFileSync as tuiWriteFile } from "node:fs";
|
||||||
|
import { tmpdir as tuiTmpdir } from "node:os";
|
||||||
|
|
||||||
|
console.log("\nTUI transcript — polling reader:");
|
||||||
|
|
||||||
|
await asyncTest("readTuiTranscript returns assistant text when terminal marker present", async () => {
|
||||||
|
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
tuiWriteFile(p, [
|
||||||
|
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
||||||
|
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }),
|
||||||
|
].join("\n") + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||||
|
assert.equal(out.text, "hello world");
|
||||||
|
assert.equal(out.entrypoint, "cli");
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||||
|
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/s.jsonl`;
|
||||||
|
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||||
|
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
||||||
|
assert.equal(out.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 () => {
|
||||||
|
const dir = tuiMkdtemp(`${tuiTmpdir()}/tui-`);
|
||||||
|
const p = `${dir}/missing.jsonl`;
|
||||||
|
let threw = false;
|
||||||
|
try { await readTuiTranscript({ transcriptPath: p, wallclockMs: 200, pollMs: 50 }); }
|
||||||
|
catch { threw = true; }
|
||||||
|
assert.ok(threw, "must throw on empty timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TUI session reaper ───────────────────────────────────────────────────
|
||||||
|
import { reapStaleTuiSessions, SESSION_PREFIX, buildTuiCmd } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
console.log("\nTUI session reaper:");
|
||||||
|
|
||||||
|
test("SESSION_PREFIX is ocp-tui-", () => {
|
||||||
|
assert.equal(SESSION_PREFIX, "ocp-tui-");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nTUI command construction (proxy-purity / #4):");
|
||||||
|
|
||||||
|
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (proxy purity, #4)", () => {
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "claude-haiku", "sid-1", "/home/u", "cli");
|
||||||
|
// OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
|
||||||
|
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection");
|
||||||
|
assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
||||||
|
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli");
|
||||||
|
assert.ok(cli.includes("DISABLE_AUTOUPDATER=1"), "version pin retained");
|
||||||
|
assert.ok(cli.includes("CLAUDE_CODE_ENTRYPOINT=cli"), "cli mode labels the subscription pool");
|
||||||
|
assert.ok(cli.includes("--strict-mcp-config") && cli.includes('mcp__*'), "MCP wall retained");
|
||||||
|
// 'auto' mode must NOT pin the entrypoint (claude self-classifies via TTY).
|
||||||
|
const auto = buildTuiCmd("/usr/bin/claude", "m", "sid-3", "/home/u", "auto");
|
||||||
|
assert.ok(!/CLAUDE_CODE_ENTRYPOINT=/.test(auto), "auto mode leaves entrypoint unset");
|
||||||
|
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
||||||
|
const killed = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assert.equal(n, 2);
|
||||||
|
assert.equal(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
|
||||||
|
assert.ok(!killed.includes("olp-tui-bbbb"), "olp-tui-bbbb must never be killed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper returns 0 when tmux status !== 0 (no server)", () => {
|
||||||
|
const fakeTmux = (_args) => ({ status: 1, stdout: "" });
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assert.equal(n, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper returns 0 for empty session list", () => {
|
||||||
|
const killed = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
||||||
|
assert.equal(n, 0);
|
||||||
|
assert.equal(killed.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
||||||
|
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
|
||||||
|
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
|
||||||
|
import { tmpdir as hTmp } from "node:os";
|
||||||
|
|
||||||
|
console.log("\nTUI home preparation:");
|
||||||
|
|
||||||
|
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
|
||||||
|
const realHome = hMkdtemp(`${hTmp()}/real-`);
|
||||||
|
hMkdir(`${realHome}/.claude`, { recursive: true });
|
||||||
|
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"x"}');
|
||||||
|
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
|
||||||
|
const tuiHome = hMkdtemp(`${hTmp()}/tui-`);
|
||||||
|
const cwd = `${tuiHome}/work`;
|
||||||
|
prepareTuiHome(realHome, tuiHome, cwd);
|
||||||
|
// credentials symlinked (token never copied)
|
||||||
|
assert.equal(hReadlink(`${tuiHome}/.claude/.credentials.json`), `${realHome}/.claude/.credentials.json`);
|
||||||
|
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
|
||||||
|
assert.equal(seed.hasCompletedOnboarding, true);
|
||||||
|
assert.equal(seed.theme, "dark"); // onboarded config carried over
|
||||||
|
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
|
||||||
|
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
|
||||||
|
assert.ok(hExists(`${tuiHome}/.claude/projects`)); // own projects dir
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {
|
||||||
|
const realHome = hMkdtemp(`${hTmp()}/real2-`);
|
||||||
|
hWrite(`${realHome}/.claude.json`, JSON.stringify({ projects: {} }));
|
||||||
|
const cwd = `${realHome}/work`;
|
||||||
|
prepareTuiHome(realHome, realHome, cwd);
|
||||||
|
assert.ok(!hExists(`${realHome}/.claude/.credentials.json`)); // no scratch symlink created
|
||||||
|
const j = JSON.parse(hRead(`${realHome}/.claude.json`, "utf8"));
|
||||||
|
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveTuiEntrypointEnv ───────────────────────────────────────────────
|
||||||
|
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
|
console.log("\nresolveTuiEntrypointEnv:");
|
||||||
|
|
||||||
|
test("mode 'cli' sets CLAUDE_CODE_ENTRYPOINT=cli", () => {
|
||||||
|
const env = {};
|
||||||
|
resolveTuiEntrypointEnv(env, "cli");
|
||||||
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mode 'cli' overwrites an inherited CLAUDE_CODE_ENTRYPOINT value", () => {
|
||||||
|
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||||
|
resolveTuiEntrypointEnv(env, "cli");
|
||||||
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mode 'auto' deletes CLAUDE_CODE_ENTRYPOINT (leaves unset)", () => {
|
||||||
|
const env = {};
|
||||||
|
resolveTuiEntrypointEnv(env, "auto");
|
||||||
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
||||||
|
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mode 'auto' deletes an inherited CLAUDE_CODE_ENTRYPOINT value", () => {
|
||||||
|
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||||
|
resolveTuiEntrypointEnv(env, "auto");
|
||||||
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
||||||
|
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mode 'off' leaves an inherited CLAUDE_CODE_ENTRYPOINT value untouched", () => {
|
||||||
|
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||||
|
resolveTuiEntrypointEnv(env, "off");
|
||||||
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "sdk-cli");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mode 'off' with no inherited value leaves env unchanged", () => {
|
||||||
|
const env = { OTHER: "x" };
|
||||||
|
resolveTuiEntrypointEnv(env, "off");
|
||||||
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
||||||
|
assert.equal(env.OTHER, "x");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default mode (no second arg) behaves like 'cli'", () => {
|
||||||
|
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
||||||
|
resolveTuiEntrypointEnv(env);
|
||||||
|
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TUI session driver: runTuiTurn (live-only, guarded) ──────────────────
|
||||||
|
console.log("\nTUI session driver:");
|
||||||
|
|
||||||
|
if (process.env.OCP_TUI_LIVE === "1") {
|
||||||
|
await asyncTest("runTuiTurn drives a real interactive turn and returns text", async () => {
|
||||||
|
const { runTuiTurn } = await import("./lib/tui/session.mjs");
|
||||||
|
const out = await runTuiTurn({
|
||||||
|
prompt: "Reply with exactly the word PONG and nothing else.",
|
||||||
|
model: "claude-haiku-4-5-20251001",
|
||||||
|
claudeBin: process.env.OCP_TUI_CLAUDE_BIN || "claude",
|
||||||
|
home: process.env.HOME,
|
||||||
|
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||||
|
wallclockMs: 120000,
|
||||||
|
});
|
||||||
|
assert.ok(/PONG/i.test(out.text), `expected PONG, got: ${out.text.slice(0, 200)}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => {
|
||||||
|
assert.ok(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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