claude-code-cache-fix 3.1.1 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,533 @@
1
+ // upstream-change-detection — read-only structural fingerprinter that
2
+ // detects when Anthropic ships CC updates that change the structural shape
3
+ // of /v1/messages requests. Per-namespace baseline persists across proxy
4
+ // restarts to prevent false-positive floods.
5
+ //
6
+ // Output:
7
+ // - stderr line prefixed [upstream-change] for proxy journals/logs
8
+ // - ~/.claude/upstream-changes.jsonl (event log: baseline_established,
9
+ // structural_change)
10
+ // - ~/.claude/upstream-baseline.json (per-namespace baseline, atomic
11
+ // full replace)
12
+ //
13
+ // Activation: `enabled: true` in extensions.json (always loaded), gated at
14
+ // runtime by `CACHE_FIX_UPSTREAM_DETECTION=1`. Prefix-diff pattern.
15
+ //
16
+ // Privacy: every persisted field is a count, position, boolean, bucket
17
+ // label, or hash of stable identifiers. NO prompt content, NO file paths,
18
+ // NO message text. Test #18 enforces this at unit level.
19
+ //
20
+ // See `docs/directives/proxy-upstream-change-detection.md` for full design.
21
+
22
+ import {
23
+ mkdir as _mkdir,
24
+ readFile as _readFile,
25
+ writeFile as _writeFile,
26
+ rename as _rename,
27
+ unlink as _unlink,
28
+ appendFile as _appendFile,
29
+ } from "node:fs/promises";
30
+ import { join } from "node:path";
31
+ import { homedir } from "node:os";
32
+ import { createHash, randomBytes } from "node:crypto";
33
+
34
+ // --- Allowlists ---
35
+ //
36
+ // New entries are signal: when a request's text contains a marker / tag we
37
+ // haven't seen before, the boolean unknown-detector flips. Add entries here
38
+ // only when investigation has confirmed the new item is legitimate
39
+ // (genuine CC change, not an exploit attempt).
40
+
41
+ const KNOWN_SECTION_MARKERS = [
42
+ "# Environment",
43
+ "# System",
44
+ "# Tools",
45
+ "# Personality",
46
+ "# Settings",
47
+ "# Memory",
48
+ "# Output efficiency",
49
+ "# auto memory",
50
+ "# Doing tasks",
51
+ "# Tone and style",
52
+ "# Using your tools",
53
+ "# Text output",
54
+ "# Session-specific guidance",
55
+ "# Code references",
56
+ "# Executing actions with care",
57
+ ];
58
+
59
+ const KNOWN_REMINDER_PATTERNS = [
60
+ "<system-reminder>",
61
+ "<command-name>",
62
+ "<command-message>",
63
+ "<command-args>",
64
+ "<git-status>",
65
+ "<local-command-stdout>",
66
+ "<local-command-stderr>",
67
+ "<command-stdout>",
68
+ "<command-stderr>",
69
+ "<file-attachment>",
70
+ ];
71
+
72
+ const SECTION_MARKER_SHAPE = /^# [A-Z][a-zA-Z ]{1,30}$/m;
73
+ const REMINDER_TAG_SHAPE = /<[a-z][a-z-]{1,30}>/;
74
+
75
+ // --- Env gates (per-call to ease test isolation) ---
76
+
77
+ function isEnabled() {
78
+ return process.env.CACHE_FIX_UPSTREAM_DETECTION === "1";
79
+ }
80
+ function isQuiet() {
81
+ return process.env.CACHE_FIX_UPSTREAM_QUIET === "1";
82
+ }
83
+ function isDebug() {
84
+ return process.env.CACHE_FIX_DEBUG === "1";
85
+ }
86
+
87
+ function debug(msg) {
88
+ if (isDebug()) process.stderr.write(`[upstream-change] DEBUG: ${msg}\n`);
89
+ }
90
+
91
+ // --- Default fs (overridable for tests) ---
92
+
93
+ const DEFAULT_FS = {
94
+ mkdir: _mkdir,
95
+ readFile: _readFile,
96
+ writeFile: _writeFile,
97
+ rename: _rename,
98
+ unlink: _unlink,
99
+ appendFile: _appendFile,
100
+ };
101
+
102
+ // --- Module-scope state ---
103
+
104
+ let _namespaceMap = new Map();
105
+ let _baselineLoadedFrom = null;
106
+
107
+ export function _resetForTest() {
108
+ _namespaceMap = new Map();
109
+ _baselineLoadedFrom = null;
110
+ }
111
+
112
+ function getOutputDir() {
113
+ return process.env.CACHE_FIX_UPSTREAM_DIR || join(homedir(), ".claude");
114
+ }
115
+
116
+ function getBaselinePath(dir) {
117
+ return join(dir || getOutputDir(), "upstream-baseline.json");
118
+ }
119
+
120
+ function getJsonlPath(dir) {
121
+ return join(dir || getOutputDir(), "upstream-changes.jsonl");
122
+ }
123
+
124
+ // --- Pure helpers ---
125
+
126
+ function sha16(s) {
127
+ return createHash("sha256").update(s).digest("hex").slice(0, 16);
128
+ }
129
+
130
+ export function bucketBlockSize(size) {
131
+ if (size < 200) return "tiny";
132
+ if (size < 2000) return "small";
133
+ if (size < 20000) return "medium";
134
+ return "large";
135
+ }
136
+
137
+ export function bucketMaxTokens(n) {
138
+ if (!Number.isFinite(n) || n <= 0) return "unset";
139
+ if (n < 1024) return "tiny";
140
+ if (n < 8192) return "1k-8k";
141
+ if (n < 32768) return "8k-32k";
142
+ if (n < 100000) return "32k-100k";
143
+ return "huge";
144
+ }
145
+
146
+ export function matchKnownSectionMarkers(text) {
147
+ if (typeof text !== "string" || !text) return [];
148
+ // Strict line-based match. The marker must be the ENTIRE line (after split),
149
+ // otherwise "# Environment Details" would falsely match "# Environment" and
150
+ // we would record an allowlist index for a marker that didn't actually
151
+ // appear as a section header.
152
+ const lineSet = new Set(text.split("\n"));
153
+ const indices = [];
154
+ for (let i = 0; i < KNOWN_SECTION_MARKERS.length; i++) {
155
+ if (lineSet.has(KNOWN_SECTION_MARKERS[i])) indices.push(i);
156
+ }
157
+ return indices;
158
+ }
159
+
160
+ export function hasUnknownSectionMarker(text) {
161
+ if (typeof text !== "string" || !text) return false;
162
+ // Find candidate markers via the shape regex on each line.
163
+ const lines = text.split("\n");
164
+ for (const line of lines) {
165
+ if (SECTION_MARKER_SHAPE.test(line) && !KNOWN_SECTION_MARKERS.includes(line)) {
166
+ return true;
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+
172
+ export function matchKnownReminderPatterns(text) {
173
+ if (typeof text !== "string" || !text) return [];
174
+ const indices = [];
175
+ for (let i = 0; i < KNOWN_REMINDER_PATTERNS.length; i++) {
176
+ if (text.includes(KNOWN_REMINDER_PATTERNS[i])) indices.push(i);
177
+ }
178
+ return indices;
179
+ }
180
+
181
+ export function hasUnknownReminderPattern(text) {
182
+ if (typeof text !== "string" || !text) return false;
183
+ const matches = text.matchAll(/<[a-z][a-z-]{1,30}>/g);
184
+ for (const m of matches) {
185
+ if (!KNOWN_REMINDER_PATTERNS.includes(m[0])) return true;
186
+ }
187
+ return false;
188
+ }
189
+
190
+ export function namespaceKey(model, betaHeadersArr) {
191
+ const sorted = Array.isArray(betaHeadersArr) ? [...betaHeadersArr].sort() : [];
192
+ return sha16(`${model || ""}|${sorted.join(",")}`);
193
+ }
194
+
195
+ // Beta features arrive on the `anthropic-beta` REQUEST HEADER (Node http
196
+ // header keys are lowercased). The proxy surfaces request headers on
197
+ // `ctx.headers` to onRequest hooks. The function accepts the headers map
198
+ // (case-insensitive lookup) and falls back to body.anthropic_beta only as
199
+ // a defensive fallback for edge cases where a caller pre-merged it.
200
+ export function extractBetaHeaders(headers, body) {
201
+ const fromHeader = headers && (headers["anthropic-beta"] || headers["Anthropic-Beta"] || headers["ANTHROPIC-BETA"]);
202
+ let raw = fromHeader;
203
+ if (!raw) raw = body?.anthropic_beta;
204
+ if (!raw) return [];
205
+ if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean);
206
+ if (typeof raw === "string") return raw.split(",").map((s) => s.trim()).filter(Boolean);
207
+ return [];
208
+ }
209
+
210
+ function blockTextLength(block) {
211
+ if (!block || typeof block !== "object") return 0;
212
+ if (typeof block.text === "string") return block.text.length;
213
+ if (typeof block.content === "string") return block.content.length;
214
+ return 0;
215
+ }
216
+
217
+ function blockText(block) {
218
+ if (!block || typeof block !== "object") return "";
219
+ if (typeof block.text === "string") return block.text;
220
+ if (typeof block.content === "string") return block.content;
221
+ return "";
222
+ }
223
+
224
+ function countCacheControlInArray(arr) {
225
+ if (!Array.isArray(arr)) return { count: 0, positions: [] };
226
+ const positions = [];
227
+ for (let i = 0; i < arr.length; i++) {
228
+ const item = arr[i];
229
+ if (item && typeof item === "object" && item.cache_control) {
230
+ positions.push(i);
231
+ }
232
+ }
233
+ return { count: positions.length, positions };
234
+ }
235
+
236
+ function fingerprintSystem(system) {
237
+ const blocks = Array.isArray(system) ? system : [];
238
+ const types = blocks.map((b) => (b && typeof b === "object" && typeof b.type === "string" ? b.type : "unknown"));
239
+ const sizes = blocks.map((b) => bucketBlockSize(blockTextLength(b)));
240
+ const cc = countCacheControlInArray(blocks);
241
+
242
+ const knownIndicesSet = new Set();
243
+ let unknownPresent = false;
244
+ for (const b of blocks) {
245
+ const text = blockText(b);
246
+ for (const idx of matchKnownSectionMarkers(text)) knownIndicesSet.add(idx);
247
+ if (!unknownPresent && hasUnknownSectionMarker(text)) unknownPresent = true;
248
+ }
249
+ const knownIndicesSorted = [...knownIndicesSet].sort((a, b) => a - b);
250
+
251
+ return {
252
+ block_count: blocks.length,
253
+ block_types_in_order: types,
254
+ block_size_buckets: sizes,
255
+ known_section_marker_set_hash: sha16(knownIndicesSorted.join(",")),
256
+ known_section_marker_count: knownIndicesSorted.length,
257
+ unknown_section_marker_present: unknownPresent,
258
+ cache_control_count: cc.count,
259
+ cache_control_positions: cc.positions,
260
+ };
261
+ }
262
+
263
+ function fingerprintTools(tools) {
264
+ if (!Array.isArray(tools) || tools.length === 0) {
265
+ return {
266
+ count: 0,
267
+ names_sorted_hash: sha16(""),
268
+ schema_shape_hash: sha16(""),
269
+ };
270
+ }
271
+ const names = tools.map((t) => (t && typeof t.name === "string" ? t.name : "")).sort();
272
+ // Build name → sorted-param-keys map deterministically.
273
+ const shape = {};
274
+ for (const t of tools) {
275
+ if (!t || typeof t.name !== "string") continue;
276
+ const props = t.input_schema?.properties;
277
+ const keys = props && typeof props === "object" ? Object.keys(props).sort() : [];
278
+ shape[t.name] = keys;
279
+ }
280
+ const shapeOrdered = {};
281
+ for (const k of Object.keys(shape).sort()) shapeOrdered[k] = shape[k];
282
+
283
+ return {
284
+ count: tools.length,
285
+ names_sorted_hash: sha16(JSON.stringify(names)),
286
+ schema_shape_hash: sha16(JSON.stringify(shapeOrdered)),
287
+ };
288
+ }
289
+
290
+ function fingerprintMessages(messages) {
291
+ const arr = Array.isArray(messages) ? messages : [];
292
+ let cc = 0;
293
+ const knownSet = new Set();
294
+ let unknownPresent = false;
295
+
296
+ for (const msg of arr) {
297
+ if (!msg || typeof msg !== "object") continue;
298
+ if (Array.isArray(msg.content)) {
299
+ for (const block of msg.content) {
300
+ if (block && typeof block === "object" && block.cache_control) cc++;
301
+ const text = blockText(block);
302
+ for (const idx of matchKnownReminderPatterns(text)) knownSet.add(idx);
303
+ if (!unknownPresent && hasUnknownReminderPattern(text)) unknownPresent = true;
304
+ }
305
+ } else if (typeof msg.content === "string") {
306
+ for (const idx of matchKnownReminderPatterns(msg.content)) knownSet.add(idx);
307
+ if (!unknownPresent && hasUnknownReminderPattern(msg.content)) unknownPresent = true;
308
+ }
309
+ }
310
+
311
+ const knownSorted = [...knownSet].sort((a, b) => a - b);
312
+ return {
313
+ count: arr.length,
314
+ first_role: arr[0]?.role || null,
315
+ cache_control_count_in_messages: cc,
316
+ known_reminder_pattern_set_hash: sha16(knownSorted.join(",")),
317
+ known_reminder_pattern_count: knownSorted.length,
318
+ unknown_reminder_pattern_present: unknownPresent,
319
+ };
320
+ }
321
+
322
+ function fingerprintRequestExtras(body) {
323
+ return {
324
+ has_thinking: !!body?.thinking,
325
+ has_metadata: !!body?.metadata,
326
+ stream: body?.stream === true,
327
+ max_tokens_bucket: bucketMaxTokens(body?.max_tokens),
328
+ };
329
+ }
330
+
331
+ // Compute a structural fingerprint. `headers` is the request headers map
332
+ // (case-insensitive lookup is internal); pass `{}` if not available.
333
+ export function computeFingerprint(body, headers = {}) {
334
+ const safeBody = body && typeof body === "object" ? body : {};
335
+ const beta = extractBetaHeaders(headers, safeBody);
336
+ return {
337
+ version: 1,
338
+ namespace: {
339
+ model: typeof safeBody.model === "string" ? safeBody.model : "",
340
+ beta_headers_sorted_hash: sha16([...beta].sort().join(",")),
341
+ beta_headers_count: beta.length,
342
+ },
343
+ system: fingerprintSystem(safeBody.system),
344
+ tools: fingerprintTools(safeBody.tools),
345
+ messages: fingerprintMessages(safeBody.messages),
346
+ request_extras: fingerprintRequestExtras(safeBody),
347
+ };
348
+ }
349
+
350
+ // Diff two fingerprints. Returns array of { path, from, to } entries. Equality
351
+ // is structural (deep). Arrays are compared by JSON stringification.
352
+ export function diffFingerprints(prev, current) {
353
+ const diff = [];
354
+ if (!prev || !current) return diff;
355
+ walk("", prev, current, diff);
356
+ return diff;
357
+ }
358
+
359
+ function walk(prefix, a, b, out) {
360
+ if (a === b) return;
361
+ if (typeof a !== typeof b) {
362
+ out.push({ path: prefix, from: a, to: b });
363
+ return;
364
+ }
365
+ if (Array.isArray(a) || Array.isArray(b) || typeof a !== "object" || a === null || b === null) {
366
+ if (JSON.stringify(a) !== JSON.stringify(b)) {
367
+ out.push({ path: prefix, from: a, to: b });
368
+ }
369
+ return;
370
+ }
371
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
372
+ for (const k of keys) {
373
+ const subPath = prefix ? `${prefix}.${k}` : k;
374
+ walk(subPath, a[k], b[k], out);
375
+ }
376
+ }
377
+
378
+ // --- Persistence ---
379
+
380
+ async function loadBaseline(fs = DEFAULT_FS, dir = getOutputDir()) {
381
+ const path = getBaselinePath(dir);
382
+ try {
383
+ const raw = await fs.readFile(path, "utf8");
384
+ const parsed = JSON.parse(raw);
385
+ if (parsed && parsed.namespaces && typeof parsed.namespaces === "object") {
386
+ _namespaceMap = new Map(Object.entries(parsed.namespaces));
387
+ _baselineLoadedFrom = path;
388
+ return _namespaceMap;
389
+ }
390
+ } catch (err) {
391
+ debug(`baseline load failed (${path}): ${err?.message ?? err}`);
392
+ }
393
+ _namespaceMap = new Map();
394
+ return _namespaceMap;
395
+ }
396
+
397
+ async function persistBaseline(fs = DEFAULT_FS, dir = getOutputDir()) {
398
+ const finalPath = getBaselinePath(dir);
399
+ const tmpSuffix = `${process.pid}.${Date.now()}.${randomBytes(2).toString("hex")}`;
400
+ const tmpPath = `${finalPath}.tmp.${tmpSuffix}`;
401
+ const doc = {
402
+ version: 1,
403
+ namespaces: Object.fromEntries(_namespaceMap),
404
+ };
405
+ try {
406
+ await fs.mkdir(dir, { recursive: true });
407
+ await fs.writeFile(tmpPath, JSON.stringify(doc));
408
+ await fs.rename(tmpPath, finalPath);
409
+ } finally {
410
+ try { await fs.unlink(tmpPath); } catch {}
411
+ }
412
+ }
413
+
414
+ async function appendEvent(record, fs = DEFAULT_FS, dir = getOutputDir()) {
415
+ const path = getJsonlPath(dir);
416
+ await fs.mkdir(dir, { recursive: true });
417
+ await fs.appendFile(path, JSON.stringify(record) + "\n");
418
+ }
419
+
420
+ // Test seam: bypass module-scope state and operate on a caller-supplied map.
421
+ export async function processRequestForTest(body, { dir, map = _namespaceMap, fs = DEFAULT_FS, headers = {} } = {}) {
422
+ return _processRequest(body, headers, { dir, map, fs });
423
+ }
424
+
425
+ async function _processRequest(body, headers, { dir, map, fs }) {
426
+ const fingerprint = computeFingerprint(body, headers);
427
+ const nsKey = namespaceKey(fingerprint.namespace.model, extractBetaHeaders(headers, body));
428
+ const ts = new Date().toISOString();
429
+
430
+ const existing = map.get(nsKey);
431
+ if (!existing) {
432
+ const entry = {
433
+ namespace: fingerprint.namespace,
434
+ fingerprint,
435
+ established_at: ts,
436
+ last_updated_at: ts,
437
+ update_count: 0,
438
+ };
439
+ map.set(nsKey, entry);
440
+ await appendEvent(
441
+ { ts, event: "baseline_established", namespace: fingerprint.namespace, fingerprint },
442
+ fs,
443
+ dir,
444
+ );
445
+ return { event: "baseline_established", nsKey };
446
+ }
447
+
448
+ if (JSON.stringify(existing.fingerprint) === JSON.stringify(fingerprint)) {
449
+ return { event: "noop", nsKey };
450
+ }
451
+
452
+ const diff = diffFingerprints(existing.fingerprint, fingerprint);
453
+ const previous = existing.fingerprint;
454
+ const updated = {
455
+ namespace: fingerprint.namespace,
456
+ fingerprint,
457
+ established_at: existing.established_at,
458
+ last_updated_at: ts,
459
+ update_count: (existing.update_count || 0) + 1,
460
+ };
461
+ map.set(nsKey, updated);
462
+
463
+ await appendEvent(
464
+ {
465
+ ts,
466
+ event: "structural_change",
467
+ namespace: fingerprint.namespace,
468
+ diff,
469
+ previous,
470
+ current: fingerprint,
471
+ },
472
+ fs,
473
+ dir,
474
+ );
475
+
476
+ return { event: "structural_change", nsKey, diff };
477
+ }
478
+
479
+ function formatStderrLine({ ts, namespace, diff }) {
480
+ const head = `[upstream-change] ${ts} model=${namespace.model || "?"} beta=${namespace.beta_headers_count}`;
481
+ const summary = diff
482
+ .slice(0, 6)
483
+ .map((d) => `${d.path}: ${JSON.stringify(d.from)} → ${JSON.stringify(d.to)}`)
484
+ .join("; ");
485
+ const more = diff.length > 6 ? ` (+${diff.length - 6} more)` : "";
486
+ return `${head} :: ${summary}${more}`;
487
+ }
488
+
489
+ // --- Extension contract ---
490
+
491
+ export default {
492
+ name: "upstream-change-detection",
493
+ description:
494
+ "Detect structural changes in CC-originated /v1/messages requests via per-namespace fingerprint",
495
+ enabled: true,
496
+ order: 50,
497
+
498
+ async onRequest(ctx) {
499
+ if (!isEnabled()) return;
500
+ if (!ctx || !ctx.body) return;
501
+
502
+ try {
503
+ const dir = getOutputDir();
504
+ const fs = DEFAULT_FS;
505
+ // Lazy-load baseline on first call after module load.
506
+ if (_baselineLoadedFrom === null) {
507
+ await loadBaseline(fs, dir);
508
+ _baselineLoadedFrom = getBaselinePath(dir);
509
+ }
510
+ const result = await _processRequest(ctx.body, ctx.headers || {}, { dir, map: _namespaceMap, fs });
511
+ // Persist baseline whenever it changed.
512
+ if (result.event === "baseline_established" || result.event === "structural_change") {
513
+ await persistBaseline(fs, dir);
514
+ }
515
+ if (result.event === "structural_change" && !isQuiet()) {
516
+ const ts = new Date().toISOString();
517
+ const namespace = _namespaceMap.get(result.nsKey)?.namespace;
518
+ process.stderr.write(formatStderrLine({ ts, namespace: namespace || {}, diff: result.diff }) + "\n");
519
+ }
520
+ } catch (err) {
521
+ debug(`onRequest unexpected: ${err?.message ?? err}`);
522
+ }
523
+ },
524
+ };
525
+
526
+ // Expose internals for testing.
527
+ export {
528
+ loadBaseline,
529
+ persistBaseline,
530
+ appendEvent,
531
+ formatStderrLine,
532
+ _namespaceMap as __testNamespaceMap,
533
+ };