mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdb6662c7c | ||
|
|
4f9e2ff281 | ||
|
|
45c5717aea |
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -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_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). |
|
||||
| `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) |
|
||||
| `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). |
|
||||
|
||||
@@ -52,3 +52,38 @@ export function derivePromptCharBudget(models, { charsPerToken = 3, floor = 1500
|
||||
export function resolvePromptCharBudget(rawEnv, 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;
|
||||
}
|
||||
|
||||
@@ -209,6 +209,27 @@ export function validateJsonSchema(value, schema, path = "$", strict = false, ro
|
||||
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) {
|
||||
try { return { ok: true, value: JSON.parse(s) }; } catch { return { ok: false }; }
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+47
-7
@@ -42,7 +42,7 @@ import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||
import { 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 { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.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 { hasImageContent, buildImageBlocks, buildStreamJsonInput, MultimodalError } from "./lib/multimodal.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 _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)
|
||||
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. You have full access to the local filesystem, working directory, and shell through your available tools (Bash, Read, Write, Edit, Glob, Grep, etc.). Use them as needed to complete the operator's requests.`;
|
||||
|
||||
// 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 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
|
||||
@@ -206,12 +226,12 @@ const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You
|
||||
function extractSystemPrompt(messages) {
|
||||
const systemMessages = (messages ?? []).filter(m => m.role === "system");
|
||||
if (systemMessages.length === 0) {
|
||||
return appendOperatorPrompt(OCP_SYSTEM_PROMPT_WRAPPER, SYSTEM_PROMPT);
|
||||
return appendOperatorPrompt(SYSTEM_PROMPT_WRAPPER, SYSTEM_PROMPT);
|
||||
}
|
||||
const clientContent = systemMessages.map(m =>
|
||||
contentToText(m.content)
|
||||
).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) ─────────────────────────────
|
||||
@@ -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
|
||||
// context rather than changing the instruction set.
|
||||
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);
|
||||
// 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
|
||||
@@ -812,6 +832,21 @@ if (TUI_MODE && PROXY_ANONYMOUS_KEY) {
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
lastErr = "schema validation failed: " + errs.slice(0, 5).join("; ");
|
||||
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 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" : ""}`);
|
||||
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 (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
|
||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||
|
||||
+228
-2
@@ -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
|
||||
// return `base` unconditionally and the first test fails; make it stop trimming
|
||||
// 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):");
|
||||
|
||||
@@ -906,6 +906,204 @@ test("appendOperatorPrompt: operator value is trimmed before appending", () => {
|
||||
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 have full access to the local filesystem";
|
||||
|
||||
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 have full access to the local filesystem";
|
||||
// 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 ──
|
||||
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";
|
||||
|
||||
@@ -4356,7 +4554,7 @@ test("stream: /health block is additive and exposes the divergence counter", ()
|
||||
});
|
||||
|
||||
// ── 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", () => {
|
||||
const d = detectStructuredOutput({ response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object" } } } });
|
||||
@@ -4383,6 +4581,34 @@ test("cacheHash: structured marker isolates JSON requests from the conversationa
|
||||
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", () => {
|
||||
assert.deepEqual(validateJsonSchema({ name: "a", age: 3 }, { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } }), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user