Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

- **`CACHE_FIX_REQUIRE_HOP=1` now covers the relayed `/v1/messages` route, not just the two `CONNECT` paths.** With the variable set and no chain hop reachable, that route previously dialled `api.anthropic.com` directly — carrying the caller's API key past the boundary the variable exists to enforce, while the `CONNECT` paths correctly refused. It now answers `502` instead, matching them. This is a new user-visible outcome on the primary route: an operator who sets the variable, configures no fallbacks and has no reachable proxy will see requests refused where they previously succeeded, which is what the flag asks for. Unset (the default) nothing changes. Hosts exempted by `NO_PROXY` stay exempt — that is an operator saying "this one is direct on purpose". Three egress sites still do not consult the variable: `storageAgent()`, the update-channel probe, and `fallbackToOrigin()`. The first two issue our own requests and carry no client headers. The third forwards the client's headers verbatim to `downloads.claude.ai` on the opt-in download-rewrite path — it is enumerated here rather than claimed harmless.

- **The `auto-1m-guard` advisory is latched to the first detection instead of written on every request.** Its wording is fixed for the mode the proxy runs in, so every copy after the first carried no information, and on a long-lived proxy it crowded everything else off stderr. Per-request detection is unaffected and still observable: the `_auto1mGuard` annotation is written on every detected request, spread into the per-session JSON, and read back by the statusline. The latch's unit is the module instance, so an extension reload re-arms the line rather than silencing it for the process lifetime.

### Fixed

- **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ Note: cache-fix v3.6.2 and earlier returned 404 for the bootstrap path because t
| Mode | Default? | Behavior |
|---|---|---|
| `off` | no | Extension no-op. |
| `warn` | yes | Detect the token. Stash an annotation into the per-session JSON (`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`) and emit a stderr log line. Does not modify the request. |
| `warn` | yes | Detect the token. Stash an annotation into the per-session JSON (`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`) and emit a stderr log line, latched to the first detection (the advice never changes). Does not modify the request. |
| `strip` | opt-in | Detect AND remove the token from the `anthropic-beta` header before forwarding. Annotation: `auto_1m_action: "stripped"`. |

The CC-side kill switch is `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` (env var), which is the right fix when it actually reaches the CC process. On the VS Code extension surface that env var is reportedly unreliable; the proxy intercept bypasses that gap because it acts on the wire regardless of which CC launcher produced the request. Tracks [CC#64919](https://github.com/anthropics/claude-code/issues/64919); see [`docs/directives/proxy-auto-1m-guard.md`](docs/directives/proxy-auto-1m-guard.md) for the binary-walk that confirms the proxy-visible signal is the beta header (CC strips the `[1m]` suffix from `req.body.model` client-side before sending).
Expand Down
2 changes: 1 addition & 1 deletion docs/directives/proxy-auto-1m-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ A new proxy extension `auto-1m-guard` that operates on outbound requests:
| Mode | env var | Behavior |
|---|---|---|
| `off` | `CACHE_FIX_AUTO_1M_GUARD=off` | Extension no-op; request passes unchanged. |
| `warn` (default) | unset or `CACHE_FIX_AUTO_1M_GUARD=warn` | Detect `context-1m-2025-08-07` in the outbound `anthropic-beta` header. If present, stash `ctx.meta._auto1mGuard = { auto_1m_detected: true, auto_1m_action: "warn", auto_1m_advice: <text> }` and write a one-line stderr message visible in proxy logs. Do not modify the request. |
| `warn` (default) | unset or `CACHE_FIX_AUTO_1M_GUARD=warn` | Detect `context-1m-2025-08-07` in the outbound `anthropic-beta` header. If present, stash `ctx.meta._auto1mGuard = { auto_1m_detected: true, auto_1m_action: "warn", auto_1m_advice: <text> }` and write a one-line stderr message visible in proxy logs. That line is latched to the first detection per module instance — the advice never changes, so repeating it per request buries the log; an extension reload re-arms it. Do not modify the request. |
| `strip` (opt-in) | `CACHE_FIX_AUTO_1M_GUARD=strip` | Detect AND remove `context-1m-2025-08-07` from the `anthropic-beta` header before the request goes out. Stash the same flat object with `auto_1m_action: "stripped"`. |

The session-JSON annotation lives at `ctx.meta._auto1mGuard`, written by the cache-telemetry extension's existing spread-into-JSON pattern (the same channel session-health and thinking-block-sanitize already use).
Expand Down
11 changes: 10 additions & 1 deletion proxy/extensions/auto-1m-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//
// Three modes (env: CACHE_FIX_AUTO_1M_GUARD):
// off no-op
// warn (default) stash _auto1mGuard annotation + stderr line; no mutation
// warn (default) stash _auto1mGuard annotation + latched stderr line; no mutation
// strip also remove context-1m-2025-08-07 from the anthropic-beta header
//
// Order 520: after ttl-management (500) and before thinking-block-sanitize
Expand Down Expand Up @@ -78,6 +78,8 @@ export function joinBetaTokens(tokens) {
return tokens.join(", ");
}

let _advised = false;

export default {
name: "auto-1m-guard",
description:
Expand Down Expand Up @@ -107,6 +109,9 @@ export default {
auto_1m_advice: ADVICE,
};

// A repeat carries nothing the first line did not, and at request rate buries the log.
if (_advised) return;
_advised = true;
process.stderr.write(
`[auto-1m-guard] ${BETA_TOKEN_1M} detected in outbound betas` +
(plan.stripped ? " — stripped" : "") +
Expand All @@ -115,3 +120,7 @@ export default {
);
},
};

// Test seam — clears the latch, whose unit is the module instance, not the
// process: loadExtensions cache-busts imports, so a reload re-arms it on purpose.
export function __resetAdvisedForTests() { _advised = false; }
21 changes: 15 additions & 6 deletions proxy/extensions/request-capture.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
// request-capture — record full request bodies for offline replay.
// request-capture — record MESSAGES-API request bodies for offline replay.
//
// Directive: docs/directives/proxy-request-capture-replay.md (stage 1).
// The proxy is the only component that sees every request byte-for-byte;
// until this extension, it threw the bodies away, so every pipeline
// change could only be validated against synthetic fixtures or live
// traffic. Captures feed tools/replay.mjs and tools/cache-sim.mjs.
// The proxy sees every request byte-for-byte; until this extension, it threw
// the bodies away, so every pipeline change could only be validated against
// synthetic fixtures or live traffic.
//
// SCOPE — the outer half is the pipeline's, not this file's: the extension
// declares no `routes`, so runOnRequest's default of ["messages"] skips the hook
// for every other tagged route, /api/claude_cli/bootstrap included. The body
// gate below is what scopes an UNTAGGED caller, which appliesToRoute admits.
//
// Order 60 — after bootstrap-defense (45) and ttl-tier-detect (75 is
// AFTER, fine: it only reads), before cc-version-normalize (90), the
Expand Down Expand Up @@ -256,6 +259,12 @@ export default {
"~/.claude/cache-fix-captures/<key>-requests.jsonl for offline " +
"replay and cache simulation",
enabled: false, // overridden by extensions.json
// Declared, not inherited. runOnRequest defaults to exactly this, so the
// value is a no-op -- but the SCOPE note at the top of this file reasons
// about it, and an inherited default is invisible to anyone widening the
// corpus. jsonl-session-mirror and image-retry-circuit-breaker spell it out
// for the same reason.
routes: ["messages"],
order: 60,

async onRequest(ctx) {
Expand Down
28 changes: 28 additions & 0 deletions test/proxy-auto-1m-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import ext, {
__resetAdvisedForTests,
findBetaHeader,
parseBetaTokens,
planSanitizeBetaHeader,
Expand Down Expand Up @@ -189,3 +190,30 @@ test("onRequest: duplicate `context-1m-2025-08-07` tokens (defensive) — all re
"claude-code-20250219, oauth_auth, interleaved-thinking-2025-05-14",
);
});

// --- the advisory is advice, not a per-request fact ---

test("onRequest: the advisory is written once per module instance, but every request is still annotated", async () => {
// Earlier tests in this file already call onRequest, so without this the
// latch is spent and the count reads 0 rather than 1 — green for the wrong reason.
__resetAdvisedForTests();
const seen = [];
const orig = process.stderr.write;
process.stderr.write = (s) => {
if (!String(s).includes("[auto-1m-guard]")) return orig.call(process.stderr, s);
seen.push(String(s));
return true;
};
try {
for (let i = 0; i < 5; i++) {
const ctx = mkCtx({ headers: { "anthropic-beta": STD_BETAS_WITH_1M }, mode: "warn" });
await ext.onRequest(ctx);
// The latch sits below the annotation, which every request's session JSON needs.
assert.equal(ctx.meta._auto1mGuard?.auto_1m_detected, true, `request ${i} lost its annotation`);
}
} finally {
process.stderr.write = orig;
__resetAdvisedForTests(); // leaving it set would make a case appended below read 0
}
assert.equal(seen.length, 1, `advisory written ${seen.length}x for 5 requests`);
});
43 changes: 43 additions & 0 deletions test/request-capture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { runOnRequest } from "../proxy/pipeline.mjs";
import ext, {
resolveCaptureKey,
buildCaptureRecord,
Expand Down Expand Up @@ -178,3 +179,45 @@ test("request records carry a join id", () => {
assert.equal(r.id, "cap999");
assert.ok(r.body, "the request record still carries the body it always did");
});

test("request-capture: enabled — records the Messages API only, never another route", async () => {
// Scope has two halves and neither was pinned: the pipeline's route filter
// (no `routes` here, so it defaults to messages) and this file's body gate.
// Distinct session ids: _bootWrittenFor is module-scoped, so a mutation that
// makes one of these write cannot burn a sibling case's boot record.
const dir = await mkdtemp(join(tmpdir(), "capture-test-"));
const prevConfig = process.env.CLAUDE_CONFIG_DIR;
const prevFlag = process.env.CACHE_FIX_REQUEST_CAPTURE;
process.env.CLAUDE_CONFIG_DIR = dir;
process.env.CACHE_FIX_REQUEST_CAPTURE = "1";
try {
// Inner half — an UNTAGGED caller, which the route filter admits, so only the
// body gate is left.
await ext.onRequest({
body: { events: [{ type: "worker_started", at: 1 }] },
headers: { "x-session-id": "scope-check" },
});
assert.deepEqual(await readdir(dir), [],
"a non-Messages body was captured — the corpus would carry shapes replay cannot drive");

// Outer half — a MESSAGES body on the bootstrap route, so the gate above
// cannot be what drops it. Declaring `routes` here would widen the corpus.
await runOnRequest(
{ ...makeCtx({ headers: { "x-session-id": "scope-route" } }), meta: { route: "bootstrap" } },
[ext],
);
assert.deepEqual(await readdir(dir), [],
"the bootstrap route reached the capture hook");

// PREMISE, so the case cannot pass because capture was simply off: the same
// setup with a Messages body must write.
await ext.onRequest(makeCtx({ headers: { "x-session-id": "scope-premise" } }));
assert.ok((await readdir(dir)).length, "premise: capture is on, so a Messages body must write");
} finally {
if (prevConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = prevConfig;
if (prevFlag === undefined) delete process.env.CACHE_FIX_REQUEST_CAPTURE;
else process.env.CACHE_FIX_REQUEST_CAPTURE = prevFlag;
await rm(dir, { recursive: true, force: true });
}
});
Loading