Skip to content

Commit 38121e9

Browse files
codeslakeclaude
andcommitted
Merge remote-tracking branch 'origin/fix/1m-guard-advise-once' into HEAD
Conflict in test/proxy-held-port.test.mjs (classify()'s typeof guard): matches rebuild-resolutions.md's alternative 2, block 1, standalone (block 2 of that entry does not arise in this build order since cnighswonger#345 has not merged yet). Kept ours' guard (if (typeof body !== "string") return null;, carried into this build via cnighswonger#356's cherry-pick of cnighswonger#345's 35ac847) and dropped theirs' (cnighswonger#355, 5d2159f) removal of it. Measured: re-applying theirs' hunk alone and running 'classify survives a probe that answers with a status code' reproduces the exact TypeError the ledger records (body.startsWith is not a function). The resolved file passes that case and the other two classify cases (3/3); the parent commit passes them too, so nothing here is new. Co-Authored-By: Claude <noreply@anthropic.com>
2 parents 1227795 + cfb7f13 commit 38121e9

10 files changed

Lines changed: 194 additions & 18 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
- **`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.
88

9+
- **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 is registry-keyed on `globalThis`, not module-scoped: `loadExtensions` cache-busts every import, so a module-scoped one is re-armed on every extension reload and the line returns once per reload wherever hot reload is on.
10+
911
### Fixed
1012

1113
- **A 502 handed to a client no longer leaves no record of why.** The three `forwardRequest` catch blocks (messages, bootstrap, passthrough) only logged the reason through `debugLog`, gated on `CACHE_FIX_DEBUG=1` and off by default on every host — an outage left nothing to read back. Each site now also writes one `[cache-fix] upstream error -> 502: <code> <message> for <method> <route>` line to stderr (session ids in the route scrubbed to `cse_<id>`) for an upstream connection failure (a 502 the proxy itself generates; a relayed upstream 502 is unchanged), on by default; set `CACHE_FIX_GATEWAY_ERROR_LOG=off` to silence it.

README.ko.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ cache-fix의 `bootstrap-defense` 확장은 `CACHE_FIX_BOOTSTRAP_MODE`를 통해
505505
| 모드 | 기본? | 동작 |
506506
|---|---|---|
507507
| `off` | 아니요 | 확장은 무작위입니다. |
508-
| `warn` || 토큰을 감지합니다. 각 세션 JSON(`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`)에 주석을 저장하고 stderr 로그 라인을 출력합니다. 요청을 수정하지 않습니다. |
508+
| `warn` || 토큰을 감지합니다. 각 세션 JSON(`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`)에 주석을 저장하고 stderr 로그 라인을 출력합니다. 이 라인은 프로세스 수명 동안 최초 감지 1회로 고정됩니다(조언 문구는 변하지 않으며, 확장 리로드로 재무장되지 않습니다). 요청을 수정하지 않습니다. |
509509
| `strip` | 선택적 | 전송 전 토큰을 감지하고 `anthropic-beta` 헤더에서 제거합니다. 주석: `auto_1m_action: "stripped"`. |
510510

511511
CC 측 종료 스위치는 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`(환경 변수)이며, CC 프로세스에 실제로 도달했을 때 올바른 수정입니다. VS Code 확장 표면에서는 이 환경 변수가 신뢰할 수 없다고 보고됩니다; 프록시 인터셉트는 요청을 생성한 어떤 CC 래퍼든 작동하므로 간격을 우회합니다. [CC#64919](https://github.com/anthropics/claude-code/issues/64919) 추적; [`docs/directives/proxy-auto-1m-guard.md`](docs/directives/proxy-auto-1m-guard.md)에서 프록시 가시 신호가 베타 헤더(예: CC는 `req.body.model` 클라이언트 측에서 `[1m]` 접미사를 제거하기 전에 보냅니다)임을 확인하는 바이너리 워크를 참조하세요.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ Note: cache-fix v3.6.2 and earlier returned 404 for the bootstrap path because t
619619
| Mode | Default? | Behavior |
620620
|---|---|---|
621621
| `off` | no | Extension no-op. |
622-
| `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. |
622+
| `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 for the life of the process (the advice never changes; an extension reload does not re-arm it). Does not modify the request. |
623623
| `strip` | opt-in | Detect AND remove the token from the `anthropic-beta` header before forwarding. Annotation: `auto_1m_action: "stripped"`. |
624624

625625
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).

README.zh.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ cache-fix 的 `bootstrap-defense` 扩展提供三种模式,通过 `CACHE_FIX_B
506506
| 模式 | 默认? | 行为 |
507507
|---|---|---|
508508
| `off` || 扩展无操作。 |
509-
| `warn` || 检测标记。将注释存储到每个会话 JSON (`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`) 并发出 stderr 日志行。不修改请求。 |
509+
| `warn` || 检测标记。将注释存储到每个会话 JSON (`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`) 并发出 stderr 日志行。该行在进程生命周期内锁定为首次检测(建议文本不会变化,扩展重载也不会重新触发)。不修改请求。 |
510510
| `strip` | 主动选择 | 在转发前检测并从 `anthropic-beta` 头中删除标记。注释:`auto_1m_action: "stripped"`|
511511

512512
CC 端的关闭开关是 `CLAUDE_CODE_DISABLE_1M_CONTEXT=1`(环境变量),当它实际到达 CC 进程时才是正确修复。在 VS Code 扩展表面,该环境变量据报道不可靠;代理拦截绕过了这个间隙,因为它在任何 CC 启动器产生的请求上都作用于网络。跟踪 [CC#64919](https://github.com/anthropics/claude-code/issues/64919);参见 [`docs/directives/proxy-auto-1m-guard.md`](docs/directives/proxy-auto-1m-guard.md) 了解确认代理可见信号是 beta 头(CC 在发送前从 `req.body.model` 客户端侧剥离 `[1m]` 后缀)的二进制步行。

docs/directives/proxy-auto-1m-guard.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ A new proxy extension `auto-1m-guard` that operates on outbound requests:
7979
| Mode | env var | Behavior |
8080
|---|---|---|
8181
| `off` | `CACHE_FIX_AUTO_1M_GUARD=off` | Extension no-op; request passes unchanged. |
82-
| `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. |
82+
| `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 for the LIFE OF THE PROCESS — the advice never changes, so repeating it per request buries the log. The latch is registry-keyed on `globalThis`, so an extension reload does not re-arm it. Do not modify the request. |
8383
| `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"`. |
8484

8585
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).

proxy/extensions/auto-1m-guard.mjs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
//
1414
// Three modes (env: CACHE_FIX_AUTO_1M_GUARD):
1515
// off no-op
16-
// warn (default) stash _auto1mGuard annotation + stderr line; no mutation
16+
// warn (default) stash _auto1mGuard annotation + latched stderr line; no mutation
1717
// strip also remove context-1m-2025-08-07 from the anthropic-beta header
1818
//
1919
// Order 520: after ttl-management (500) and before thinking-block-sanitize
@@ -78,6 +78,14 @@ export function joinBetaTokens(tokens) {
7878
return tokens.join(", ");
7979
}
8080

81+
// PROCESS-DURABLE: `loadExtensions` cache-busts every import, so a module-scoped
82+
// `let` is re-armed on every extension reload and the advisory returns once per
83+
// reload. `Symbol.for`, not `Symbol()` -- the registry is what makes every
84+
// re-evaluated copy reach the same object. `process.env` is the other way this
85+
// repo keeps reload-durable state (server.mjs, request-capture.mjs); a boolean
86+
// does not need the string coercion or the leak into spawned children.
87+
const _latch = (globalThis[Symbol.for("cache-fix.auto-1m-guard")] ??= { advised: false });
88+
8189
export default {
8290
name: "auto-1m-guard",
8391
description:
@@ -107,6 +115,9 @@ export default {
107115
auto_1m_advice: ADVICE,
108116
};
109117

118+
// A repeat carries nothing the first line did not, and at request rate buries the log.
119+
if (_latch.advised) return;
120+
_latch.advised = true;
110121
process.stderr.write(
111122
`[auto-1m-guard] ${BETA_TOKEN_1M} detected in outbound betas` +
112123
(plan.stripped ? " — stripped" : "") +
@@ -115,3 +126,7 @@ export default {
115126
);
116127
},
117128
};
129+
130+
// Test seam — clears the process-wide latch. Any module instance clears it for
131+
// all of them, which is the property the latch exists to have.
132+
export function __resetAdvisedForTests() { _latch.advised = false; }

proxy/extensions/request-capture.mjs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
// request-capture — record full request bodies for offline replay.
1+
// request-capture — record MESSAGES-API request bodies for offline replay.
22
//
3-
// Directive: docs/directives/proxy-request-capture-replay.md (stage 1).
4-
// The proxy is the only component that sees every request byte-for-byte;
5-
// until this extension, it threw the bodies away, so every pipeline
6-
// change could only be validated against synthetic fixtures or live
7-
// traffic. Captures feed tools/replay.mjs and tools/cache-sim.mjs.
3+
// The proxy sees every request byte-for-byte; until this extension, it threw
4+
// the bodies away, so every pipeline change could only be validated against
5+
// synthetic fixtures or live traffic.
6+
//
7+
// SCOPE — the outer half is the pipeline's, not this file's: the extension
8+
// declares no `routes`, so runOnRequest's default of ["messages"] skips the hook
9+
// for every other tagged route, /api/claude_cli/bootstrap included. The body
10+
// gate below is what scopes an UNTAGGED caller, which appliesToRoute admits.
811
//
912
// Order 60 — after bootstrap-defense (45) and ttl-tier-detect (75 is
1013
// AFTER, fine: it only reads), before cc-version-normalize (90), the
@@ -256,6 +259,12 @@ export default {
256259
"~/.claude/cache-fix-captures/<key>-requests.jsonl for offline " +
257260
"replay and cache simulation",
258261
enabled: false, // overridden by extensions.json
262+
// Declared, not inherited. runOnRequest defaults to exactly this, so the
263+
// value is a no-op -- but the SCOPE note at the top of this file reasons
264+
// about it, and an inherited default is invisible to anyone widening the
265+
// corpus. jsonl-session-mirror and image-retry-circuit-breaker spell it out
266+
// for the same reason.
267+
routes: ["messages"],
259268
order: 60,
260269

261270
async onRequest(ctx) {

test/proxy-auto-1m-guard.test.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test, beforeEach, afterEach } from "node:test";
22
import assert from "node:assert/strict";
33
import ext, {
4+
__resetAdvisedForTests,
45
findBetaHeader,
56
parseBetaTokens,
67
planSanitizeBetaHeader,
@@ -189,3 +190,53 @@ test("onRequest: duplicate `context-1m-2025-08-07` tokens (defensive) — all re
189190
"claude-code-20250219, oauth_auth, interleaved-thinking-2025-05-14",
190191
);
191192
});
193+
194+
// --- the advisory is advice, not a per-request fact ---
195+
196+
// The advisory sampler, shared by the two cases below. Forwards what it is not
197+
// sampling, so it cannot swallow an unrelated line. Resets the latch on both
198+
// sides -- a spent one makes a case appended later count 0 and read green for
199+
// the wrong reason.
200+
async function sampleAdvisories(fn) {
201+
__resetAdvisedForTests();
202+
const seen = [];
203+
const orig = process.stderr.write;
204+
process.stderr.write = (s) => {
205+
if (!String(s).includes("[auto-1m-guard]")) return orig.call(process.stderr, s);
206+
seen.push(String(s));
207+
return true;
208+
};
209+
try { await fn(); } finally { process.stderr.write = orig; __resetAdvisedForTests(); }
210+
return seen;
211+
}
212+
213+
test("onRequest: the advisory is written once, but every request is still annotated", async () => {
214+
const seen = await sampleAdvisories(async () => {
215+
for (let i = 0; i < 5; i++) {
216+
const ctx = mkCtx({ headers: { "anthropic-beta": STD_BETAS_WITH_1M }, mode: "warn" });
217+
await ext.onRequest(ctx);
218+
// The latch sits below the annotation, which every request's session JSON needs.
219+
assert.equal(ctx.meta._auto1mGuard?.auto_1m_detected, true, `request ${i} lost its annotation`);
220+
}
221+
});
222+
assert.equal(seen.length, 1, `advisory written ${seen.length}x for 5 requests`);
223+
});
224+
225+
test("onRequest: the advisory latch spans the process, not one module instance", async () => {
226+
// loadExtensions cache-busts every import (pipeline.mjs), so this module is
227+
// re-evaluated inside ONE process on every reload -- and a module-scoped latch
228+
// re-arms there, returning the advisory this exists to silence.
229+
const href = new URL("../proxy/extensions/auto-1m-guard.mjs", import.meta.url).href;
230+
const a = await import(`${href}?latch=a`);
231+
const b = await import(`${href}?latch=b`);
232+
assert.notEqual(a.default, b.default,
233+
"premise: both imports resolved to the same module, so this case proves nothing");
234+
235+
const seen = await sampleAdvisories(async () => {
236+
for (const m of [a, b]) {
237+
await m.default.onRequest(mkCtx({ headers: { "anthropic-beta": STD_BETAS_WITH_1M } }));
238+
}
239+
});
240+
assert.equal(seen.length, 1,
241+
`two module instances in one process wrote ${seen.length} advisories`);
242+
});

0 commit comments

Comments
 (0)