Compare commits

...
Author SHA1 Message Date
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
a32bc9af17 fix(prompt): OCP_LOCAL_TOOLS wrapper no longer hard-codes a tool list (closes #185) (#191)
The positive local-tools wrapper (server.mjs) claimed the model has "full access
to the local filesystem, working directory, and shell through your available
tools (Bash, Read, Write, Edit, Glob, Grep, etc.)". That over-promises when an
operator narrows CLAUDE_ALLOWED_TOOLS (e.g. to Read,Grep): the model is told it
has Bash/Write/shell it doesn't hold, then attempts a non-granted tool and gets
denied. The default set matches the text, so the primary/OpenClaw path was fine —
but the enumeration was a prompt-honesty mismatch (flagged in the #182 fact-check).

Reworded to flip the POSTURE only ("you may use your available local tools … do
not assume access beyond the tool set provided to you in this session") without
enumerating specific tools or asserting shell/write capability. The model learns
its real tools from the tool definitions claude is given (--allowedTools), so the
enumeration was redundant as well as fragile — now honest under any allowed set.

No const reordering / no CONFIG_EPOCH change (still a constant; the epoch-fold
test confirms toggling still invalidates the cache with the new text). Test
markers updated (selectPromptWrapper unit + the boot-server integration assertion
that the positive wrapper reaches the -p spawn). Suite 449/0.

Rule 2: OCP-owned prompt composition, no wire change, no cli.js citation.

Closes #185

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-23 17:38:41 +10:00
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
4f9e2ff281 feat(server): OCP_LOCAL_TOOLS — positive local-tools system-prompt wrapper (single-user, default off) (#182)
Motivation (the OpenClaw case). OCP's `-p` path prepends OCP_SYSTEM_PROMPT_WRAPPER,
which tells the model it has NO local filesystem/shell/env access. Correct for a
shared/multi-tenant gateway. But an OpenClaw agent pointed at its own local OCP runs
the model SERVER-SIDE via `claude -p`, which already passes --allowedTools and has the
CLI's built-in tools — and on a loopback instance the OCP host IS the operator's
machine, so those are local tools. The wrapper gags them: the agent replies "I don't
have filesystem access" for tools it actually holds. OCP_LOCAL_TOOLS=1 swaps in a
positive wrapper for that case. (It does NOT enable client-side tool_calls for
OpenClaw/Cline — that remains unsupported by design; OCP is a text-prompt bridge.)

Safety: changes ONLY the system-prompt text, never the tool surface. Tools are governed
solely by --allowedTools/--disallowedTools; AUTH_MODE=multi still --disallowedTools the
whole FS/web/agent surface regardless of the wrapper. Fail-closed boot gate mirroring
OCP_TUI_FULL_TOOLS (ADR 0007): refuse to start when =1 is combined with
CLAUDE_AUTH_MODE=multi, a non-loopback bind, or PROXY_ANONYMOUS_KEY.

Scope/alignment: no new endpoint/header/field/wire operation. The wrapper text is
OCP-owned prompt composition (same class as OCP_SYSTEM_PROMPT_WRAPPER and
CLAUDE_SYSTEM_PROMPT), passed via the already-cited `claude --system-prompt` flag
(unchanged). ALIGNMENT.md Rule 2: nothing invented on the wire.

- lib/prompt.mjs: pure selectPromptWrapper() + localToolsSafetyError() (unit-tested).
- server.mjs: single hoisted flag LOCAL_TOOLS_ACTIVE = OCP_LOCAL_TOOLS && !TUI_MODE
  (the wrapper is only applied on the -p path; TUI composes its own prompt, so the flag
  is inert under TUI — announced with a warning rather than a misleading "ON"). Wrapper
  selection, boot gate, and the CONFIG_EPOCH fold all key off it, so toggling the flag +
  restarting invalidates the standard response cache (#177). Default path byte-for-byte
  unchanged.
- README env-var row + tool-model note; CHANGELOG Unreleased entry.

Tests (+14): unit-test both ternary branches and every gate condition; plus an
INTEGRATION harness that boots real server.mjs with a fake `claude` capturing the
--system-prompt — asserting the POSITIVE wrapper reaches a request under =1 and the
EXACT negative wrapper when unset, the boot gate refuses all three unsafe configs, the
safe config boots, TUI announces inert, and toggling the flag re-spawns (cache
invalidated). Mutation-verified: reverting the wiring / neutering the gate / reverting
the epoch fold each turns a test RED. 443 passed, 0 failed.

Noticed but scoped out (Iron Rule 11): the structured-output cache path
(handleChatCompletions, the response_format branch) does not fold CONFIG_EPOCH at all —
a pre-existing gap from #177/#153, independent of this flag. Happy to fix in a follow-up.

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:05:38 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
45c5717aea fix(structured): crash-safe validation façade — deep model reply → refusal not 500 (closes #181) (#184)
* fix(structured): crash-safe validation façade — deep model reply → refusal, not a 500 (closes #181)

#153's cyclic-$ref guard caps the REF-chain depth but not the DATA depth:
validateJsonSchema recurses on the value's nesting (properties/items/additionalProps),
so a model reply nested ~2000+ levels overflowed the stack with a RangeError, which
handleChatCompletions caught as a generic HTTP 500 instead of the spec-correct refusal.
(Found in the #153 final review, filed as #181; ≤1 spawn, no crash, no client-only
trigger — the value always comes from the model reply.)

New exported validateJsonSchemaSafe() wraps the validator: ANY throw (the deep-data
RangeError, or any future recursion hazard) becomes a single validation error, so the
structured-output retry loop treats a pathological reply as "did not validate" →
refusal. A well-formed reply is byte-identical (passes the inner errors through).
runStructuredCompletion calls the safe façade.

Chose the wrapper over threading a data-depth counter through six recursive call
sites: it protects every internal path at once (impossible to miss one) and stays
deterministically testable — a 6000-deep fixture reliably overflows on any platform.

Tests: +2, mutation-proven (revert the wrapper to a bare call → the deep test throws
RED). Suite 431/0. Rule 2: OCP-internal validation, no wire change, no cli.js citation.

Closes #181

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

* fix(structured): narrow validateJsonSchemaSafe to RangeError-only + drop unused import (review fold-in)

Reviewer of #184: the catch-all would silently mask a future genuine bug (e.g. a
TypeError from a malformed schema) as a validation miss. Narrowed to
`if (e instanceof RangeError) return [...]; throw e;` so only the #181 deep-nesting
overflow becomes a refusal; any other throw surfaces at error level as before. +1
test proving a non-RangeError (required:42 → TypeError) re-throws. Dropped the now-
unused raw `validateJsonSchema` import from server.mjs. Merged current main (incl.
#183) so CI runs the true post-merge tree. Suite 433/0.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-21 20:05:50 +10:00
9 changed files with 429 additions and 21 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog # Changelog
## 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
- **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
Minor release. Headline: **the default `sonnet` alias now resolves to Claude Sonnet 5** — a behavior change for every request that omits `model` (pin `claude-sonnet-4-6` by full ID to keep the previous default). Also: Windows-safe upgrade snapshots, two upgrade-system reliability fixes from a live fleet update, the `CLAUDE_SYSTEM_PROMPT` env var made functional, cache-key honesty for config changes, a billing-policy status correction (the 2026-06-15 `-p` split is PAUSED by Anthropic), and a major README restructure. No new endpoint; no new `cli.js` wire behavior. Every code PR carried a fresh-context reviewer (Iron Rule 10). Minor release. Headline: **the default `sonnet` alias now resolves to Claude Sonnet 5** — a behavior change for every request that omits `model` (pin `claude-sonnet-4-6` by full ID to keep the previous default). Also: Windows-safe upgrade snapshots, two upgrade-system reliability fixes from a live fleet update, the `CLAUDE_SYSTEM_PROMPT` env var made functional, cache-key honesty for config changes, a billing-policy status correction (the 2026-06-15 `-p` split is PAUSED by Anthropic), and a major README restructure. No new endpoint; no new `cli.js` wire behavior. Every code PR carried a fresh-context reviewer (Iron Rule 10).
+2 -1
View File
@@ -159,7 +159,7 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --
OCP is a **text-prompt bridge** to the official `claude` CLI. It does **not** pass through OpenAI `tools`/`functions` payloads or Anthropic `tool_use` blocks to the client. Clients (Cline, Cursor, OpenClaw, etc.) pointed at OCP receive **assistant TEXT only** — they never get `tool_calls` to execute locally. OCP is a **text-prompt bridge** to the official `claude` CLI. It does **not** pass through OpenAI `tools`/`functions` payloads or Anthropic `tool_use` blocks to the client. Clients (Cline, Cursor, OpenClaw, etc.) pointed at OCP receive **assistant TEXT only** — they never get `tool_calls` to execute locally.
Any tool use happens server-side, under the `--allowedTools` set configured on the OCP host. In default mode (no `CLAUDE_NO_CONTEXT`), the `claude` CLI's own built-in tools are available to the model; in TUI mode, the operator controls the tool surface via `OCP_TUI_FULL_TOOLS`. Either way, the tools run under the operator's credentials on the server, and the client sees only the final text output. Any tool use happens server-side, under the `--allowedTools` set configured on the OCP host. In default mode (no `CLAUDE_NO_CONTEXT`), the `claude` CLI's own built-in tools are available to the model; in TUI mode, the operator controls the tool surface via `OCP_TUI_FULL_TOOLS`. Either way, the tools run under the operator's credentials on the server, and the client sees only the final text output. Note that on the `-p` path OCP prepends a system-prompt wrapper telling the model it has **no** local access (right for a shared gateway) — a single-user loopback instance whose model *should* use its tools can flip this with `OCP_LOCAL_TOOLS=1` (see Environment Variables).
**Client-local tool execution is not supported by design.** Supporting it would require bypassing the `claude` CLI to call the raw Anthropic API directly — that is a different product, and is out of scope per `ALIGNMENT.md` (every OCP endpoint must correspond to something `cli.js` actually does). **Client-local tool execution is not supported by design.** Supporting it would require bypassing the `claude` CLI to call the raw Anthropic API directly — that is a different product, and is out of scope per `ALIGNMENT.md` (every OCP endpoint must correspond to something `cli.js` actually does).
@@ -235,6 +235,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
| `CLAUDE_MAX_IMAGES` | `20` | Max image parts per request. Over-cap gets `HTTP 413`. | | `CLAUDE_MAX_IMAGES` | `20` | Max image parts per request. Over-cap gets `HTTP 413`. |
| `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | `20971520` | Aggregate decoded-byte cap across all images in a request (default 20 MB). Over-cap gets `HTTP 413`. | | `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | `20971520` | Aggregate decoded-byte cap across all images in a request (default 20 MB). Over-cap gets `HTTP 413`. |
| `CLAUDE_SYSTEM_PROMPT` | *(unset)* | Operator-wide system-prompt text appended (last) to every request's composed system prompt on the default `-p` path. TUI-mode panes are unaffected (they keep the interactive CLI's own system prompt). Echoed truncated on `/health.systemPrompt`. Note: changing this value and restarting auto-invalidates the response cache (the key carries a boot-config epoch, #177). | | `CLAUDE_SYSTEM_PROMPT` | *(unset)* | Operator-wide system-prompt text appended (last) to every request's composed system prompt on the default `-p` path. TUI-mode panes are unaffected (they keep the interactive CLI's own system prompt). Echoed truncated on `/health.systemPrompt`. Note: changing this value and restarting auto-invalidates the response cache (the key carries a boot-config epoch, #177). |
| `OCP_LOCAL_TOOLS` | *(unset)* | **Single-user, loopback only.** `=1` swaps the default *"you have no local filesystem/shell access"* system-prompt wrapper for a positive one telling the model it **may** use its tools. These are the **server-side `claude` tools** OCP spawns via `-p` (`--allowedTools`) — which, on a loopback instance, run on the operator's own machine, i.e. *local* tools. For a personal instance (e.g. an **OpenClaw** agent on its own local OCP) the default wrapper otherwise makes the model refuse to use tools it legitimately has. Changes **only the prompt**, never the tool surface (governed by `--allowedTools`/`--disallowedTools`; multi-tenant still `--disallowedTools` the whole FS surface). **Does not** enable client-side `tool_calls` for OpenClaw/Cline/etc. — that remains unsupported by design (see § How tools work). Fail-closed: OCP **refuses to boot** if `=1` is combined with `CLAUDE_AUTH_MODE=multi`, a non-loopback bind, or `PROXY_ANONYMOUS_KEY` (mirrors `OCP_TUI_FULL_TOOLS`, ADR 0007). **Inert in TUI mode** (the `-p` wrapper is unused there; the TUI tool surface is `OCP_TUI_FULL_TOOLS`) — a warning is logged. Off by default → the default path is byte-for-byte unchanged. Toggling it auto-invalidates the standard response cache (boot-config epoch, #177). |
| `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 (multi mode) — 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`. Full setup + security notes: [docs/lan-mode.md § Anonymous Access](docs/lan-mode.md#anonymous-access-optional). | | `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key (multi mode) — 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`. Full setup + security notes: [docs/lan-mode.md § Anonymous Access](docs/lan-mode.md#anonymous-access-optional). |
+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.
+35
View File
@@ -52,3 +52,38 @@ export function derivePromptCharBudget(models, { charsPerToken = 3, floor = 1500
export function resolvePromptCharBudget(rawEnv, models, opts) { export function resolvePromptCharBudget(rawEnv, models, opts) {
return rawEnv ? parseInt(rawEnv, 10) : derivePromptCharBudget(models, opts); return rawEnv ? parseInt(rawEnv, 10) : derivePromptCharBudget(models, opts);
} }
// OCP_LOCAL_TOOLS system-prompt wrapper selection (pure).
//
// OCP's `-p` path prepends a fixed wrapper to every request's system prompt. The DEFAULT wrapper
// tells the model it has NO local filesystem/shell/env access — the right posture for a shared or
// multi-tenant gateway. But a single-user, loopback-bound instance (e.g. an OpenClaw agent talking
// to its own local OCP) DOES legitimately have tools — the `-p` path already passes `--allowedTools`
// and the CLI's built-in tools are available — so the default wrapper actively gaslights the model
// into refusing to use tools it holds. `OCP_LOCAL_TOOLS=1` swaps in a positive wrapper for that case.
//
// This does NOT expand the tool surface: tools are governed solely by `--allowedTools` /
// `--disallowedTools` (multi-tenant mode `--disallowedTools` the whole FS surface regardless of the
// wrapper). It only changes the PROMPT the operator's own model reads. Pure so it is unit-testable.
export function selectPromptWrapper(localToolsEnabled, negativeWrapper, positiveWrapper) {
return localToolsEnabled ? positiveWrapper : negativeWrapper;
}
// Boot-time safety gate for OCP_LOCAL_TOOLS, mirroring the OCP_TUI_FULL_TOOLS model (ADR 0007): a
// positive "you may use local tools" wrapper must never reach an untrusted caller. Returns a fatal
// message string when the flag is enabled in an unsafe deployment, or null when it is safe/disabled.
// Fail-closed: any of multi-tenant auth, a non-loopback bind, or an anonymous key is refused. Pure —
// the caller does the process.exit so this stays testable.
export function localToolsSafetyError({ enabled, authMode, loopbackBind, anonymousKey }) {
if (!enabled) return null;
if (authMode === "multi") {
return "OCP_LOCAL_TOOLS=1 is incompatible with CLAUDE_AUTH_MODE=multi — a guest/anonymous prompt would be told it may drive the operator's filesystem/shell. Single-user only.";
}
if (!loopbackBind) {
return "OCP_LOCAL_TOOLS=1 requires a loopback bind (127.0.0.1/::1) — a network-exposed positive-tools wrapper could reach an untrusted peer. Bind to loopback, or leave OCP_LOCAL_TOOLS off.";
}
if (anonymousKey) {
return "OCP_LOCAL_TOOLS=1 is unsafe with PROXY_ANONYMOUS_KEY set — anonymous callers could reach the local-tools-enabled model without a named key. Remove PROXY_ANONYMOUS_KEY, or leave OCP_LOCAL_TOOLS off.";
}
return null;
}
+21
View File
@@ -209,6 +209,27 @@ export function validateJsonSchema(value, schema, path = "$", strict = false, ro
return errors; return errors;
} }
// Crash-safe façade over validateJsonSchema (issue #181). The validator recurses on the DATA's
// nesting depth (properties/items/additionalProperties), which the REF_DEPTH_CAP does NOT bound —
// only the ref-chain is. A model reply nested ~2000 levels deep therefore overflowed the stack with
// a RangeError, which the request handler caught as a generic HTTP 500 instead of the spec-correct
// `refusal`. This wrapper converts ANY throw (the deep-data RangeError, or any future recursion
// hazard) into a single validation error, so the structured-output retry loop treats a pathological
// reply as "did not validate" → refusal — never a 500, never a crash. A well-formed reply is
// unaffected: the inner validator returns and this just passes its errors through.
export function validateJsonSchemaSafe(value, schema, path = "$", strict = false, root = schema) {
try {
return validateJsonSchema(value, schema, path, strict, root);
} catch (e) {
// Catch ONLY the deep-nesting stack overflow (the #181 vector) and turn it into a validation
// miss → retry → refusal, never a 500. Any OTHER throw is a genuine bug: re-throw it so it
// surfaces at error level instead of being silently masked as "did not validate" (reviewer
// finding — a catch-all would hide a future TypeError behind a warn-level structured_retry).
if (e instanceof RangeError) return [`${path}: schema validation aborted (value nesting too deep)`];
throw e;
}
}
function tryJsonParse(s) { function tryJsonParse(s) {
try { return { ok: true, value: JSON.parse(s) }; } catch { return { ok: false }; } try { return { ok: true, value: JSON.parse(s) }; } catch { return { ok: false }; }
} }
+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": {
+47 -7
View File
@@ -42,7 +42,7 @@ import { dirname, join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs"; import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { DEFAULT_PORT } from "./lib/constants.mjs"; import { DEFAULT_PORT } from "./lib/constants.mjs";
import { StructuredOutputError, detectStructuredOutput, validateJsonSchema, extractJsonPayload, structuredSystemInstruction, resolveMaxAttempts } from "./lib/structured-output.mjs"; import { StructuredOutputError, detectStructuredOutput, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, resolveMaxAttempts } from "./lib/structured-output.mjs";
import { isLoopbackBind } from "./lib/net.mjs"; import { isLoopbackBind } from "./lib/net.mjs";
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs"; import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs";
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs"; import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
@@ -52,7 +52,7 @@ import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs"; import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
import { hasImageContent, buildImageBlocks, buildStreamJsonInput, MultimodalError } from "./lib/multimodal.mjs"; import { hasImageContent, buildImageBlocks, buildStreamJsonInput, MultimodalError } from "./lib/multimodal.mjs";
import { parsePositiveInt } from "./lib/env.mjs"; import { parsePositiveInt } from "./lib/env.mjs";
import { appendOperatorPrompt, derivePromptCharBudget } from "./lib/prompt.mjs"; import { appendOperatorPrompt, derivePromptCharBudget, selectPromptWrapper, localToolsSafetyError } from "./lib/prompt.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -198,7 +198,27 @@ function resolveClaude() {
// Reference: https://github.com/dtzp555-max/olp commit 97e7d16 (Phase 6c) // Reference: https://github.com/dtzp555-max/olp commit 97e7d16 (Phase 6c)
const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`; const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`;
// Build the full system-prompt string: OCP_SYSTEM_PROMPT_WRAPPER prepended, // Positive counterpart used only when OCP_LOCAL_TOOLS=1 — a single-user, loopback-bound instance
// where the operator's own model legitimately has tools (the `-p` path passes --allowedTools). Tells
// the model it MAY use them instead of disclaiming access it actually holds. Off by default; the
// default wrapper above is byte-for-byte unchanged. Selecting the positive wrapper does NOT expand
// the tool surface (governed independently by --allowedTools/--disallowedTools) — it only changes the
// prompt — and is boot-gated below (multi/non-loopback/anon → refuse) mirroring OCP_TUI_FULL_TOOLS.
const OCP_LOCAL_TOOLS_WRAPPER = `You are accessed via the OCP HTTP proxy running on the operator's own machine. Unlike the shared-gateway posture, you may use your available local tools to act on the operator's machine as the task requires. Use only the tools you actually have — do not assume filesystem, shell, or other access beyond the tool set provided to you in this session.`;
// OCP_LOCAL_TOOLS is inert in TUI mode: the interactive (non-`-p`) path composes its own prompt via
// callClaudeTui/messagesToPrompt and never calls extractSystemPrompt, so the wrapper is only ever
// applied on the `-p` path. LOCAL_TOOLS_ACTIVE is the single source of truth (hoisted once, house
// style) used by the wrapper selection, the boot gate, and the startup notice — so the flag is
// enabled/announced/gated in exactly the mode where it has an effect. (TUI tool surface is governed
// by OCP_TUI_FULL_TOOLS instead.)
const LOCAL_TOOLS = process.env.OCP_LOCAL_TOOLS === "1";
const LOCAL_TOOLS_ACTIVE = LOCAL_TOOLS && process.env.CLAUDE_TUI_MODE !== "true";
// The wrapper actually prepended to each request's system prompt, chosen once at startup.
const SYSTEM_PROMPT_WRAPPER = selectPromptWrapper(LOCAL_TOOLS_ACTIVE, OCP_SYSTEM_PROMPT_WRAPPER, OCP_LOCAL_TOOLS_WRAPPER);
// Build the full system-prompt string: SYSTEM_PROMPT_WRAPPER prepended,
// then any system-role messages from the request appended (separated by blank line), // then any system-role messages from the request appended (separated by blank line),
// then the operator-wide CLAUDE_SYSTEM_PROMPT appended LAST (lib/prompt.mjs — a // then the operator-wide CLAUDE_SYSTEM_PROMPT appended LAST (lib/prompt.mjs — a
// no-op returning the same string when the var is unset, so the default path is // no-op returning the same string when the var is unset, so the default path is
@@ -206,12 +226,12 @@ const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You
function extractSystemPrompt(messages) { function extractSystemPrompt(messages) {
const systemMessages = (messages ?? []).filter(m => m.role === "system"); const systemMessages = (messages ?? []).filter(m => m.role === "system");
if (systemMessages.length === 0) { if (systemMessages.length === 0) {
return appendOperatorPrompt(OCP_SYSTEM_PROMPT_WRAPPER, SYSTEM_PROMPT); return appendOperatorPrompt(SYSTEM_PROMPT_WRAPPER, SYSTEM_PROMPT);
} }
const clientContent = systemMessages.map(m => const clientContent = systemMessages.map(m =>
contentToText(m.content) contentToText(m.content)
).join("\n\n"); ).join("\n\n");
return appendOperatorPrompt(`${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`, SYSTEM_PROMPT); return appendOperatorPrompt(`${SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`, SYSTEM_PROMPT);
} }
// ── NDJSON line buffer parser (Phase 6c port) ───────────────────────────── // ── NDJSON line buffer parser (Phase 6c port) ─────────────────────────────
@@ -374,7 +394,7 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
// API) are excluded because a const epoch cannot track them; truncation also only drops // API) are excluded because a const epoch cannot track them; truncation also only drops
// context rather than changing the instruction set. // context rather than changing the instruction set.
const CONFIG_EPOCH = cryptoCreateHash("sha256") const CONFIG_EPOCH = cryptoCreateHash("sha256")
.update(JSON.stringify([SYSTEM_PROMPT, OCP_SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT])) .update(JSON.stringify([SYSTEM_PROMPT, SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT]))
.digest("hex").slice(0, 16); .digest("hex").slice(0, 16);
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome / // Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's // spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
@@ -812,6 +832,21 @@ if (TUI_MODE && PROXY_ANONYMOUS_KEY) {
process.exit(1); process.exit(1);
} }
// OCP_LOCAL_TOOLS safety gate (mirrors the OCP_TUI_FULL_TOOLS model, ADR 0007): the positive
// "you may use local tools" system-prompt wrapper is single-user only, so refuse to boot if it
// could reach an untrusted caller. Fail-closed on multi-tenant auth, a non-loopback bind, or an
// anonymous key. The pure predicate lives in lib/prompt.mjs (unit-tested); the exit stays here.
const _localToolsBootError = localToolsSafetyError({
enabled: LOCAL_TOOLS_ACTIVE,
authMode: AUTH_MODE,
loopbackBind: isLoopbackBind(BIND_ADDRESS),
anonymousKey: !!PROXY_ANONYMOUS_KEY,
});
if (_localToolsBootError) {
console.error(`FATAL: ${_localToolsBootError}\n See README § "Environment Variables" (OCP_LOCAL_TOOLS) and docs/adr/0007-tui-interactive-mode.md. Refusing to start.`);
process.exit(1);
}
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") { if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored"); console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
} }
@@ -2732,7 +2767,10 @@ async function runStructuredCompletion(upstreamCall, model, messages, conversati
continue; continue;
} }
if (structured.mode === "schema" && structured.schema) { if (structured.mode === "schema" && structured.schema) {
const errs = validateJsonSchema(extracted.value, structured.schema, "$", structured.strict); // validateJsonSchemaSafe (#181): a pathologically deep model reply overflows the value-depth
// recursion; the safe façade turns that into a validation miss (→ retry → refusal) instead of
// a caught RangeError surfacing as a generic 500.
const errs = validateJsonSchemaSafe(extracted.value, structured.schema, "$", structured.strict);
if (errs.length) { if (errs.length) {
lastErr = "schema validation failed: " + errs.slice(0, 5).join("; "); lastErr = "schema validation failed: " + errs.slice(0, 5).join("; ");
logEvent("warn", "structured_retry", { attempt, reason: "schema", errors: errs.slice(0, 5) }); logEvent("warn", "structured_retry", { attempt, reason: "schema", errors: errs.slice(0, 5) });
@@ -3575,6 +3613,8 @@ server.listen(PORT, BIND_ADDRESS, () => {
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`); console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`); console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`);
console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`); console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`);
if (LOCAL_TOOLS_ACTIVE) console.log(`Local tools: ON (OCP_LOCAL_TOOLS=1) — model told it may use local tools; single-user/loopback only`);
else if (LOCAL_TOOLS) console.warn(`⚠ OCP_LOCAL_TOOLS=1 is ignored in TUI mode (the -p system-prompt wrapper is not used). The TUI tool surface is governed by OCP_TUI_FULL_TOOLS.`);
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`); if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`); if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`); else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
+265 -5
View File
@@ -844,7 +844,7 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
// contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt // contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt
// return `base` unconditionally and the first test fails; make it stop trimming // return `base` unconditionally and the first test fails; make it stop trimming
// and the whitespace test fails. // and the whitespace test fails.
import { appendOperatorPrompt, derivePromptCharBudget, resolvePromptCharBudget } from "./lib/prompt.mjs"; import { appendOperatorPrompt, derivePromptCharBudget, resolvePromptCharBudget, selectPromptWrapper, localToolsSafetyError } from "./lib/prompt.mjs";
console.log("\nPrompt-char budget (ADR 0009 — SPOT-derived):"); console.log("\nPrompt-char budget (ADR 0009 — SPOT-derived):");
@@ -906,6 +906,204 @@ test("appendOperatorPrompt: operator value is trimmed before appending", () => {
assert.equal(appendOperatorPrompt("W", " hi "), "W\n\nhi"); assert.equal(appendOperatorPrompt("W", " hi "), "W\n\nhi");
}); });
// ── OCP_LOCAL_TOOLS wrapper selection + safety gate (lib/prompt.mjs) ──────────
console.log("\nOCP_LOCAL_TOOLS wrapper + safety gate:");
const NEG = "You do NOT have access to any local filesystem";
const POS = "you may use your available local tools";
test("selectPromptWrapper: default (disabled) returns the negative wrapper BYTE-IDENTICAL", () => {
// Mutation-proof: flip the ternary and the default path leaks the positive wrapper.
assert.equal(selectPromptWrapper(false, NEG, POS), NEG);
});
test("selectPromptWrapper: enabled returns the positive (local-tools) wrapper", () => {
assert.equal(selectPromptWrapper(true, NEG, POS), POS);
});
test("localToolsSafetyError: disabled → null regardless of an otherwise-unsafe deploy", () => {
// The gate must not fire when the flag is off — the default path is never blocked.
assert.equal(localToolsSafetyError({ enabled: false, authMode: "multi", loopbackBind: false, anonymousKey: true }), null);
});
test("localToolsSafetyError: enabled on a safe single-user loopback instance → null (boots)", () => {
assert.equal(localToolsSafetyError({ enabled: true, authMode: "none", loopbackBind: true, anonymousKey: false }), null);
assert.equal(localToolsSafetyError({ enabled: true, authMode: "shared", loopbackBind: true, anonymousKey: false }), null);
});
test("localToolsSafetyError: enabled + AUTH_MODE=multi → fatal (guest could be told it has FS)", () => {
const e = localToolsSafetyError({ enabled: true, authMode: "multi", loopbackBind: true, anonymousKey: false });
assert.ok(e && /multi/.test(e), `expected a multi-tenant fatal, got: ${e}`);
});
test("localToolsSafetyError: enabled + non-loopback bind → fatal (network-exposed)", () => {
const e = localToolsSafetyError({ enabled: true, authMode: "none", loopbackBind: false, anonymousKey: false });
assert.ok(e && /loopback/.test(e), `expected a loopback fatal, got: ${e}`);
});
test("localToolsSafetyError: enabled + anonymous key → fatal (unnamed callers)", () => {
const e = localToolsSafetyError({ enabled: true, authMode: "none", loopbackBind: true, anonymousKey: true });
assert.ok(e && /ANONYMOUS/i.test(e), `expected an anonymous-key fatal, got: ${e}`);
});
test("localToolsSafetyError: multi is checked before loopback/anon (most severe first)", () => {
// A deploy that trips several conditions reports the multi-tenant one — the strongest signal.
const e = localToolsSafetyError({ enabled: true, authMode: "multi", loopbackBind: false, anonymousKey: true });
assert.ok(/multi/.test(e));
});
// ── OCP_LOCAL_TOOLS INTEGRATION: boot real server.mjs, observe the -p spawn ──────────
// The unit tests above prove the pure helpers. These close the INTEGRATION SEAM the suite
// otherwise can't reach (server.mjs boots a listener on import): a fake `claude` captures the
// exact --system-prompt OCP spawns it with, so we assert the SELECTED wrapper actually reaches
// a request — and boot-gate refusals are asserted by the process exit code. Without these, the
// wiring (extractSystemPrompt using SYSTEM_PROMPT_WRAPPER, the boot gate, the epoch fold) can be
// silently reverted with the unit suite still green — the maintainer's #1 rejection pattern.
import { spawn as _ltSpawn } from "node:child_process";
import { writeFileSync as _ltWrite, chmodSync as _ltChmod, readFileSync as _ltRead, existsSync as _ltExists, rmSync as _ltRm, mkdtempSync as _ltMkdtemp } from "node:fs";
import { tmpdir as _ltTmp } from "node:os";
import { fileURLToPath as _ltF2P } from "node:url";
const LT_SERVER = _ltF2P(new URL("./server.mjs", import.meta.url));
const LT_POSIX = process.platform !== "win32"; // fake is a /bin/sh script; CI is POSIX
const LT_NEG_MARK = "You do NOT have access to any local filesystem";
const LT_POS_MARK = "you may use your available local tools";
// Fake claude: record the --system-prompt it was spawned with, bump an optional spawn counter,
// then emit a minimal valid stream-json response so the request completes (and caches).
const LT_FAKE = `#!/bin/sh
prev=""
for a in "$@"; do
if [ "$prev" = "--system-prompt" ]; then printf '%s' "$a" > "$SP_CAPTURE"; fi
prev="$a"
done
if [ -n "$SP_COUNTER" ]; then c=$(cat "$SP_COUNTER" 2>/dev/null || echo 0); echo $((c+1)) > "$SP_COUNTER"; fi
printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"OK"}]}}'
printf '%s\\n' '{"type":"result"}'
exit 0
`;
function ltMkdir() { return _ltMkdtemp(join(_ltTmp(), "ocp-lt-")); }
function ltFake(dir) { const p = join(dir, "claude"); _ltWrite(p, LT_FAKE); _ltChmod(p, 0o755); return p; }
function ltBoot(env, dir) {
const child = _ltSpawn(process.execPath, [LT_SERVER], {
env: { ...process.env, NODE_ENV: "test", OCP_DIR_OVERRIDE: dir, OCP_SKIP_AUTH_TEST: "1",
CLAUDE_BIND: "127.0.0.1", CLAUDE_AUTH_MODE: "none", CLAUDE_CACHE_TTL: "0", CLAUDE_TIMEOUT: "4000", ...env },
stdio: ["ignore", "pipe", "pipe"],
});
const buf = { out: "", err: "", exit: undefined };
child.stdout.on("data", d => { buf.out += d; });
child.stderr.on("data", d => { buf.err += d; });
child.on("exit", code => { buf.exit = code; });
return { child, buf };
}
async function ltWait(cond, ms = 9000) {
const start = Date.now();
while (Date.now() - start < ms) { if (cond()) return true; await new Promise(r => setTimeout(r, 40)); }
return false;
}
async function ltPost(port, body) {
try {
await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
} catch { /* the fake may close the socket; the spawn (and capture) already happened */ }
}
console.log("\nOCP_LOCAL_TOOLS integration (boot server.mjs):");
test("integration: OCP_LOCAL_TOOLS=1 → the -p spawn receives the POSITIVE wrapper (kills the no-op mutation)", async () => {
if (!LT_POSIX) return; // sh fake — skip on Windows CI
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39321", SP_CAPTURE: cap }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
await ltPost(39321, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude was spawned and captured --system-prompt");
const sp = _ltRead(cap, "utf8");
assert.ok(sp.includes(LT_POS_MARK), `expected POSITIVE wrapper in --system-prompt, got: ${sp.slice(0,90)}`);
assert.ok(!sp.includes(LT_NEG_MARK), "positive wrapper must REPLACE the negative one, not append");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: flag OFF → the -p spawn receives the EXACT negative wrapper (default path byte-for-byte)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const cap = join(dir, "sp.txt"); const fake = ltFake(dir);
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39322", SP_CAPTURE: cap }, dir); // OCP_LOCAL_TOOLS unset
try {
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `server did not start: ${buf.err.slice(0,200)}`);
await ltPost(39322, { model: "sonnet", messages: [{ role: "user", content: "hi" }] });
assert.ok(await ltWait(() => _ltExists(cap)), "fake claude captured --system-prompt");
const sp = _ltRead(cap, "utf8");
// No system messages + no CLAUDE_SYSTEM_PROMPT → the wrapper is passed verbatim.
assert.equal(sp, `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`);
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: boot gate REFUSES each unsafe config (multi / non-loopback / anon key)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir);
const cases = [
{ label: "multi", env: { CLAUDE_AUTH_MODE: "multi" } },
{ label: "non-loopback", env: { CLAUDE_BIND: "0.0.0.0" } },
{ label: "anon", env: { PROXY_ANONYMOUS_KEY: "pub" } },
];
try {
for (const [i, c] of cases.entries()) {
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(39330 + i), ...c.env }, dir);
try {
assert.ok(await ltWait(() => buf.exit != null), `[${c.label}] expected the process to exit`);
assert.notEqual(buf.exit, 0, `[${c.label}] must exit non-zero`);
assert.ok(/FATAL[\s\S]*OCP_LOCAL_TOOLS/.test(buf.err), `[${c.label}] expected a local-tools FATAL, got: ${buf.err.slice(0,160)}`);
} finally { child.kill("SIGKILL"); }
}
} finally { _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: safe single-user config BOOTS past the gate and announces local tools", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir);
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39340" }, dir); // loopback + none
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `safe config must boot, got: ${buf.err.slice(0,200)}`);
assert.ok(buf.out.includes("Local tools: ON"), "startup must announce local tools when active");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: TUI mode → flag is announced INERT (not 'ON'), boot not refused", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir);
// Non-loopback would normally trip the local-tools gate; under TUI the flag is inert so the
// gate must NOT fire on its behalf. Use loopback here to isolate TUI's own guards from ours.
const { child, buf } = ltBoot({ OCP_LOCAL_TOOLS: "1", CLAUDE_TUI_MODE: "true", CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39341" }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on") || buf.exit != null), `did not start: ${buf.err.slice(0,200)}`);
assert.ok(!buf.out.includes("Local tools: ON"), "must NOT claim local tools are ON in TUI mode (the wrapper is unused there)");
assert.ok(/ignored in TUI mode/.test(buf.out + buf.err), "must warn that OCP_LOCAL_TOOLS is inert under TUI");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response cache (epoch fold)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
const req = { model: "sonnet", messages: [{ role: "user", content: "epoch-probe" }] };
const bootOnce = async (env, port) => {
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: String(port), CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter, ...env }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0,160)}`);
_ltWrite(counter, "0"); // reset AFTER boot so boot-time spawns (if any) don't count
await ltPost(port, req);
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000); // give the spawn a beat
return Number(_ltRead(counter, "utf8")) || 0;
} finally { child.kill("SIGKILL"); }
};
try {
const off = await bootOnce({}, 39350); // caches "OK" under epoch(negative)
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39351); // same DB, epoch(positive) → must MISS → re-spawn
assert.equal(off, 1, "first request (cache empty) must spawn claude");
assert.equal(on, 1, "after toggling the flag the identical request must NOT be served from the old cache (epoch differs → re-spawn)");
} finally { _ltRm(dir, { recursive: true, force: true }); }
});
// ── Upgrade Tests ── // ── Upgrade Tests ──
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs"; import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";
@@ -2011,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");
@@ -3306,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, " ");
@@ -3324,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):");
@@ -3335,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);
}); });
@@ -4325,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");
@@ -4356,7 +4588,7 @@ test("stream: /health block is additive and exposes the divergence counter", ()
}); });
// ── OpenAI Structured Outputs (response_format) — lib/structured-output.mjs ── // ── OpenAI Structured Outputs (response_format) — lib/structured-output.mjs ──
import { detectStructuredOutput, validateJsonSchema, extractJsonPayload, structuredSystemInstruction, StructuredOutputError, resolveMaxAttempts } from "./lib/structured-output.mjs"; import { detectStructuredOutput, validateJsonSchema, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, StructuredOutputError, resolveMaxAttempts } from "./lib/structured-output.mjs";
test("detectStructuredOutput: json_schema shape", () => { test("detectStructuredOutput: json_schema shape", () => {
const d = detectStructuredOutput({ response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object" } } } }); const d = detectStructuredOutput({ response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object" } } } });
@@ -4383,6 +4615,34 @@ test("cacheHash: structured marker isolates JSON requests from the conversationa
assert.equal(plain, cacheHash("m", msgs, { keyId: "k" })); // unchanged for normal requests assert.equal(plain, cacheHash("m", msgs, { keyId: "k" })); // unchanged for normal requests
}); });
// ── validateJsonSchemaSafe (#181): deep value must NOT crash the handler ─────
// A recursive schema + a model reply nested ~thousands deep overflows the value-
// depth recursion → RangeError → the handler used to surface a generic 500. The
// safe façade turns it into a validation miss (→ retry → refusal). Mutation-proof:
// replace the wrapper body with a bare `validateJsonSchema(...)` call and the deep
// test throws instead of returning errors.
test("validateJsonSchemaSafe: pathologically deep value → errors, never throws", () => {
const schema = { $defs: { node: { type: "object", properties: { child: { $ref: "#/$defs/node" } } } }, $ref: "#/$defs/node" };
let deep = {};
let cur = deep;
for (let i = 0; i < 6000; i++) { cur.child = {}; cur = cur.child; } // way past any stack limit
let out;
assert.doesNotThrow(() => { out = validateJsonSchemaSafe(deep, schema, "$", true); }, "must not throw a RangeError out to the handler");
assert.ok(Array.isArray(out) && out.length > 0, "returns a non-empty validation error, so the retry loop yields a refusal not a 500");
});
test("validateJsonSchemaSafe: well-formed value passes through unchanged (byte-identical to the raw validator)", () => {
const schema = { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } };
assert.deepEqual(validateJsonSchemaSafe({ name: "a", age: 3 }, schema), validateJsonSchema({ name: "a", age: 3 }, schema));
assert.deepEqual(validateJsonSchemaSafe({ name: "a" }, schema), validateJsonSchema({ name: "a" }, schema)); // error case matches too
});
test("validateJsonSchemaSafe: re-throws a non-RangeError so genuine bugs aren't masked as a validation miss", () => {
// A schema whose `required` is a non-iterable makes the inner validator throw a TypeError — that's
// a real bug, not a deep-value overflow, and must surface (not be swallowed as "did not validate").
assert.throws(() => validateJsonSchemaSafe({ x: 1 }, { type: "object", required: 42 }), (e) => !(e instanceof RangeError));
});
test("validateJsonSchema: valid object passes", () => { test("validateJsonSchema: valid object passes", () => {
assert.deepEqual(validateJsonSchema({ name: "a", age: 3 }, { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } }), []); assert.deepEqual(validateJsonSchema({ name: "a", age: 3 }, { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } }), []);
}); });