Compare commits

...
Author SHA1 Message Date
8aac87aa61 fix(tui): pass --safe-mode so the host CLAUDE.md cannot leak into proxied turns (#187)
OCP is a proxy: the operator's private ~/.claude/CLAUDE.md and auto-memory
must never enter a proxied turn's context. The pane already sets
CLAUDE_CODE_DISABLE_CLAUDE_MDS + CLAUDE_CODE_DISABLE_AUTO_MEMORY, but on
newer claude (2.1.216) those env vars no longer suppress the injection, so
a proxied turn can obey the operator's private CLAUDE.md instead of the
caller's prompt — a leak of private context into API responses.

Add --safe-mode to the interactive spawn argv (gated off on the streaming
and OCP_TUI_FULL_TOOLS paths). Docs + tests updated.

Co-authored-by: sumlin <3495838+sumlin@users.noreply.github.com>
2026-07-23 13:33:56 +10:00
c3294b404a fix(tui): accept shift+tab to cycle as an input-ready marker (#188)
Newer claude 2.1.x renders the input bar with a `shift+tab to cycle`
footer (part of "⏵⏵ bypass permissions on (shift+tab to cycle)") instead
of the classic `? for shortcuts` hint. tuiInputReady() matched only the
old string, so on those builds every pane read as never-ready: cold boots
timed out (tui_pane_not_ready) and, with the warm pool on, every pre-boot
failed (bootFailures climbed, warm hits stayed at zero) with no error
surfaced to the operator.

Widen the matcher to accept either footer. Keep the test replica verbatim
and add a positive case for the new footer.

Co-authored-by: sumlin <3495838+sumlin@users.noreply.github.com>
2026-07-23 13:33:15 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
bdb6662c7c chore(release): v3.24.0 — OpenAI multimodal + structured outputs, SPOT prompt budget, agentic fix, OCP_LOCAL_TOOLS (#186)
* chore(release): v3.24.0 — OpenAI multimodal vision + structured outputs, SPOT prompt budget, agentic-turn fix, OCP_LOCAL_TOOLS

Consolidates #179/#153/#154/#183/#181/#182 (merged since the v3.23.0 tag).
3.23.0 → 3.24.0.

Minor: four user-facing features (multimodal vision #154, structured outputs
#153, SPOT-derived prompt budget #179, OCP_LOCAL_TOOLS #182) + two fixes
(#183 agentic final-answer, #181 deep-reply-500). No breaking change; no new
endpoint; no new cli.js wire behavior. Two features from @vvlasy-openclaw.

Release-kit walk: new env vars all documented in-PR (CLAUDE_IMAGE_ALLOW_URL /
CLAUDE_MAX_IMAGE_* #154, OCP_STRUCTURED_MAX_ATTEMPTS #153, OCP_LOCAL_TOOLS #182 —
grepped present in README env table); ADR 0009 (#179) + ADR 0006 (B.1, #153/#154)
indexed; version from package.json only (no stale refs); the CHANGELOG Unreleased
section (which held #182) is retitled to v3.24.0 with the other five entries added.

Tag push v3.24.0 at this squash commit triggers release.yml.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* docs(changelog): correct contributor count — four of six PRs are @vvlasy-openclaw's, not two (release reviewer)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-21 21:19:13 +10:00
5 changed files with 92 additions and 13 deletions
+12 -2
View File
@@ -1,10 +1,20 @@
# Changelog # Changelog
## Unreleased ## v3.24.0 — 2026-07-21
Minor release. Headline: two long-requested **OpenAI-compat features** land — **multimodal vision** (`image_url` parts) and **structured outputs** (`response_format` / JSON schema). Also: the prompt-char budget now derives from the model SPOT instead of a hand-set constant, an agentic-turn bug that dropped the model's final answer is fixed, and `OCP_LOCAL_TOOLS` supports the OpenClaw-backend use case. Four of the six landed from external contributors (@vvlasy-openclaw). Every code PR carried a fresh-context reviewer (Iron Rule 10); no new endpoint, no new `cli.js` wire behavior.
### Added ### Added
- **`OCP_LOCAL_TOOLS` — positive local-tools system-prompt wrapper (single-user, loopback only; default off).** The `-p` path prepends a wrapper telling the model it has no local filesystem/shell access — correct for a shared gateway, but it makes a personal instance's model (e.g. an OpenClaw agent on its own local OCP) refuse to use the server-side `claude` tools it legitimately has (which, on a loopback instance, are the operator's own machine). `=1` swaps in a positive wrapper. Changes **only the prompt**, never the tool surface (`--allowedTools`/`--disallowedTools` untouched; multi-tenant still disallows the FS surface); it does **not** enable client-side `tool_calls` (still unsupported by design). Fail-closed boot gate mirroring `OCP_TUI_FULL_TOOLS` (ADR 0007): refuses to start under `CLAUDE_AUTH_MODE=multi`, a non-loopback bind, or `PROXY_ANONYMOUS_KEY`. Inert (and logged as such) in TUI mode. Pure selection + gate logic in `lib/prompt.mjs` (unit-tested); the active wrapper is folded into the config epoch so toggling it invalidates the standard response cache. Verified end-to-end (boot the server + a fake `claude` capturing `--system-prompt`); default path byte-for-byte unchanged. No new `cli.js` wire behavior (reuses the already-cited `--system-prompt` flag). - **Multimodal vision — OpenAI `image_url` parts (#154, contributed by @vvlasy-openclaw).** `/v1/chat/completions` forwards OpenAI `image_url` content parts to `claude` as native Anthropic image blocks via `--input-format stream-json` (the CLI's own contract — no invented wire shape, verified live). Base64 `data:` URIs by default; remote `http(s)` URLs are off unless `CLAUDE_IMAGE_ALLOW_URL=1` (and even then OCP never fetches them — no SSRF surface). Byte/count caps (`CLAUDE_MAX_IMAGE_BYTES`, `CLAUDE_MAX_IMAGES`, `CLAUDE_MAX_IMAGE_TOTAL_BYTES`), all fail-closed on a misconfigured value. TUI mode returns `400 images_unsupported_in_tui_mode` (it can't carry image blocks) and an image present only in a `system` message returns `400` rather than being silently dropped. README § "Images / Multimodal".
- **Structured outputs — OpenAI `response_format` (#153, contributed by @vvlasy-openclaw).** `/v1/chat/completions` honors `response_format: { type: "json_schema" | "json_object" }` so OpenAI-SDK clients (Home Assistant AI Tasks, Honcho, scripts) get machine-parseable JSON in `content`. Validates against the schema (incl. `$ref`/`$defs` + `allOf`/`anyOf`/`oneOf` — the shapes the OpenAI SDK emits), retries with a stronger instruction up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3, fail-closed), and on exhaustion returns OpenAI's own `refusal` field (200/`content:null`) rather than an invented error. Cyclic-`$ref` schemas fail closed (no stack overflow); a pathologically deep model reply returns a refusal, not a 500 (#181). Single-flight dedup + structured-keyed cache bound the cost. Class B.1 (ADR 0006). README § "Structured Outputs".
- **SPOT-derived prompt-char budget (#179, ADR 0009).** `MAX_PROMPT_CHARS` default now derives from `max(models.json contextWindow) × 3 chars/token` (600,000 today) instead of the hand-set 150,000 (~37.5k tokens) that silently under-delivered the advertised window ~5×. `CLAUDE_MAX_PROMPT_CHARS` and the settings API remain absolute overrides; a garbage value fails closed to the derived default.
- **`OCP_LOCAL_TOOLS` — positive local-tools system-prompt wrapper (single-user, loopback only; default off) (#182, contributed by @vvlasy-openclaw).** The `-p` path prepends a wrapper telling the model it has no local filesystem/shell access — correct for a shared gateway, but it makes a personal instance's model (e.g. an OpenClaw agent on its own local OCP) refuse to use the server-side `claude` tools it legitimately has. `=1` swaps in a positive wrapper. Changes **only the prompt**, never the tool surface (`--allowedTools`/`--disallowedTools` untouched; multi-tenant still disallows the FS surface); it does **not** enable client-side `tool_calls` (still unsupported by design). Fail-closed boot gate mirroring `OCP_TUI_FULL_TOOLS` (ADR 0007): refuses to start under `CLAUDE_AUTH_MODE=multi`, a non-loopback bind, or `PROXY_ANONYMOUS_KEY`. Inert (and logged as such) in TUI mode. The active wrapper is folded into the config epoch so toggling it invalidates the standard response cache. No new `cli.js` wire behavior (reuses the already-cited `--system-prompt` flag).
### Fixed
- **Agentic turns dropped the model's final answer (#183, contributed by @vvlasy-openclaw).** On a tool-using turn, `/v1/chat/completions` returned only the opening preamble ("I'll find the repo…") and silently discarded the post-tool-use final answer: aggregate-`assistant` extraction was gated on `isFirstDelta` (which flips false after the first text), and OCP runs pure-aggregate mode (no `--include-partial-messages`), so each of an agentic turn's several assistant messages after the first was lost. Now guards on `sawTextDelta` and accumulates every assistant message (streaming and buffered paths assemble byte-identically).
- **Deep structured reply returned a 500 instead of a refusal (#181 / #184).** `validateJsonSchema` recurses on the model reply's nesting depth; a ~2000-level-deep reply overflowed the stack → caught `RangeError` → generic 500. A crash-safe façade converts that (only) into a validation miss → refusal; any other throw still surfaces.
## v3.23.0 — 2026-07-17 ## v3.23.0 — 2026-07-17
+1 -1
View File
@@ -65,7 +65,7 @@ Then restart OCP. At boot you will see (with the env token set, isolated home au
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format. - **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`plans/2026-07-13-tui-latency/streaming-spike.md`](plans/2026-07-13-tui-latency/streaming-spike.md). - **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`plans/2026-07-13-tui-latency/streaming-spike.md`](plans/2026-07-13-tui-latency/streaming-spike.md).
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely. - **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 ~2035K context floor of interactive mode); MCP is hard-disabled. - **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 runs `claude` with `--safe-mode`, which disables all host customizations (CLAUDE.md, skills, plugins, hooks, MCP servers) while leaving auth, model selection, built-in tools, and permissions untouched — so a `CLAUDE.md` on the OCP host can never leak into a proxied turn and steer the answer, and the session still bills the subscription pool (`cc_entrypoint=cli`, unlike `--bare`). The `CLAUDE_CODE_DISABLE_CLAUDE_MDS` / `CLAUDE_CODE_DISABLE_AUTO_MEMORY` env vars are kept as a fallback (see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~2035K context floor of interactive mode); MCP is hard-disabled. **Exception:** the two opt-in modes that deliberately need a customization `--safe-mode` would strip — real streaming (`OCP_TUI_STREAM`, which registers a `MessageDisplay` **hook** via `--settings`) and `OCP_TUI_FULL_TOOLS` (which grants an **MCP** / skills surface) — run without `--safe-mode` and rely on the env-var suppression alone.
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the env token set and `OCP_TUI_HOME` unset, OCP runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments. - **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the env token set and `OCP_TUI_HOME` unset, OCP runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment. - **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today. - **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
+41 -6
View File
@@ -182,8 +182,13 @@ function tuiCapturePane(tmux, tmuxName) {
} }
// True once claude's input bar is rendered and ready for keystrokes. // True once claude's input bar is rendered and ready for keystrokes.
// Two known ready-state footers across claude versions: the classic `? for shortcuts`
// hint, and the `shift+tab to cycle` hint (part of "⏵⏵ bypass permissions on (shift+tab
// to cycle)") that newer claude 2.1.x renders in its place. Match EITHER — a matcher that
// only knew the classic string silently reported "never ready" on those builds, timing out
// every boot (tui_pane_not_ready) and, with the warm pool on, failing every pre-boot.
function tuiInputReady(pane) { function tuiInputReady(pane) {
return /\? for shortcuts/.test(pane); return /\? for shortcuts|shift\+tab to cycle/.test(pane);
} }
// True once the pasted prompt has POSITIVELY landed in the input box. We only trust // True once the pasted prompt has POSITIVELY landed in the input box. We only trust
@@ -384,9 +389,15 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
// CLAUDE.md and auto-memory must NEVER leak into the agent OCP runs on the user's behalf. // 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 // 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 // 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 // obeyed by the proxied turn until these flags were set, after which it was not. Mirrors the
// by design (not gated): proxy purity is not an opt-in. Harmless on hosts with no CLAUDE.md // -p path's CLAUDE_NO_CONTEXT vars. Harmless on hosts with no CLAUDE.md (they suppress nothing).
// (the common case — they suppress nothing). Mirrors the -p path's CLAUDE_NO_CONTEXT vars. //
// These env vars stopped being sufficient on newer claude: they are present in the pane's
// environment yet the host CLAUDE.md is still injected into the turn's context, so a proxied
// turn again obeys the operator's private CLAUDE.md instead of the caller's prompt — a leak of
// the operator's private context into API responses. The robust suppression is the --safe-mode
// flag added to the argv below; these env vars are kept as belt-and-braces for the two argv
// paths that cannot take --safe-mode (see the safeModeArgs note near the return).
const sets = [ const sets = [
`HOME=${shq(ehome)}`, `HOME=${shq(ehome)}`,
"DISABLE_AUTOUPDATER=1", "DISABLE_AUTOUPDATER=1",
@@ -477,9 +488,32 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
// so the OFF argv is byte-for-byte the pre-streaming argv. // so the OFF argv is byte-for-byte the pre-streaming argv.
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : []; const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
// --safe-mode: disable ALL customizations (host CLAUDE.md, skills, plugins, hooks, MCP
// servers, custom commands/agents, output styles, ...) while leaving auth, model selection,
// built-in tools, and permissions untouched (claude 2.1.216 --help). This is the robust
// replacement for the CLAUDE_CODE_DISABLE_CLAUDE_MDS / _AUTO_MEMORY env vars above, which no
// longer suppress the host CLAUDE.md on newer claude — so the operator's private CLAUDE.md
// could otherwise leak into a proxied API response. Unlike --bare, --safe-mode does NOT drop
// the interactive session off the subscription pool (it keeps auth + model selection), so the
// billing classifier stays cc_entrypoint=cli.
//
// NOT applied when the pane deliberately relies on a customization --safe-mode would strip,
// because those would break SILENTLY:
// - streaming (settingsArgs present): the MessageDisplay hook is a HOOK, which --safe-mode
// disables — every streamed turn would then fire zero deltas (tui.streamZeroDeltaTurns)
// and fall back to buffered, defeating OCP_TUI_STREAM.
// - OCP_TUI_FULL_TOOLS=1: grants an MCP / skills / agent surface (--mcp-config, ...) that
// --safe-mode disables wholesale.
// Those two opt-in paths keep the env-var suppression above as their best-effort fallback.
const safeModeArgs =
settingsArgs.length === 0 && process.env.OCP_TUI_FULL_TOOLS !== "1"
? ["--safe-mode"]
: [];
return [ return [
envPrefix, envPrefix,
shq(claudeBin), shq(claudeBin),
...safeModeArgs,
"--model", shq(model), "--model", shq(model),
"--session-id", sessionId, "--session-id", sessionId,
...toolArgs, ...toolArgs,
@@ -615,8 +649,9 @@ export async function bootTuiPane({
// A pooled pane is SINGLE-USE: it already carries its own fresh --session-id, it // A pooled pane is SINGLE-USE: it already carries its own fresh --session-id, it
// serves this one turn, and it is killed in the finally like any other pane. // serves this one turn, and it is killed in the finally like any other pane.
// 2. On a MISS: pre-trust the scratch cwd, boot an interactive `claude` in a fresh tmux // 2. On a MISS: pre-trust the scratch cwd, boot an interactive `claude` in a fresh tmux
// session in the scratch cwd, poll capture-pane until the `? for shortcuts` input bar // session in the scratch cwd, poll capture-pane until the input bar appears — see
// appears (bootTuiPane). BOOT_MS is the max wait, not a fixed delay. // tuiInputReady for the ready-state footers matched (bootTuiPane). BOOT_MS is the max
// wait, not a fixed delay.
// 3. Write prompt to a 0600 temp file (no shell injection from prompt content). // 3. Write prompt to a 0600 temp file (no shell injection from prompt content).
// 4. Paste the prompt via tmux load-buffer + paste-buffer -p (bracketed paste) — // 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). // reliable for large multi-line prompts where send-keys -l is not (issue #130).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-claude-proxy", "name": "open-claude-proxy",
"version": "3.23.0", "version": "3.24.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": {
+37 -3
View File
@@ -2209,10 +2209,32 @@ console.log("\nTUI command construction (proxy-purity / #4):");
test("buildTuiCmd suppresses host CLAUDE.md + auto-memory (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"); 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. // OCP is a proxy: the host's CLAUDE.md / auto-memory must never leak into the proxied turn.
// Primary mechanism is --safe-mode (env vars alone stopped suppressing on newer claude);
// the env vars remain as belt-and-braces.
assert.ok(/(^| )--safe-mode( |$)/.test(cmd), "default pane must pass --safe-mode (disables host CLAUDE.md/skills/plugins/hooks)");
assert.ok(/(^| )CLAUDE_CODE_DISABLE_CLAUDE_MDS=1( |$)/.test(cmd), "must disable CLAUDE.md injection"); 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"); assert.ok(/(^| )CLAUDE_CODE_DISABLE_AUTO_MEMORY=1( |$)/.test(cmd), "must disable auto-memory injection");
}); });
test("buildTuiCmd omits --safe-mode when a customization it would strip is in use", () => {
const save = process.env.OCP_TUI_FULL_TOOLS;
try {
delete process.env.OCP_TUI_FULL_TOOLS;
// streaming registers a MessageDisplay HOOK via --settings; --safe-mode would kill the hook
// (zero deltas), so it must be omitted on the streaming pane.
const streaming = buildTuiCmd("/usr/bin/claude", "m", "sid-s", "/home/u", "cli", { file: "/d/sid-s.jsonl", settings: "/d/s.json" });
assert.ok(!/--safe-mode/.test(streaming), "streaming pane must NOT pass --safe-mode (would disable the MessageDisplay hook)");
assert.ok(streaming.includes("--settings '/d/s.json'"), "streaming pane keeps its --settings hook");
// OCP_TUI_FULL_TOOLS grants an MCP/skills surface --safe-mode disables wholesale.
process.env.OCP_TUI_FULL_TOOLS = "1";
const full = buildTuiCmd("/usr/bin/claude", "m", "sid-f", "/home/u", "cli");
assert.ok(!/--safe-mode/.test(full), "full-tools pane must NOT pass --safe-mode (would disable MCP/skills)");
} finally {
if (save === undefined) delete process.env.OCP_TUI_FULL_TOOLS; else process.env.OCP_TUI_FULL_TOOLS = save;
}
});
test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => { test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
const cli = buildTuiCmd("/usr/bin/claude", "m", "sid-2", "/home/u", "cli"); 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("DISABLE_AUTOUPDATER=1"), "version pin retained");
@@ -3504,7 +3526,7 @@ if (process.env.OCP_TUI_LIVE === "1") {
// Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs. // Replicates tuiInputReady, tuiPromptLanded verbatim from lib/tui/session.mjs.
// Keep in sync with the definitions there. // Keep in sync with the definitions there.
function _tuiInputReady(pane) { function _tuiInputReady(pane) {
return /\? for shortcuts/.test(pane); return /\? for shortcuts|shift\+tab to cycle/.test(pane);
} }
function _tuiPromptLanded(pane, prompt) { function _tuiPromptLanded(pane, prompt) {
const flatPane = pane.replace(/\s+/g, " "); const flatPane = pane.replace(/\s+/g, " ");
@@ -3522,7 +3544,12 @@ const TUI_READY_PANE = ` Try "how does <filepath> work?"
const TUI_LANDED_PANE = ` Reply with exactly: PONG_TEST const TUI_LANDED_PANE = ` Reply with exactly: PONG_TEST
? for shortcuts · ← for agents`; ? for shortcuts · ← for agents`;
// Welcome splash shown before input bar is rendered — no `? for shortcuts`. // Newer claude 2.1.x renders the input bar with a `shift+tab to cycle` footer instead of
// `? for shortcuts` — the matcher must accept it too, or the pane reads as never-ready.
const TUI_READY_PANE_SHIFT_TAB = ` Try "how does <filepath> work?"
⏵⏵ bypass permissions on (shift+tab to cycle)`;
// Welcome splash shown before input bar is rendered — neither ready-state footer.
const TUI_BOOT_PANE = `╭─ Claude Code v2.1.114 ─ Welcome back Tao! ─╮\n│ Tips for getting started │`; 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):"); console.log("\nTUI readiness + paste-verify predicates (issue #130):");
@@ -3533,6 +3560,9 @@ test("tuiInputReady(READY_PANE) === true (input bar rendered)", () => {
test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => { test("tuiInputReady(LANDED_PANE) === true (input bar still present after paste)", () => {
assert.equal(_tuiInputReady(TUI_LANDED_PANE), true); assert.equal(_tuiInputReady(TUI_LANDED_PANE), true);
}); });
test("tuiInputReady(READY_PANE_SHIFT_TAB) === true (newer claude `shift+tab to cycle` footer)", () => {
assert.equal(_tuiInputReady(TUI_READY_PANE_SHIFT_TAB), true);
});
test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => { test("tuiInputReady(BOOT_PANE) === false (welcome splash, no input bar yet)", () => {
assert.equal(_tuiInputReady(TUI_BOOT_PANE), false); assert.equal(_tuiInputReady(TUI_BOOT_PANE), false);
}); });
@@ -4523,13 +4553,17 @@ test("stream: sink path is keyed by session_id (concurrent panes cannot interlea
assert.ok(A.endsWith("/aaaa-1111.jsonl")); assert.ok(A.endsWith("/aaaa-1111.jsonl"));
}); });
test("stream: buildTuiCmd — OFF is byte-for-byte the pre-streaming argv; ON adds only env + --settings", () => { test("stream: buildTuiCmd — streaming ON adds env + --settings and drops --safe-mode (hook survives)", () => {
const off = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli"); const off = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli");
assert.ok(!off.includes("--settings"), "no --settings when streaming is off"); assert.ok(!off.includes("--settings"), "no --settings when streaming is off");
assert.ok(!off.includes("OCP_TUI_STREAM_FILE"), "no sink env when streaming is off"); assert.ok(!off.includes("OCP_TUI_STREAM_FILE"), "no sink env when streaming is off");
assert.ok(off.includes("--safe-mode"), "the non-streaming pane carries --safe-mode");
const on = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli", { file: "/d/SID.jsonl", settings: "/d/s.json" }); const on = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli", { file: "/d/SID.jsonl", settings: "/d/s.json" });
assert.ok(on.includes("OCP_TUI_STREAM_FILE='/d/SID.jsonl'"), "sink delivered via the pane env"); assert.ok(on.includes("OCP_TUI_STREAM_FILE='/d/SID.jsonl'"), "sink delivered via the pane env");
assert.ok(on.includes("--settings '/d/s.json'")); assert.ok(on.includes("--settings '/d/s.json'"));
// --safe-mode would disable the MessageDisplay hook registered by --settings, so the
// streaming pane must NOT carry it (it keeps the env-var suppression instead).
assert.ok(!on.includes("--safe-mode"), "streaming pane omits --safe-mode so the hook fires");
// must not regress the MCP wall or the pinned effort (#156) // must not regress the MCP wall or the pinned effort (#156)
assert.ok(on.includes("--strict-mcp-config") && on.includes("--disallowedTools 'mcp__*'"), "MCP wall intact"); assert.ok(on.includes("--strict-mcp-config") && on.includes("--disallowedTools 'mcp__*'"), "MCP wall intact");
assert.ok(on.includes("--effort low"), "OCP_TUI_EFFORT default intact"); assert.ok(on.includes("--effort low"), "OCP_TUI_EFFORT default intact");