mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2144e6769f | ||
|
|
e6f1a6aac1 | ||
|
|
f14f4ec754 | ||
|
|
bafad077ff | ||
|
|
b038d3ceac |
@@ -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_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
| `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_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) |
|
| `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). |
|
||||||
|
|||||||
@@ -355,6 +355,11 @@ export function cacheHash(model, messages, opts = {}) {
|
|||||||
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
||||||
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
||||||
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
|
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) {
|
for (const m of messages) {
|
||||||
h.update(m.role || "");
|
h.update(m.role || "");
|
||||||
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -59,6 +59,16 @@ export async function runDoctor(opts = {}) {
|
|||||||
// of recommending a downgrade against a stale hardcoded value.
|
// of recommending a downgrade against a stale hardcoded value.
|
||||||
let latestVersion = opts.mockLatest;
|
let latestVersion = opts.mockLatest;
|
||||||
if (!latestVersion) {
|
if (!latestVersion) {
|
||||||
|
// Issue #173: `git show origin/main:...` reads the LOCALLY CACHED remote ref. Without a
|
||||||
|
// fetch first, a machine that hasn't pulled since the last release sees latest == current
|
||||||
|
// and reports noop — new releases were invisible everywhere except the machine that cut
|
||||||
|
// the tag (live repro: Oracle VM, 2026-07-17). Fetch before comparing; on failure
|
||||||
|
// (offline, auth, timeout) fall through to the cached ref — the pre-existing behavior.
|
||||||
|
if (!opts.skipNetwork) {
|
||||||
|
try {
|
||||||
|
execSync(`git -C ${ocpDir} fetch --tags --quiet`, { stdio: ["pipe", "pipe", "pipe"], timeout: 15000 });
|
||||||
|
} catch { /* offline → compare against cached origin/main, as before */ }
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
const remotePkg = JSON.parse(out);
|
const remotePkg = JSON.parse(out);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readd
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||||
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
const ts = formatSnapshotTimestamp(new Date());
|
||||||
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
||||||
mkdirSync(root, { recursive: true });
|
mkdirSync(root, { recursive: true });
|
||||||
|
|
||||||
@@ -48,7 +48,10 @@ export function listSnapshots(homeDir) {
|
|||||||
return readdirSync(root)
|
return readdirSync(root)
|
||||||
.filter(name => name.startsWith("upgrade-snapshot-"))
|
.filter(name => name.startsWith("upgrade-snapshot-"))
|
||||||
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => {
|
||||||
|
const chronological = parseSnapshotTimestamp(a.name) - parseSnapshotTimestamp(b.name);
|
||||||
|
return chronological || a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,9 +110,21 @@ export function gcSnapshots(homeDir, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseSnapshotTimestamp(name) {
|
function parseSnapshotTimestamp(name) {
|
||||||
|
// Both legacy ISO names and Windows-safe names are supported.
|
||||||
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
||||||
|
// upgrade-snapshot-2026-05-11T08-30-00Z → epoch ms
|
||||||
const m = name.match(/upgrade-snapshot-(.+)$/);
|
const m = name.match(/upgrade-snapshot-(.+)$/);
|
||||||
if (!m) return 0;
|
if (!m) return 0;
|
||||||
const t = Date.parse(m[1]);
|
const raw = m[1];
|
||||||
return Number.isFinite(t) ? t : 0;
|
const t = Date.parse(raw);
|
||||||
|
if (Number.isFinite(t)) return t;
|
||||||
|
const iso = raw.replace(/(T\d{2})-(\d{2})-(\d{2})Z$/, "$1:$2:$3Z");
|
||||||
|
const portable = Date.parse(iso);
|
||||||
|
return Number.isFinite(portable) ? portable : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSnapshotTimestamp(date) {
|
||||||
|
// Windows forbids ':' in directory names. Replacing only the time separators
|
||||||
|
// preserves chronological lexical order and keeps the timestamp readable.
|
||||||
|
return date.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -17,6 +17,20 @@ import { existsSync, copyFileSync } from "node:fs";
|
|||||||
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||||
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
|
// Post-flight acceptance predicate (issue #173). A health probe passes ONLY when the server
|
||||||
|
// is authed AND actually serving the TARGET version. auth.ok alone is not enough: a stale
|
||||||
|
// process holding the port answers auth.ok=true while still running the OLD code — exactly
|
||||||
|
// what a nohup-fallback orphan did on 2026-07-17 (upgrade "succeeded", /health kept serving
|
||||||
|
// 3.21.1). Comparing /health.version to the checkout target catches orphan-holds-port,
|
||||||
|
// restart-didn't-take, and wrong-unit-restarted alike. `target` tolerates a leading "v"
|
||||||
|
// (doctor reports "v3.22.1"; /health reports "3.22.1"); an empty/unknown target degrades to
|
||||||
|
// the old auth-only check rather than blocking an otherwise-good upgrade.
|
||||||
|
export function postFlightOk(body, target) {
|
||||||
|
if (body?.auth?.ok !== true) return false;
|
||||||
|
const want = String(target || "").replace(/^v/, "");
|
||||||
|
return !want || body?.version === want;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runUpgrade(opts = {}) {
|
export async function runUpgrade(opts = {}) {
|
||||||
const dryRun = !!opts.dryRun;
|
const dryRun = !!opts.dryRun;
|
||||||
const yes = !!opts.yes;
|
const yes = !!opts.yes;
|
||||||
@@ -137,16 +151,22 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
if (!opts.mockExec) {
|
if (!opts.mockExec) {
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
let ok = false;
|
let ok = false;
|
||||||
|
let lastSeen = null;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
||||||
const body = JSON.parse(out);
|
const body = JSON.parse(out);
|
||||||
if (body.auth?.ok === true) { ok = true; break; }
|
lastSeen = body.version;
|
||||||
|
if (postFlightOk(body, doctor.latest_version)) { ok = true; break; }
|
||||||
} catch { /* retry */ }
|
} catch { /* retry */ }
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
|
phases.push({
|
||||||
|
name: "post-flight", status: "fail",
|
||||||
|
message: `health did not return auth.ok=true AND version=${doctor.latest_version} within 10s`
|
||||||
|
+ (lastSeen ? ` (last saw version=${lastSeen} — a stale process may still hold the port; check \`ss -ltnp\` / \`lsof -i\`)` : ""),
|
||||||
|
});
|
||||||
throw new Error("post-flight failed");
|
throw new Error("post-flight failed");
|
||||||
}
|
}
|
||||||
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
||||||
|
|||||||
+24
-7
@@ -35,7 +35,7 @@
|
|||||||
*/
|
*/
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn, execFileSync, spawnSync } from "node:child_process";
|
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 { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
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 { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
|
||||||
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
|
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
|
||||||
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.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 __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"));
|
||||||
@@ -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.`;
|
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,
|
// Build the full system-prompt string: OCP_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),
|
||||||
// ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper".
|
// 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) {
|
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 OCP_SYSTEM_PROMPT_WRAPPER;
|
return appendOperatorPrompt(OCP_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 `${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) ─────────────────────────────
|
// ── 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 HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
||||||
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||||
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
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 /
|
// 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
|
||||||
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
|
// 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;
|
req._cacheHash = null;
|
||||||
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
|
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
|
||||||
} 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).
|
||||||
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
|
// 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
|
req._cacheHash = hash; // store for later write-back
|
||||||
try {
|
try {
|
||||||
const cached = getCachedResponse(hash, CACHE_TTL);
|
const cached = getCachedResponse(hash, CACHE_TTL);
|
||||||
|
|||||||
+131
-16
@@ -204,6 +204,23 @@ test("cacheHash includes temperature in hash", () => {
|
|||||||
assert.notEqual(h2, h3);
|
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", () => {
|
test("cacheHash includes max_tokens in hash", () => {
|
||||||
const h1 = cacheHash("sonnet", msgs1, {});
|
const h1 = cacheHash("sonnet", msgs1, {});
|
||||||
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
||||||
@@ -821,11 +838,61 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
|
|||||||
assert.equal(result.next_action.kind, "noop");
|
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 ──
|
// ── Upgrade Tests ──
|
||||||
import { runUpgrade } from "./scripts/upgrade.mjs";
|
import { runUpgrade, postFlightOk } from "./scripts/upgrade.mjs";
|
||||||
|
|
||||||
console.log("\nUpgrade:");
|
console.log("\nUpgrade:");
|
||||||
|
|
||||||
|
// ── postFlightOk (issue #173) — the acceptance predicate for phase 6 ─────────
|
||||||
|
// Mutation-proof: revert the version comparison to auth-only and the "stale process
|
||||||
|
// still holds the port" test below goes green-to-red (that case is the 2026-07-17
|
||||||
|
// Oracle incident: orphan answered auth.ok=true while serving the OLD version).
|
||||||
|
test("postFlightOk: rejects a healthy-looking probe that serves the WRONG version (orphan case)", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.21.1" }, "v3.22.1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postFlightOk: accepts auth.ok + exact target version, tolerating the leading v", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, "v3.22.1"), true);
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, "3.22.1"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postFlightOk: auth failure rejects regardless of version", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: false }, version: "3.22.1" }, "v3.22.1"), false);
|
||||||
|
assert.equal(postFlightOk({ version: "3.22.1" }, "v3.22.1"), false);
|
||||||
|
assert.equal(postFlightOk(null, "v3.22.1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("postFlightOk: unknown/empty target degrades to the auth-only check (never blocks)", () => {
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, ""), true);
|
||||||
|
assert.equal(postFlightOk({ auth: { ok: true }, version: "3.22.1" }, undefined), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("upgrade --dry-run prints plan, no side effects", async () => {
|
test("upgrade --dry-run prints plan, no side effects", async () => {
|
||||||
const result = await runUpgrade({
|
const result = await runUpgrade({
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
@@ -880,6 +947,41 @@ import { join as testJoin } from "node:path";
|
|||||||
|
|
||||||
console.log("\nSnapshot:");
|
console.log("\nSnapshot:");
|
||||||
|
|
||||||
|
const portableSnapshotName = (isoTimestamp) => `upgrade-snapshot-${isoTimestamp.replace(/:/g, "-")}`;
|
||||||
|
const legacyMixedSnapshot = "upgrade-snapshot-2026-05-11T09:05:00Z";
|
||||||
|
const portableMixedSnapshot = "upgrade-snapshot-2026-05-11T09-47-00Z";
|
||||||
|
|
||||||
|
function runMixedSnapshotScenario() {
|
||||||
|
// NTFS rejects the legacy ':' name, so exercise the real exported functions
|
||||||
|
// in an isolated process whose built-in fs bindings expose both formats.
|
||||||
|
const moduleUrl = new URL("./scripts/lib/snapshot.mjs", import.meta.url).href;
|
||||||
|
const script = `
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { syncBuiltinESMExports } from "node:module";
|
||||||
|
const names = ${JSON.stringify([legacyMixedSnapshot, portableMixedSnapshot])};
|
||||||
|
const deleted = [];
|
||||||
|
fs.existsSync = () => true;
|
||||||
|
fs.readdirSync = () => [...names];
|
||||||
|
fs.statSync = () => ({ mtimeMs: 0 });
|
||||||
|
fs.rmSync = (path) => { deleted.push(path); };
|
||||||
|
syncBuiltinESMExports();
|
||||||
|
const { listSnapshots, gcSnapshots } = await import(${JSON.stringify(moduleUrl)});
|
||||||
|
const listed = listSnapshots("/virtual-home").map(snapshot => snapshot.name);
|
||||||
|
const gc = gcSnapshots("/virtual-home", {
|
||||||
|
keepCount: 1,
|
||||||
|
keepDays: 0,
|
||||||
|
now: new Date("2026-05-12T00:00:00Z")
|
||||||
|
});
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
listed,
|
||||||
|
kept: gc.kept.map(snapshot => snapshot.name),
|
||||||
|
removed: gc.removed.map(snapshot => snapshot.name),
|
||||||
|
deleted
|
||||||
|
}));
|
||||||
|
`;
|
||||||
|
return JSON.parse(execFileSync(process.execPath, ["--input-type=module", "--eval", script], { encoding: "utf8" }));
|
||||||
|
}
|
||||||
|
|
||||||
test("writeSnapshot creates dir + manifest files", () => {
|
test("writeSnapshot creates dir + manifest files", () => {
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
@@ -904,7 +1006,7 @@ test("listSnapshots returns sorted by ISO timestamp", () => {
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
|
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
const list = listSnapshots(root);
|
const list = listSnapshots(root);
|
||||||
assert.equal(list.length, 3);
|
assert.equal(list.length, 3);
|
||||||
@@ -913,6 +1015,19 @@ test("listSnapshots returns sorted by ISO timestamp", () => {
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("listSnapshots sorts mixed legacy and Windows-safe names chronologically", () => {
|
||||||
|
const result = runMixedSnapshotScenario();
|
||||||
|
assert.deepEqual(result.listed, [legacyMixedSnapshot, portableMixedSnapshot]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots keeps the newer Windows-safe snapshot across the format boundary", () => {
|
||||||
|
const result = runMixedSnapshotScenario();
|
||||||
|
assert.deepEqual(result.kept, [portableMixedSnapshot]);
|
||||||
|
assert.deepEqual(result.removed, [legacyMixedSnapshot]);
|
||||||
|
assert.equal(result.deleted.length, 1);
|
||||||
|
assert.ok(result.deleted[0].endsWith(legacyMixedSnapshot));
|
||||||
|
});
|
||||||
|
|
||||||
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
||||||
// Use mockExec=true so no real commands are run.
|
// Use mockExec=true so no real commands are run.
|
||||||
// Verify the success path returns a snapshotPath (Fix B regression guard).
|
// Verify the success path returns a snapshotPath (Fix B regression guard).
|
||||||
@@ -1002,7 +1117,7 @@ test("gcSnapshots keeps last N regardless of age", () => {
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
assert.equal(result.kept.length, 3);
|
assert.equal(result.kept.length, 3);
|
||||||
@@ -1085,7 +1200,7 @@ test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () =
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
|
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
|
||||||
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
@@ -1099,7 +1214,7 @@ test("gcSnapshots never deletes the most recent snapshot", () => {
|
|||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
tMkdirSync(testJoin(dotOcp, "upgrade-snapshot-2026-01-01T10:00:00Z"));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName("2026-01-01T10:00:00Z")));
|
||||||
// Even with keepCount=0 and keepDays=0, the most recent must survive
|
// Even with keepCount=0 and keepDays=0, the most recent must survive
|
||||||
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
assert.equal(result.kept.length, 1);
|
assert.equal(result.kept.length, 1);
|
||||||
@@ -1112,13 +1227,13 @@ test("gcSnapshots --dry-run reports plan without deleting", () => {
|
|||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
tMkdirSync(dotOcp, { recursive: true });
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
tMkdirSync(testJoin(dotOcp, portableSnapshotName(ts)));
|
||||||
}
|
}
|
||||||
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
assert.equal(result.dryRun, true);
|
assert.equal(result.dryRun, true);
|
||||||
assert.equal(result.removed.length, 2);
|
assert.equal(result.removed.length, 2);
|
||||||
// Files still exist
|
// Files still exist
|
||||||
assert.ok(testExistsSync(testJoin(dotOcp, "upgrade-snapshot-2026-04-01T10:00:00Z")));
|
assert.ok(testExistsSync(testJoin(dotOcp, portableSnapshotName("2026-04-01T10:00:00Z"))));
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2613,21 +2728,21 @@ import { tmpdir as hTmp } from "node:os";
|
|||||||
console.log("\nTUI home preparation:");
|
console.log("\nTUI home preparation:");
|
||||||
|
|
||||||
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
|
test("prepareTuiHome scratch mode: symlinks creds, seeds onboarded config, trusts cwd, strips history", () => {
|
||||||
const realHome = hMkdtemp(`${hTmp()}/real-`);
|
const realHome = hMkdtemp(testJoin(hTmp(), "real-"));
|
||||||
hMkdir(`${realHome}/.claude`, { recursive: true });
|
hMkdir(testJoin(realHome, ".claude"), { recursive: true });
|
||||||
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"x"}');
|
hWrite(testJoin(realHome, ".claude", ".credentials.json"), '{"token":"x"}');
|
||||||
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
|
hWrite(testJoin(realHome, ".claude.json"), JSON.stringify({ theme: "dark", projects: { "/old/secret/project": { hasTrustDialogAccepted: true } } }));
|
||||||
const tuiHome = hMkdtemp(`${hTmp()}/tui-`);
|
const tuiHome = hMkdtemp(testJoin(hTmp(), "tui-"));
|
||||||
const cwd = `${tuiHome}/work`;
|
const cwd = testJoin(tuiHome, "work");
|
||||||
prepareTuiHome(realHome, tuiHome, cwd);
|
prepareTuiHome(realHome, tuiHome, cwd);
|
||||||
// credentials symlinked (token never copied)
|
// credentials symlinked (token never copied)
|
||||||
assert.equal(hReadlink(`${tuiHome}/.claude/.credentials.json`), `${realHome}/.claude/.credentials.json`);
|
assert.equal(hReadlink(testJoin(tuiHome, ".claude", ".credentials.json")), testJoin(realHome, ".claude", ".credentials.json"));
|
||||||
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
|
const seed = JSON.parse(hRead(testJoin(tuiHome, ".claude.json"), "utf8"));
|
||||||
assert.equal(seed.hasCompletedOnboarding, true);
|
assert.equal(seed.hasCompletedOnboarding, true);
|
||||||
assert.equal(seed.theme, "dark"); // onboarded config carried over
|
assert.equal(seed.theme, "dark"); // onboarded config carried over
|
||||||
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
|
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true); // scratch cwd trusted
|
||||||
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
|
assert.equal(seed.projects["/old/secret/project"], undefined); // user project history stripped
|
||||||
assert.ok(hExists(`${tuiHome}/.claude/projects`)); // own projects dir
|
assert.ok(hExists(testJoin(tuiHome, ".claude", "projects"))); // own projects dir
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {
|
test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd in real config", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user