claude-code-cache-fix 3.6.1 → 3.7.0

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.
package/README.md CHANGED
@@ -208,6 +208,63 @@ Options (all optional; all fall back to the same env vars used by the CLI):
208
208
 
209
209
  *The embeddable factory was contributed by [@bilby91](https://github.com/bilby91) at [Crunchloop DAP](https://dap.crunchloop.ai) — see [PR #123](https://github.com/cnighswonger/claude-code-cache-fix/pull/123).*
210
210
 
211
+ ## What this proxy defends against
212
+
213
+ **Cache-economics regressions.** The original purpose of cache-fix is to absorb the cache-handling behaviors in Claude Code that cost users real money and quota — TTL downgrades, cache-breaking header churn, identity-latching issues, and the rest of the regression catalog documented across our issue history. The proxy sits between CC and the Anthropic API, normalizes the request and response stream, and emits enough observability (via statusline integration and the quota-status files) that users can see what their session is actually doing. This is the load-bearing feature for almost every user today.
214
+
215
+ **Bootstrap-channel observability.** Claude Code v2.1.150 introduced a prompt-section consumer that fetches a server-supplied string from `/api/claude_cli/bootstrap` and merges it into the agent's behavioral-instructions prompt path. We filed this behavior with Anthropic's security team in May 2026; Anthropic closed the report as *Informative*, treating TLS as the transport-integrity boundary and declining to add application-layer authenticity checks. Cache-fix v3.7.0 adds explicit handling for this path. Default mode is `audit` — bootstrap responses proxy through to CC and are logged to `~/.claude/cache-fix-bootstrap-log.jsonl` so users can inspect them locally. To opt into block mode instead, set `CACHE_FIX_BOOTSTRAP_MODE=block` in the proxy environment; block mode short-circuits the upstream call and returns a 200 with an empty JSON body, dropping bootstrap content before it reaches CC. (Note: cache-fix v3.6.2 and earlier returned 404 for this path because the proxy router did not include it — the practical effect was that bootstrap content was not previously reaching CC for cache-fix users. v3.7.0's default `audit` changes that behavior; explicit `CACHE_FIX_BOOTSTRAP_MODE=block` preserves it.) The full disclosure record, including Anthropic's verbatim close text, is in [`docs/disclosure/heron-brook-2026-05.md`](docs/disclosure/heron-brook-2026-05.md).
216
+
217
+ **Reference material:**
218
+ - [`docs/disclosure/heron-brook-2026-05.md`](docs/disclosure/heron-brook-2026-05.md) — full disclosure record
219
+ - [`CHANGELOG.md`](CHANGELOG.md#370---2026-05-26) — v3.7.0 release entry (includes the behavior-change note for prior users)
220
+ - [`cnighswonger/heron-brook-poc`](https://github.com/cnighswonger/heron-brook-poc) — reproducer for the bootstrap-channel behavior
221
+
222
+ ## Recommended CC operational config
223
+
224
+ The proxy fixes what it can fix at the request layer. A handful of CC client-side env vars and `~/.claude/settings.json` knobs solve adjacent problems the proxy can't reach — silent model swaps on CC update, ambiguous model fallback, schema-strip side effects. Surfacing these here as a recommendation; users decide their own config.
225
+
226
+ These findings come from [@fgrosswig](https://github.com/fgrosswig)'s binary analysis of CC v2.1.91. Methodology is public PowerShell + ASCII string extraction; he shared the resulting punch list privately as a courtesy.
227
+
228
+ ### Suggested `~/.claude/settings.json` env block
229
+
230
+ The model IDs below are illustrative — replace with your preferred main and small-fast models. The point is that pinning *something* explicit beats relying on CC's defaults.
231
+
232
+ ```json
233
+ {
234
+ "env": {
235
+ "CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP": "1",
236
+ "ANTHROPIC_MODEL": "claude-opus-4-7",
237
+ "ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001"
238
+ }
239
+ }
240
+ ```
241
+
242
+ **`CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1`** — single most impactful flag. CC has a legacy code path that silently remaps your pinned model to a different one after certain version updates. Setting this to `1` disables the remap; the model you pin is the model you get. (If you don't pin, CC's defaults apply as usual.)
243
+
244
+ **`ANTHROPIC_MODEL`** — pins the primary model. Keeping this explicit means the cache prefix hash stays stable across CC version bumps that would otherwise swap your default. Adjust to whichever model you actually want.
245
+
246
+ **`ANTHROPIC_SMALL_FAST_MODEL`** — pins the side-channel "fast" model CC uses for short auxiliary calls (e.g., title generation, classification). Without an explicit pin, this can silently fall back to a different family on update.
247
+
248
+ ### `autoCompactWindow=1000000` caveat
249
+
250
+ If you've seen the `autoCompactWindow: 1000000` setting recommended elsewhere: it only takes effect when the active model qualifies for 1M-context (currently `claude-sonnet-4-6` or `claude-opus-4-6` with the appropriate beta header). Without those preconditions it caps at the hardcoded 200K regardless of what you set.
251
+
252
+ ### `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` schema-strip side effect
253
+
254
+ If you set this flag, CC strips any tool field outside `["name", "description", "input_schema", "cache_control"]` from outgoing requests. Custom tools relying on `defer_loading` or `eager_input_streaming` will silently lose those fields and behave differently. Worth knowing before turning the flag on.
255
+
256
+ ## Known CC behaviors that affect cache cost
257
+
258
+ These aren't bugs cache-fix patches — they're upstream CC behaviors users should be aware of when sizing their session cost.
259
+
260
+ ### Diagnostic slash commands inflate conversation history ([#49335](https://github.com/anthropics/claude-code/issues/49335))
261
+
262
+ Running `/context`, `/release-notes` (and likely other state-inspection commands) appends the diagnostic output to conversation history rather than rendering terminal-only. Subsequent turns replay the inflated payload via prompt cache, compounding token cost on a state-inspection action that should be free. Empirically measured at +3,480 `cache_creation_input_tokens` for a single `/context` invocation on v2.1.148; another user reports ~5K on a separate session. `/release-notes` is worse — defaults to dumping the full changelog.
263
+
264
+ Worse for diagnosis: the inflated payload that bills against your cache isn't written to the local JSONL transcript, so you can't audit the cost source locally — you can only infer it from `cache_creation_input_tokens` jumps in response usage metadata. (Proxy-mode users can inspect the deltas in `~/.claude/quota-status/` files, which the proxy writes directly from response headers.)
265
+
266
+ **Workaround until upstream fix:** use these commands sparingly in long sessions. If you need them frequently in a session, consider `/compact` after a diagnostic run to reset the bleed.
267
+
211
268
  ## Quick Start: Preload (CC v2.1.112 and earlier)
212
269
 
213
270
  If you're on a Node.js-based CC version (v2.1.112 or earlier), the preload interceptor works without a proxy:
@@ -246,7 +303,7 @@ For manual VS Code wrapper setup (without the VSIX), see [docs/preload-setup.md]
246
303
 
247
304
  **What it does NOT do:** No network calls from the proxy or interceptor. All telemetry is written to local files under `~/.claude/`. No data leaves your machine.
248
305
 
249
- **Supply chain:** Proxy mode: 7 small extension modules in `proxy/extensions/` (each under 200 lines). Preload mode: single unminified file (`preload.mjs`, ~1,700 lines). One dev dependency (`zod` for schema validation in tests only). Review before installing. npm provenance links each published version to its source commit.
306
+ **Supply chain:** Proxy mode: 7 small extension modules in `proxy/extensions/` (each under 200 lines). Preload mode: single unminified file (`preload.mjs`, ~1,700 lines). One dev dependency (`zod` for schema validation in tests only). Review before installing. Published builds carry npm's default registry signatures; sigstore provenance attestation is not currently published tracked as a follow-up.
250
307
 
251
308
  **Independent audit:** [Assessed as "LEGITIMATE TOOL"](https://github.com/anthropics/claude-code/issues/38335#issuecomment-4244413605) by @TheAuditorTool (2026-04-14).
252
309
 
@@ -323,13 +380,23 @@ The interceptor can only *help* or *do nothing*. It cannot make things worse.
323
380
 
324
381
  Both modes write quota state on every API call. Proxy mode (v3.5.0+) splits into `~/.claude/quota-status/account.json` (account-global fields: Q5h/Q7d, status, overage) plus `~/.claude/quota-status/sessions/<id>.json` (per-session cache fields: TTL tier, hit rate). Preload mode keeps the legacy `~/.claude/quota-status.json` (single-session by construction). The included `tools/quota-statusline.sh` script displays a live status line showing:
325
382
 
326
- - **Q5h %** with burn rate (%/min)
327
- - **Q7d %** with burn rate (%/hr)
383
+ - **Q5h** quota bar `[███░┃░░░░░]` + percent + `(exhaust X, reset Y)`. Filled cells are consumed quota; the heavy-vertical tick is wall-clock elapsed position in the window. Tick to the right of the fill = under pace; tick inside the fill = burning faster than time (over pace). `exhaust` is the projected time-to-100% at the current burn rate; `reset` is the wall-clock time until the window rolls over. When `exhaust < reset`, you will hit 100% before the window resets — back off.
384
+ - **Q7d** same shape with day-scale durations (e.g. `(exhaust 3d13h, reset 3d0h)`). Below a day, the suffix auto-switches to `h/m` format (e.g. `(exhaust 1h41m, reset 0h30m)`).
328
385
  - **TTL tier** — `TTL:1h` when healthy, **`TTL:5m` in red when the server has downgraded you** (typically at Q5h ≥ 100%)
329
386
  - **PEAK** in yellow during weekday peak hours (13:00–19:00 UTC)
330
387
  - **Cache hit rate %**
331
388
  - **OVERAGE** flag when active
332
389
 
390
+ Example line (mid-window, healthy state):
391
+
392
+ ```
393
+ Q5h [███░┃░░░░░] 30% (exhaust 4h40m, reset 3h00m) | Q7d [█████┃░░░░] 53% (exhaust 3d13h, reset 3d0h) | TTL:1h 98.3%
394
+ ```
395
+
396
+ The `(exhaust …, reset …)` suffix is dropped piecewise when projection isn't meaningful: at 0% (fresh window) and 100% (already exhausted) only `reset` is shown; in the first 5 minutes after window start the burn rate isn't stable enough to project (a single early call dominates the rate), so `exhaust` is held back until then on both Q5h and Q7d; a stale `resets_at` (the server-reported value sits in the past, before the next API call refreshes it) drops both.
397
+
398
+ The bar uses Unicode block characters (`█┃░`) — most modern terminals render these correctly. If your terminal substitutes boxes or replacement glyphs, configure a Unicode-capable font (any DejaVu, Fira, Iosevka, JetBrains Mono, etc.).
399
+
333
400
  ### Setup
334
401
 
335
402
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-cache-fix",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
4
4
  "description": "Cache optimization proxy and interceptor for Claude Code. Fixes prompt cache bugs, stabilizes prefix, reduces quota burn.",
5
5
  "type": "module",
6
6
  "exports": {
package/proxy/config.mjs CHANGED
@@ -10,14 +10,15 @@ function envInt(name, fallback) {
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
 
13
- // Existing fields are read once at module init (preserving prior behavior).
14
- // New corp-proxy/CA fields are getters so they reflect live env — important
15
- // for test isolation (see test/proxy-upstream-corp-proxy.test.mjs) and for
16
- // callers that legitimately want to flip env at runtime.
13
+ // Most fields are read once at module init (preserving prior behavior).
14
+ // Corp-proxy/CA fields and `upstream` are getters so they reflect live env —
15
+ // important for test isolation (see test/proxy-upstream-corp-proxy.test.mjs
16
+ // and test/proxy-server-bootstrap.test.mjs) and for callers that legitimately
17
+ // want to flip env at runtime.
17
18
  const config = {
18
19
  port: envInt("CACHE_FIX_PROXY_PORT", 9801),
19
20
  bind: process.env.CACHE_FIX_PROXY_BIND || "127.0.0.1",
20
- upstream: process.env.CACHE_FIX_PROXY_UPSTREAM || "https://api.anthropic.com",
21
+ get upstream() { return process.env.CACHE_FIX_PROXY_UPSTREAM || "https://api.anthropic.com"; },
21
22
  timeout: envInt("CACHE_FIX_PROXY_TIMEOUT", 600_000),
22
23
  extensionsDir: process.env.CACHE_FIX_EXTENSIONS_DIR || join(__dirname, "extensions"),
23
24
  extensionsConfig: process.env.CACHE_FIX_EXTENSIONS_CONFIG || join(__dirname, "extensions.json"),
@@ -0,0 +1,161 @@
1
+ import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
6
+ const SCHEMA_VERSION = 1;
7
+ const EXTENSION_VERSION = "v3.6.3";
8
+
9
+ function logPath() {
10
+ return process.env.CACHE_FIX_BOOTSTRAP_LOG_PATH || join(homedir(), ".claude", "cache-fix-bootstrap-log.jsonl");
11
+ }
12
+
13
+ // Single-tier rotation by design: any previous .1 gets overwritten. The audit
14
+ // log is a forward-looking signal feed, not an archival record — if v3.7.0
15
+ // anomaly detection wants long-term retention it can subscribe to the stream
16
+ // directly. Keeping rotation to one tier bounds disk usage at 2×5MB = 10MB.
17
+ function rotateIfNeeded(path) {
18
+ let size = 0;
19
+ try { size = statSync(path).size; } catch { return; }
20
+ if (size < LOG_ROTATE_BYTES) return;
21
+ try { renameSync(path, `${path}.1`); } catch {}
22
+ }
23
+
24
+ // Single-writer invariant: cache-fix-proxy is one Node process per host, and
25
+ // every bootstrap response that needs logging flows through this extension.
26
+ // All writes are appendFileSync from a single event loop, so no inter-process
27
+ // or inter-extension locking is required. If that invariant ever changes
28
+ // (e.g. multi-process proxy, sibling extension writing to the same file),
29
+ // this writer needs to gain a lock.
30
+ function appendRecord(record) {
31
+ const path = logPath();
32
+ try {
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ rotateIfNeeded(path);
35
+ appendFileSync(path, JSON.stringify(record) + "\n");
36
+ } catch (err) {
37
+ process.stderr.write(`[bootstrap-defense] log write failed: ${err.message}\n`);
38
+ }
39
+ }
40
+
41
+ function modeFromEnv(fallback) {
42
+ const raw = process.env.CACHE_FIX_BOOTSTRAP_MODE;
43
+ if (raw === "audit" || raw === "block") return raw;
44
+ return fallback;
45
+ }
46
+
47
+ // PII discipline: the audit log MUST NOT include client headers (Authorization,
48
+ // x-api-key, cookies, etc.) or request/response bodies. Callers pass only the
49
+ // extracted scalar fields below — the full headers object is never threaded
50
+ // through this function, so a future maintainer can't accidentally widen the
51
+ // log surface by spreading the parameter object.
52
+ //
53
+ // v3.7.0 extension fields are emitted as null/defaulted so log readers don't
54
+ // break when v3.7.0 starts populating baseline_hash, anomaly_status, etc.
55
+ function recordShape({ phase, mode, status, body_bytes, upstream_host, request_id, error }) {
56
+ return {
57
+ schema_version: SCHEMA_VERSION,
58
+ extension_version: EXTENSION_VERSION,
59
+ timestamp: new Date().toISOString(),
60
+ phase,
61
+ mode,
62
+ status: status ?? null,
63
+ body_bytes: body_bytes ?? null,
64
+ upstream_host: upstream_host ?? null,
65
+ request_id: request_id ?? null,
66
+ baseline_hash: null,
67
+ anomaly_status: null,
68
+ error: error ?? null,
69
+ };
70
+ }
71
+
72
+ // Extract audit-record scalars from the request context. Meta wins over
73
+ // headers so that the canonical request-side fields stashed by the server
74
+ // (handleBootstrap stashes `_bootstrapUpstreamHost` and `_bootstrapRequestId`
75
+ // before the upstream call) are authoritative on the onResponse path, where
76
+ // ctx.headers carries the upstream RESPONSE headers (no Host, no request-id).
77
+ // On the onRequest block path, meta hasn't been populated yet — the helper
78
+ // falls back to ctx.headers (the client request headers) so request_id is
79
+ // still captured. Upstream_host falls back to null on the request path
80
+ // because the resolved upstream isn't known until handleBootstrap runs.
81
+ //
82
+ // Signature is scalar-only by design: callers MUST NOT spread a headers
83
+ // object into recordShape, so a future maintainer can't accidentally widen
84
+ // the log surface to carry Authorization / x-api-key / cookies.
85
+ function extractAuditFields(meta, headers) {
86
+ const requestId =
87
+ meta?._bootstrapRequestId ??
88
+ headers?.["request-id"] ??
89
+ headers?.["x-request-id"] ??
90
+ null;
91
+ return {
92
+ upstream_host: meta?._bootstrapUpstreamHost ?? null,
93
+ request_id: requestId,
94
+ };
95
+ }
96
+
97
+ export default {
98
+ name: "bootstrap-defense",
99
+ description:
100
+ "Audit (default) or block /api/claude_cli/bootstrap traffic. Audit mode proxies the response through to CC and logs metadata to ~/.claude/cache-fix-bootstrap-log.jsonl. Block mode returns empty 200, preserving v3.6.2's de-facto behavior in explicit form.",
101
+ // Pipeline route scoping (pipeline.mjs:appliesToRoute) gates this extension
102
+ // to the bootstrap path. The internal route guard below is belt-and-suspenders
103
+ // in case a caller invokes the hook directly.
104
+ routes: ["bootstrap"],
105
+ order: 45,
106
+
107
+ async onRequest(ctx) {
108
+ if (ctx.meta?.route !== "bootstrap") return;
109
+
110
+ const mode = modeFromEnv("audit");
111
+ ctx.meta._bootstrapDefenseMode = mode;
112
+
113
+ if (mode === "block") {
114
+ appendRecord(
115
+ recordShape({
116
+ phase: "request_blocked",
117
+ mode,
118
+ ...extractAuditFields(ctx.meta, ctx.headers),
119
+ }),
120
+ );
121
+ return {
122
+ skip: true,
123
+ status: 200,
124
+ headers: { "content-type": "application/json" },
125
+ body: {},
126
+ };
127
+ }
128
+ },
129
+
130
+ async onResponse(ctx) {
131
+ if (ctx.meta?.route !== "bootstrap") return;
132
+ const mode = ctx.meta._bootstrapDefenseMode || "audit";
133
+ if (mode !== "audit") return;
134
+
135
+ // Prefer raw on-wire byte count from server.mjs (accurate even for
136
+ // non-JSON / unparseable upstream responses). The fallback stringify path
137
+ // exists only for direct unit-test invocation of onResponse without going
138
+ // through handleBootstrap — production traffic always sets the meta field.
139
+ let bodyBytes = ctx.meta?._bootstrapBodyBytes ?? null;
140
+ if (bodyBytes === null) {
141
+ try { bodyBytes = Buffer.byteLength(JSON.stringify(ctx.body ?? {})); } catch {}
142
+ }
143
+
144
+ const upstreamError = ctx.meta?._bootstrapUpstreamError ?? null;
145
+ // Request-side context (request_id, upstream_host) is captured at
146
+ // forward time and stashed on ctx.meta — we read from meta here rather
147
+ // than ctx.headers (which on onResponse contains the upstream RESPONSE
148
+ // headers, not the client request).
149
+ appendRecord(
150
+ recordShape({
151
+ phase: upstreamError ? "upstream_error_audited" : "response_audited",
152
+ mode,
153
+ status: ctx.status,
154
+ body_bytes: bodyBytes,
155
+ upstream_host: ctx.meta?._bootstrapUpstreamHost ?? null,
156
+ request_id: ctx.meta?._bootstrapRequestId ?? null,
157
+ error: upstreamError,
158
+ }),
159
+ );
160
+ },
161
+ };
@@ -1,4 +1,5 @@
1
1
  {
2
+ "bootstrap-defense": { "enabled": true, "order": 45 },
2
3
  "ttl-tier-detect": { "enabled": true, "order": 75 },
3
4
  "fingerprint-strip": { "enabled": true, "order": 100 },
4
5
  "image-strip": { "enabled": true, "order": 150 },
@@ -46,10 +46,27 @@ export function snapshotRegistry() {
46
46
  return [...registry];
47
47
  }
48
48
 
49
+ // Route scoping: extensions default to messages-only so that adding a new
50
+ // route (e.g. /api/claude_cli/bootstrap) doesn't drag every existing
51
+ // message-mutating extension onto it — most throw on a null body because
52
+ // they were never designed for non-messages traffic. Cross-cutting
53
+ // extensions (cache-telemetry, usage-log, …) opt into additional routes
54
+ // by declaring an explicit `routes` array on their default export.
55
+ //
56
+ // If ctx.meta.route is undefined we skip filtering entirely — preserves
57
+ // back-compat for callers that don't tag routes (legacy tests, embedders).
58
+ function appliesToRoute(ext, route) {
59
+ if (!route) return true;
60
+ const routes = ext.routes || ["messages"];
61
+ return routes.includes(route);
62
+ }
63
+
49
64
  export async function runOnRequest(ctx, snapshot) {
50
65
  const exts = snapshot || registry;
66
+ const route = ctx.meta?.route;
51
67
  for (const ext of exts) {
52
68
  if (!ext.onRequest) continue;
69
+ if (!appliesToRoute(ext, route)) continue;
53
70
  try {
54
71
  const result = await ext.onRequest(ctx);
55
72
  if (result && result.skip) return result;
@@ -62,8 +79,10 @@ export async function runOnRequest(ctx, snapshot) {
62
79
 
63
80
  export async function runOnResponseStart(ctx, snapshot) {
64
81
  const exts = snapshot || registry;
82
+ const route = ctx.meta?.route;
65
83
  for (const ext of exts) {
66
84
  if (!ext.onResponseStart) continue;
85
+ if (!appliesToRoute(ext, route)) continue;
67
86
  try {
68
87
  await ext.onResponseStart(ctx);
69
88
  } catch (err) {
@@ -74,8 +93,10 @@ export async function runOnResponseStart(ctx, snapshot) {
74
93
 
75
94
  export async function runOnStreamEvent(ctx, snapshot) {
76
95
  const exts = snapshot || registry;
96
+ const route = ctx.meta?.route;
77
97
  for (const ext of exts) {
78
98
  if (!ext.onStreamEvent) continue;
99
+ if (!appliesToRoute(ext, route)) continue;
79
100
  try {
80
101
  await ext.onStreamEvent(ctx);
81
102
  } catch (err) {
@@ -86,8 +107,10 @@ export async function runOnStreamEvent(ctx, snapshot) {
86
107
 
87
108
  export async function runOnResponse(ctx, snapshot) {
88
109
  const exts = snapshot || registry;
110
+ const route = ctx.meta?.route;
89
111
  for (const ext of exts) {
90
112
  if (!ext.onResponse) continue;
113
+ if (!appliesToRoute(ext, route)) continue;
91
114
  try {
92
115
  await ext.onResponse(ctx);
93
116
  } catch (err) {
package/proxy/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import http from "node:http";
2
- import { pathToFileURL } from "node:url";
2
+ import { pathToFileURL, URL } from "node:url";
3
3
  import config from "./config.mjs";
4
4
  import { forwardRequest } from "./upstream.mjs";
5
5
  import { streamResponse, createTelemetryRecord } from "./stream.mjs";
@@ -15,16 +15,15 @@ function collectBody(req) {
15
15
  });
16
16
  }
17
17
 
18
- async function handleMessages(clientReq, clientRes) {
19
- const abortController = new AbortController();
20
- const extSnapshot = snapshotRegistry();
21
-
22
- clientReq.on("close", () => {
23
- if (!clientRes.writableEnded) {
24
- abortController.abort();
25
- }
26
- });
27
-
18
+ // Run the pre-forward pipeline stages (collect body, parse, runOnRequest)
19
+ // and either short-circuit with an extension-supplied response (block mode,
20
+ // auth-failure synth, etc.) or return the inputs the caller needs to drive
21
+ // forwarding and the post-response stages.
22
+ //
23
+ // `routeName` is stashed on ctx.meta.route so route-aware extensions
24
+ // (bootstrap-defense, env-flag-detector) can discriminate without each
25
+ // route needing its own pipeline hook.
26
+ async function preForward(clientReq, clientRes, _abortController, extSnapshot, routeName, baseMeta = {}) {
28
27
  const rawBody = await collectBody(clientReq);
29
28
 
30
29
  let parsed;
@@ -35,23 +34,49 @@ async function handleMessages(clientReq, clientRes) {
35
34
  }
36
35
 
37
36
  let forwardBody = rawBody;
38
- const meta = {};
37
+ // baseMeta lets routes pre-populate audit scalars (e.g. resolved upstream
38
+ // hostname, request_id) so they're available to onRequest hooks BEFORE the
39
+ // upstream call — block-mode short-circuits in onRequest, so a post-call
40
+ // stash would miss the block-path audit record.
41
+ const meta = { ...baseMeta, route: routeName };
39
42
 
40
- if (parsed && extSnapshot.length > 0) {
43
+ if (extSnapshot.length > 0) {
41
44
  const reqCtx = { body: parsed, headers: { ...clientReq.headers }, meta };
42
45
  const skipResult = await runOnRequest(reqCtx, extSnapshot);
43
46
 
44
47
  if (skipResult && skipResult.skip) {
45
48
  const status = skipResult.status || 400;
46
- const body = skipResult.body || { error: "blocked_by_extension" };
47
- clientRes.writeHead(status, { "content-type": "application/json" });
48
- clientRes.end(JSON.stringify(body));
49
- return;
49
+ const headers = skipResult.headers || { "content-type": "application/json" };
50
+ const body = skipResult.body ?? { error: "blocked_by_extension" };
51
+ clientRes.writeHead(status, headers);
52
+ clientRes.end(typeof body === "string" ? body : JSON.stringify(body));
53
+ return { handled: true };
50
54
  }
51
55
 
52
- forwardBody = Buffer.from(JSON.stringify(reqCtx.body));
56
+ if (parsed) {
57
+ forwardBody = Buffer.from(JSON.stringify(reqCtx.body));
58
+ }
53
59
  }
54
60
 
61
+ return { handled: false, parsed, forwardBody, meta };
62
+ }
63
+
64
+ async function handleMessages(clientReq, clientRes) {
65
+ const abortController = new AbortController();
66
+ const extSnapshot = snapshotRegistry();
67
+
68
+ // Streaming SSE: if the client gives up mid-stream, free the upstream.
69
+ // Bootstrap (handleBootstrap) doesn't install this because its response is
70
+ // a single non-SSE JSON payload — aborting on clientReq close prematurely
71
+ // would race the response write on fast-failure paths (e.g. ECONNREFUSED).
72
+ clientReq.on("close", () => {
73
+ if (!clientRes.writableEnded) abortController.abort();
74
+ });
75
+
76
+ const pre = await preForward(clientReq, clientRes, abortController, extSnapshot, "messages");
77
+ if (pre.handled) return;
78
+ const { parsed, forwardBody, meta } = pre;
79
+
55
80
  const requestedModel = parsed?.model || null;
56
81
 
57
82
  let upstreamRes, responseHeaders, statusCode, upstreamConnectionId;
@@ -129,6 +154,89 @@ async function handleMessages(clientReq, clientRes) {
129
154
  }
130
155
  }
131
156
 
157
+ // Route handler for `/api/claude_cli/bootstrap` (CC v2.1.150+ system-prompt
158
+ // injection channel). Same pipeline shape as handleMessages but without
159
+ // the streaming branch — bootstrap is a single non-SSE JSON response.
160
+ // The bootstrap-defense extension binds to onRequest/onResponse with
161
+ // `ctx.meta.route === "bootstrap"` to drive audit/block behavior.
162
+ async function handleBootstrap(clientReq, clientRes) {
163
+ const abortController = new AbortController();
164
+ const extSnapshot = snapshotRegistry();
165
+
166
+ // Resolve audit-record scalars BEFORE preForward so they're visible to
167
+ // onRequest hooks (block-mode short-circuits there). HTTP responses don't
168
+ // carry a Host header, so the audit log derives upstream_host from
169
+ // config.upstream — the actual destination requests were forwarded to.
170
+ let upstreamHost = null;
171
+ try {
172
+ upstreamHost = new URL(config.upstream).hostname;
173
+ } catch {}
174
+ const baseMeta = {
175
+ _bootstrapUpstreamHost: upstreamHost,
176
+ _bootstrapRequestId:
177
+ clientReq.headers["request-id"] ?? clientReq.headers["x-request-id"] ?? null,
178
+ };
179
+
180
+ const pre = await preForward(clientReq, clientRes, abortController, extSnapshot, "bootstrap", baseMeta);
181
+ if (pre.handled) return;
182
+ const { forwardBody, meta } = pre;
183
+
184
+ let upstreamRes, responseHeaders, statusCode, upstreamConnectionId;
185
+
186
+ try {
187
+ ({ upstreamRes, responseHeaders, statusCode, upstreamConnectionId } = await forwardRequest(
188
+ clientReq,
189
+ forwardBody,
190
+ abortController.signal,
191
+ ));
192
+ } catch (err) {
193
+ // Anomaly audit: bootstrap upstream errors are exactly the kind of event
194
+ // an attacker triggering DNS shenanigans or an outage would produce, so
195
+ // route them through the extension pipeline before responding 502.
196
+ if (extSnapshot.length > 0) {
197
+ meta._bootstrapUpstreamError = err.message;
198
+ meta._bootstrapBodyBytes = 0;
199
+ const errCtx = { status: 502, headers: {}, body: null, meta };
200
+ await runOnResponse(errCtx, extSnapshot);
201
+ }
202
+ clientRes.writeHead(502, { "content-type": "application/json" });
203
+ clientRes.end(JSON.stringify({ error: "upstream_error", message: err.message }));
204
+ return;
205
+ }
206
+
207
+ meta._upstreamConnectionId = upstreamConnectionId ?? null;
208
+
209
+ if (extSnapshot.length > 0) {
210
+ const resCtx = { status: statusCode, headers: responseHeaders, meta };
211
+ await runOnResponseStart(resCtx, extSnapshot);
212
+ }
213
+
214
+ const chunks = [];
215
+ for await (const chunk of upstreamRes) chunks.push(chunk);
216
+ const rawResponse = Buffer.concat(chunks);
217
+
218
+ if (extSnapshot.length > 0) {
219
+ let responseBody = null;
220
+ try {
221
+ responseBody = JSON.parse(rawResponse.toString());
222
+ } catch {}
223
+ // Stash raw byte count so bootstrap-defense (and future audit extensions)
224
+ // can record the on-wire payload size even when the body fails to parse.
225
+ // Non-JSON responses are exactly the anomaly audit mode needs to capture.
226
+ meta._bootstrapBodyBytes = rawResponse.length;
227
+ const resCtx = { status: statusCode, headers: responseHeaders, body: responseBody, meta };
228
+ await runOnResponse(resCtx, extSnapshot);
229
+ if (responseBody !== null) {
230
+ clientRes.writeHead(statusCode, resCtx.headers);
231
+ clientRes.end(JSON.stringify(resCtx.body));
232
+ return;
233
+ }
234
+ }
235
+
236
+ clientRes.writeHead(statusCode, responseHeaders);
237
+ clientRes.end(rawResponse);
238
+ }
239
+
132
240
  function handleHealth(_req, res) {
133
241
  res.writeHead(200, { "content-type": "application/json" });
134
242
  res.end(JSON.stringify({ status: "ok" }));
@@ -157,6 +265,9 @@ export function createProxyServer() {
157
265
  if (req.method === "POST" && req.url?.startsWith("/v1/messages")) {
158
266
  return handleMessages(req, res);
159
267
  }
268
+ if (req.url?.startsWith("/api/claude_cli/bootstrap")) {
269
+ return handleBootstrap(req, res);
270
+ }
160
271
  handleNotFound(req, res);
161
272
  });
162
273
  }
@@ -41,7 +41,7 @@ fi
41
41
  # through os.environ, never via a shell-substituted string.
42
42
  result=$(python3 <<'PYEOF' 2>/dev/null
43
43
  import sys, json, os, re, hashlib
44
- from datetime import datetime, timezone, timedelta
44
+ from datetime import datetime, timezone
45
45
 
46
46
  home = os.path.expanduser('~')
47
47
  account_path = os.path.join(home, '.claude', 'quota-status', 'account.json')
@@ -98,28 +98,87 @@ ts = sess.get('timestamp') or acc.get('timestamp', '')
98
98
 
99
99
  now = datetime.fromisoformat(ts.replace('Z', '+00:00')) if ts else datetime.now(timezone.utc)
100
100
 
101
- # Q5h burn rate
102
- rate5 = ''
103
- if q5h_reset > 0 and q5h > 0:
104
- window_start = datetime.fromtimestamp(q5h_reset, tz=timezone.utc) - timedelta(hours=5)
105
- elapsed_min = (now - window_start).total_seconds() / 60
106
- if elapsed_min > 1:
107
- rate5 = '{:+.1f}'.format(q5h / elapsed_min)
108
-
109
- # Q7d burn rate
110
- rate7 = ''
111
- if q7d_reset > 0 and q7d > 0:
112
- window_start_7d = datetime.fromtimestamp(q7d_reset, tz=timezone.utc) - timedelta(days=7)
113
- elapsed_hr = (now - window_start_7d).total_seconds() / 3600
114
- if elapsed_hr > 0.1:
115
- rate7 = '{:+.1f}'.format(q7d / elapsed_hr)
116
-
117
- label = 'Q5h: {}%'.format(q5h)
118
- if rate5:
119
- label += ' ({}%/m)'.format(rate5)
120
- label += ' | Q7d: {}%'.format(q7d)
121
- if rate7:
122
- label += ' ({}%/hr)'.format(rate7)
101
+ SECS_PER_MIN = 60
102
+ MINS_PER_HR = 60
103
+ HRS_PER_DAY = 24
104
+ SECS_PER_HR = SECS_PER_MIN * MINS_PER_HR
105
+ SECS_PER_DAY = SECS_PER_HR * HRS_PER_DAY
106
+
107
+ # Minimum elapsed time in a window before we'll project an exhaust ETA from
108
+ # its burn rate. Below this the rate is dominated by a single early call and
109
+ # the projection is noise.
110
+ BURN_WARMUP_SEC = 5 * SECS_PER_MIN
111
+
112
+ BAR_WIDTH = 10
113
+
114
+ def draw_bar(consumed_pct, elapsed_pct, width=BAR_WIDTH):
115
+ # Tick overlays a fill cell when consumed > elapsed, keeping bar width
116
+ # constant — that's what makes the over-pace state legible (┃ inside the
117
+ # filled run) rather than just pushing fill cells around.
118
+ fill = int(round(max(0, min(100, consumed_pct)) / 100 * width))
119
+ if elapsed_pct is None:
120
+ tick = -1
121
+ else:
122
+ tick = min(int(max(0, min(100, elapsed_pct)) / 100 * width), width - 1)
123
+ cells = []
124
+ remaining = fill
125
+ for i in range(width):
126
+ if i == tick:
127
+ cells.append('┃')
128
+ elif remaining > 0:
129
+ cells.append('█')
130
+ remaining -= 1
131
+ else:
132
+ cells.append('░')
133
+ return '[' + ''.join(cells) + ']'
134
+
135
+ def fmt_time(secs):
136
+ # Autoselect scale: `{D}d{H}h` for >=1 day, `{H}h{MM}m` below that.
137
+ # One formatter so the Q5h (always h/m) and Q7d (h/m or d/h depending on
138
+ # how close to reset) callers don't need to pick.
139
+ if secs is None or secs <= 0:
140
+ return ''
141
+ if secs >= SECS_PER_DAY:
142
+ return '{}d{}h'.format(int(secs // SECS_PER_DAY), int((secs % SECS_PER_DAY) // SECS_PER_HR))
143
+ return '{}h{:02d}m'.format(int(secs // SECS_PER_HR), int((secs % SECS_PER_HR) // SECS_PER_MIN))
144
+
145
+ def window_view(reset_ts, window_secs):
146
+ # Returns (elapsed_sec, secs_left). elapsed_sec may be negative (server
147
+ # gave us a reset_at past the window head — invalid) or exceed window_secs
148
+ # (stale reset_at not yet refreshed by the next API call). Callers handle
149
+ # both; downstream rendering clamps the tick to the bar edges.
150
+ if reset_ts <= 0:
151
+ return None, None
152
+ window_start = datetime.fromtimestamp(reset_ts - window_secs, tz=timezone.utc)
153
+ return (now - window_start).total_seconds(), reset_ts - now.timestamp()
154
+
155
+ def time_to_exhaust_sec(pct, elapsed_sec, min_elapsed_sec):
156
+ # (100 - pct) divided by current burn rate (pct / elapsed_sec). Gated on
157
+ # min_elapsed_sec so very-fresh windows don't project off noise.
158
+ if elapsed_sec is None or elapsed_sec <= min_elapsed_sec:
159
+ return None
160
+ if pct <= 0 or pct >= 100:
161
+ return None
162
+ return (100 - pct) * elapsed_sec / pct
163
+
164
+ def format_window(name, pct, elapsed_sec, window_secs, secs_left, min_elapsed_sec):
165
+ ep = None if elapsed_sec is None or elapsed_sec < 0 else elapsed_sec / window_secs * 100
166
+ extras = []
167
+ stale = secs_left is not None and secs_left <= 0
168
+ if not stale:
169
+ exhaust = time_to_exhaust_sec(pct, elapsed_sec, min_elapsed_sec)
170
+ if exhaust is not None:
171
+ extras.append('exhaust ' + fmt_time(exhaust))
172
+ if secs_left is not None and secs_left > 0:
173
+ extras.append('reset ' + fmt_time(secs_left))
174
+ tail = ' (' + ', '.join(extras) + ')' if extras else ''
175
+ return '{} {} {}%{}'.format(name, draw_bar(pct, ep), pct, tail)
176
+
177
+ elapsed_5h, left_5h = window_view(q5h_reset, 5 * SECS_PER_HR)
178
+ elapsed_7d, left_7d = window_view(q7d_reset, 7 * SECS_PER_DAY)
179
+
180
+ label = format_window('Q5h', q5h, elapsed_5h, 5 * SECS_PER_HR, left_5h, BURN_WARMUP_SEC)
181
+ label += ' | ' + format_window('Q7d', q7d, elapsed_7d, 7 * SECS_PER_DAY, left_7d, BURN_WARMUP_SEC)
123
182
  if overage == 'active':
124
183
  label += ' | OVERAGE'
125
184