Compare commits

..
Author SHA1 Message Date
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> e2fa21ddf7 fix(prompt): OCP_LOCAL_TOOLS wrapper no longer hard-codes a tool list (closes #185)
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: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-23 17:29:24 +10:00
2 changed files with 3 additions and 93 deletions
+3 -13
View File
@@ -2804,16 +2804,6 @@ async function handleChatCompletions(req, res) {
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }]; const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
const model = parsed.model || modelsConfig.aliases.sonnet; const model = parsed.model || modelsConfig.aliases.sonnet;
// Cache keys must hash the RESOLVED model, never the string the client happened to use.
// `model` is whatever was sent — a canonical id, an alias ("opus"), or a legacyAlias
// ("claude-opus-4"). MODEL_MAP carries all three, and models.json is read once at boot, so
// repointing an alias only takes effect on restart — while the SQLite response_cache outlives
// it. Hashing the raw string would therefore keep serving the OLD model's answers under that
// alias until TTL expiry, silently defeating the repoint (the #176 hazard, for aliases).
// Resolving first also means "opus" and "claude-opus-5" correctly share one slot: identical
// spawn, identical answer. Only the cache KEY is resolved — `model` is still echoed back to
// the client verbatim, so the wire response is unchanged.
const cacheModel = MODEL_MAP[model] || model;
const stream = parsed.stream; const stream = parsed.stream;
// Validate model against known models // Validate model against known models
@@ -2910,7 +2900,7 @@ async function handleChatCompletions(req, res) {
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0); const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
let structuredHash = null; let structuredHash = null;
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) { if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
structuredHash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH }); structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
try { try {
const cached = getCachedResponse(structuredHash, CACHE_TTL); const cached = getCachedResponse(structuredHash, CACHE_TTL);
if (cached) { if (cached) {
@@ -2929,7 +2919,7 @@ async function handleChatCompletions(req, res) {
// one-off structured request (not stateful sessions / client-side prompt caching), independent of // one-off structured request (not stateful sessions / client-side prompt caching), independent of
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write. // whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
const dedupKey = (!conversationId && !hasCacheControl(messages)) const dedupKey = (!conversationId && !hasCacheControl(messages))
? cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured, configEpoch: CONFIG_EPOCH }) ? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
: null; : null;
const runStructured = async () => { const runStructured = async () => {
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured); const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
@@ -2978,7 +2968,7 @@ async function handleChatCompletions(req, res) {
} else { } else {
// D1: include keyId in hash to isolate per-key cache pools (v2 format). // 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. // configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
const hash = cacheHash(cacheModel, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH }); 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 req._cacheHash = hash; // store for later write-back
try { try {
const cached = getCachedResponse(hash, CACHE_TTL); const cached = getCachedResponse(hash, CACHE_TTL);
-80
View File
@@ -1104,86 +1104,6 @@ test("integration: toggling OCP_LOCAL_TOOLS invalidates the standard response ca
} finally { _ltRm(dir, { recursive: true, force: true }); } } finally { _ltRm(dir, { recursive: true, force: true }); }
}); });
// ── Cache keys hash the RESOLVED model, not the alias string (#194) ──────────
// models.json is read once at boot, so repointing an alias only takes effect on restart —
// while the SQLite response_cache outlives it. Hashing the raw string would keep serving the
// OLD model's answers under that alias until TTL expiry. Rather than mutate models.json
// mid-suite, these assert the equivalent observable: an alias and its canonical target must
// land on the SAME cache slot, which is true only if the key is resolved before hashing.
// Mutation: change `cacheModel` back to `model` at the three cacheHash call sites in
// server.mjs and both tests go red (2 spawns instead of 1).
// Fake that emits schema-valid JSON, so the structured path caches a VALIDATED result
// (the stock LT_FAKE returns "OK", which fails validation → refusal → never cached).
const LT_FAKE_JSON = `#!/bin/sh
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\\":true}"}]}}'
printf '%s\\n' '{"type":"result"}'
exit 0
`;
function ltFakeJson(dir) { const p = join(dir, "claude-json"); _ltWrite(p, LT_FAKE_JSON); _ltChmod(p, 0o755); return p; }
const LT_SCHEMA = { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false };
console.log("\nCache key resolves the model alias (#194):");
test("integration: an alias and its canonical target share ONE cache slot (normal path)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFake(dir); const counter = join(dir, "spawns.txt");
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39360", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
_ltWrite(counter, "0");
const msgs = [{ role: "user", content: "alias-resolution-probe" }];
await ltPost(39360, { model: "sonnet", messages: msgs }); // miss → spawn
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 3000);
await ltPost(39360, { model: "claude-sonnet-5", messages: msgs }); // same resolved model → HIT
await new Promise(r => setTimeout(r, 600));
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
"the canonical id must hit the slot the alias populated — a 2nd spawn means the key still hashes the raw alias");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: an alias and its canonical target share ONE cache slot (STRUCTURED path)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
const { child, buf } = ltBoot({ CLAUDE_BIN: fake, CLAUDE_PROXY_PORT: "39361", CLAUDE_CACHE_TTL: "60000", SP_COUNTER: counter }, dir);
try {
assert.ok(await ltWait(() => buf.out.includes("listening on")), `did not start: ${buf.err.slice(0, 200)}`);
_ltWrite(counter, "0");
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
const msgs = [{ role: "user", content: "structured-alias-probe" }];
await ltPost(39361, { model: "sonnet", messages: msgs, response_format: rf });
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
await ltPost(39361, { model: "claude-sonnet-5", messages: msgs, response_format: rf });
await new Promise(r => setTimeout(r, 600));
assert.equal(Number(_ltRead(counter, "utf8")) || 0, 1,
"structured cache key must resolve the alias too — this is the path the epoch-only fix missed");
} finally { child.kill("SIGKILL"); _ltRm(dir, { recursive: true, force: true }); }
});
test("integration: a config change invalidates the STRUCTURED cache too (closes the #177 gap)", async () => {
if (!LT_POSIX) return;
const dir = ltMkdir(); const fake = ltFakeJson(dir); const counter = join(dir, "spawns.txt");
const rf = { type: "json_schema", json_schema: { name: "probe", schema: LT_SCHEMA } };
const req = { model: "sonnet", messages: [{ role: "user", content: "structured-epoch-probe" }], response_format: rf };
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, 200)}`);
_ltWrite(counter, "0");
await ltPost(port, req);
await ltWait(() => (Number(_ltRead(counter, "utf8")) || 0) >= 1, 4000);
return Number(_ltRead(counter, "utf8")) || 0;
} finally { child.kill("SIGKILL"); }
};
try {
const off = await bootOnce({}, 39362); // caches under epoch(negative wrapper)
const on = await bootOnce({ OCP_LOCAL_TOOLS: "1" }, 39363); // same DB, epoch differs → must re-spawn
assert.equal(off, 1, "first structured request (cache empty) must spawn claude");
assert.equal(on, 1, "structured cache must honor CONFIG_EPOCH — before #194 it omitted the epoch entirely and served the stale answer");
} 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";