Compare commits

..
Author SHA1 Message Date
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> 2144e6769f fix(cache): fold a boot-config epoch into the response-cache key (#176)
The cache key hashed model + keyId + sampling params + raw messages, but the
ANSWER also depends on boot-time server config that shapes the composed prompt
and tool surface: CLAUDE_SYSTEM_PROMPT (newly load-bearing since #175),
OCP_SYSTEM_PROMPT_WRAPPER, CLAUDE_ALLOWED_TOOLS, and CLAUDE_NO_CONTEXT. The
cache store is SQLite-backed and survives restarts, so an operator who changed
any of these and restarted kept serving answers composed under the OLD config
until TTL expiry (found by the #175 independent reviewer).

Fix: server.mjs computes CONFIG_EPOCH once at boot — a 16-hex sha256 digest of
the four values — and passes it to cacheHash, which folds `ce:<epoch>|` into the
key. Any config change = instant whole-cache invalidation (the honest behavior).
Callers that omit configEpoch (tests, any older path) hash byte-identically to
before — asserted by test. Runtime-mutable settings (maxPromptChars via the
settings API) are deliberately excluded: a const epoch cannot track them, and
truncation drops context rather than changing the instruction set (noted in the
code comment).

One-time side effect on upgrade: existing cache entries no longer match (keys
now carry the epoch). Cache is off by default and TTL-bounded; a one-time miss
storm is the cost of never serving stale-config answers again.

ALIGNMENT.md Rule 2: no cli.js citation applies — cache-key composition is
OCP-internal (Class B); no endpoint, header, or wire field changes.

Tests: +2, mutation-proven (dropping the fold fails both). Suite 343/0.

Closes #176

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 19:27:08 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
e6f1a6aac1 fix(server): wire CLAUDE_SYSTEM_PROMPT (dead since APPEND_SYSTEM_PROMPT retirement) (#175)
* fix(server): wire CLAUDE_SYSTEM_PROMPT into the composed system prompt (was dead since APPEND_SYSTEM_PROMPT retirement)

The var was read (SYSTEM_PROMPT, server.mjs), documented in the file header as
"appended to all requests", and echoed on /health.systemPrompt — but nothing on
the request path consumed it: extractSystemPrompt() composed only the wrapper +
client system messages. The wiring was lost when APPEND_SYSTEM_PROMPT was
retired, leaving the header comment and the buildCliArgs comment describing
behavior that did not exist (caught by the PR #170 independent reviewer).

Wire, not delete, because:
- the /health `systemPrompt` field is part of the grandfathered B.2 contract
  (ADR 0006, frozen at v3.16.4) — removal would need an ADR; wiring keeps the
  shape and makes the field honest;
- fleet check: no deployment sets the var (Mac prod plist, Oracle systemd unit,
  PI231 /etc/ocp/ocp.env all clean), so wiring changes behavior for NOBODY today;
- with the var unset, appendOperatorPrompt returns its input string unchanged —
  the default path is byte-for-byte identical.

Mechanics: new pure lib/prompt.mjs `appendOperatorPrompt(base, operatorAppend)`
(trimmed; whitespace-only treated as unset so a stray space in a service unit
cannot inject "\n\n " into every request), applied as the LAST segment in both
extractSystemPrompt branches — an operator-wide directive reads as the final
instruction, after client system messages. TUI-mode is untouched (panes keep the
interactive CLI's own system prompt); documented in the README row.

ALIGNMENT.md Rule 2: no cli.js citation applies. No endpoint, header, or wire
field is added or altered; the change affects only the CONTENT OCP passes to the
already-established `--system-prompt` flag (file header § verified v2.1.104),
i.e. OCP-owned prompt composition. /health shape unchanged.

Tests: +3, doubly mutation-proven (unconditional-base revert → 2 failures;
trim removal → 2 failures). Suite 341 passed / 0 failed.

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

* docs(readme): cache-staleness caveat on CLAUDE_SYSTEM_PROMPT row (reviewer advisory)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 19:18:57 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
f14f4ec754 fix(upgrade): doctor fetches tags before deciding latest + post-flight asserts served version (#173) (#174)
Two fixes for the two halves of issue #173, both from live incidents during the
2026-07-17 fleet update:

1. scripts/doctor.mjs — `git show origin/main:package.json` reads the LOCALLY
   CACHED remote ref; without a fetch first, any machine that hadn't pulled since
   the last release saw latest == current and reported "Already at latest" (live
   repro: Oracle VM at 3.21.1 with v3.22.1 released). The doctor now runs
   `git fetch --tags --quiet` (15s timeout) before comparing, gated on
   !opts.skipNetwork; on failure (offline/auth) it falls through to the cached
   ref — the pre-existing behavior. All existing doctor tests pass mockLatest +
   skipNetwork, so no test touches the network.

2. scripts/upgrade.mjs — post-flight accepted any healthy /health (auth.ok only),
   so a stale process holding the port passed post-flight while still serving the
   OLD version (live repro: a Jul-7 nohup-fallback orphan held :3456; upgrade
   "succeeded", /health kept serving 3.21.1). New exported predicate
   postFlightOk(body, target): auth.ok AND /health.version === target (leading-v
   tolerant; empty target degrades to the auth-only check, never blocks). The
   failure message now reports the last-seen version and points at the
   stale-process diagnosis (`ss -ltnp` / `lsof -i`).

Tests: +4, mutation-proven — reverting the predicate to auth-only fails the
"orphan case" test (337/1). Full suite 338 passed / 0 failed.

No server.mjs change — scripts layer only; no cli.js operation involved, so no
citation applies.

Closes #173

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 18:52:45 +10:00
5 changed files with 90 additions and 7 deletions
+1
View File
@@ -228,6 +228,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
| `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: the response cache key does not include server config — after changing this value, flush the cache (`ocp clear`) or let TTL expire. |
| `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). |
+5
View File
@@ -355,6 +355,11 @@ export function cacheHash(model, messages, opts = {}) {
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
// #176: fold the server's boot-config epoch into the key, so a config change that shapes
// answers (operator system prompt, wrapper text, allowed tools, NO_CONTEXT) invalidates
// the persistent cache instead of serving answers composed under the old config. Callers
// that omit it (older paths, tests) hash byte-identically to before.
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
for (const m of messages) {
h.update(m.role || "");
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
+17
View File
@@ -0,0 +1,17 @@
// lib/prompt.mjs — pure operator-append step for the system prompt.
//
// Extracted so the rule is unit-testable (the suite never imports server.mjs — it
// boots a listener). server.mjs composes wrapper + client system messages exactly as
// before, then passes the result through this. With CLAUDE_SYSTEM_PROMPT unset the
// return is the INPUT STRING UNCHANGED — the default path stays byte-for-byte
// identical, which is the repo's bar for touching a request-shaping function.
//
// The operator prompt goes LAST deliberately: a server-wide directive ("answer in
// Chinese") should read as the final instruction, not something a client system
// message overrides by coming later. Whitespace-only values are treated as unset —
// a stray space in a service unit's Environment= line must not inject "\n\n " into
// every request.
export function appendOperatorPrompt(base, operatorAppend) {
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
return op ? `${base}\n\n${op}` : base;
}
+24 -7
View File
@@ -35,7 +35,7 @@
*/
import { createServer } from "node:http";
import { spawn, execFileSync, spawnSync } from "node:child_process";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { randomUUID, timingSafeEqual, createHash as cryptoCreateHash } from "node:crypto";
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
@@ -49,6 +49,7 @@ import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthB
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
import { appendOperatorPrompt } from "./lib/prompt.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -195,17 +196,19 @@ function resolveClaude() {
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,
// then any system-role messages from the request appended (separated by blank line).
// ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper".
// 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
// byte-for-byte unchanged). ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper".
function extractSystemPrompt(messages) {
const systemMessages = (messages ?? []).filter(m => m.role === "system");
if (systemMessages.length === 0) {
return OCP_SYSTEM_PROMPT_WRAPPER;
return appendOperatorPrompt(OCP_SYSTEM_PROMPT_WRAPPER, SYSTEM_PROMPT);
}
const clientContent = systemMessages.map(m =>
contentToText(m.content)
).join("\n\n");
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
return appendOperatorPrompt(`${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`, SYSTEM_PROMPT);
}
// ── NDJSON line buffer parser (Phase 6c port) ─────────────────────────────
@@ -343,6 +346,19 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
// Config epoch for the response cache (issue #176). The cache key hashes model + messages +
// sampling params, but the ANSWER also depends on boot-time server config that shapes the
// composed prompt / tool surface: the operator system prompt (#175), the OCP wrapper text,
// the allowed-tools set, and NO_CONTEXT. The cache store is SQLite-backed and survives
// restarts, so without this an operator who changes any of these and restarts keeps serving
// answers composed under the OLD config until TTL expiry. Folding a digest of the four into
// every cache key makes any change an instant, whole-cache invalidation — the honest behavior.
// Deliberately boot-time-only: runtime-mutable settings (e.g. maxPromptChars via the settings
// 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]))
.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
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
@@ -2626,8 +2642,9 @@ async function handleChatCompletions(req, res) {
req._cacheHash = null;
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
} else {
// D1: include keyId in hash to isolate per-key cache pools (v2 format)
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
req._cacheHash = hash; // store for later write-back
try {
const cached = getCachedResponse(hash, CACHE_TTL);
+43
View File
@@ -204,6 +204,23 @@ test("cacheHash includes temperature in hash", () => {
assert.notEqual(h2, h3);
});
// ── configEpoch (#176): a boot-config change must invalidate the persistent cache ──
// Mutation-proof: drop the `ce:` fold in keys.mjs and the first test goes green-to-red.
test("cacheHash: different configEpoch → different key (config change invalidates)", () => {
const h1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
const h2 = cacheHash("sonnet", msgs1, { configEpoch: "bbbb000011112222" });
assert.notEqual(h1, h2);
});
test("cacheHash: same configEpoch is stable; absent epoch hashes byte-identically to pre-#176", () => {
const e1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
const e2 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
assert.equal(e1, e2);
// absent-epoch calls (older callers, all pre-existing tests) must not change behavior
assert.equal(cacheHash("sonnet", msgs1, {}), cacheHash("sonnet", msgs1));
assert.notEqual(e1, cacheHash("sonnet", msgs1), "epoch-carrying key differs from legacy key");
});
test("cacheHash includes max_tokens in hash", () => {
const h1 = cacheHash("sonnet", msgs1, {});
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
@@ -821,6 +838,32 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
assert.equal(result.next_action.kind, "noop");
});
// ── System-prompt operator append (CLAUDE_SYSTEM_PROMPT wiring) ─────────────
// The var was documented + echoed on /health but never reached a request (dead
// since APPEND_SYSTEM_PROMPT was retired — caught in PR #170 review). The wiring
// 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 } from "./lib/prompt.mjs";
console.log("\nSystem-prompt operator append:");
test("appendOperatorPrompt: appends the operator prompt LAST, blank-line separated", () => {
assert.equal(appendOperatorPrompt("WRAPPER\n\nclient", "Answer in Chinese."), "WRAPPER\n\nclient\n\nAnswer in Chinese.");
});
test("appendOperatorPrompt: unset/empty/whitespace-only → base returned BYTE-IDENTICAL", () => {
const base = "WRAPPER\n\nclient sys";
assert.equal(appendOperatorPrompt(base, undefined), base);
assert.equal(appendOperatorPrompt(base, ""), base);
assert.equal(appendOperatorPrompt(base, " \n "), base, "a stray space in a service unit must not inject anything");
assert.equal(appendOperatorPrompt(base, null), base);
});
test("appendOperatorPrompt: operator value is trimmed before appending", () => {
assert.equal(appendOperatorPrompt("W", " hi "), "W\n\nhi");
});
// ── Upgrade Tests ──
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";