Skip to content

Commit cd57cb5

Browse files
codeslakeclaude
andcommitted
merge pr-355
Resolved test/proxy-held-port.test.mjs: both sides add the same type guard to classify() and differ only in wording. Kept cnighswonger#355's, because cnighswonger#345's says "health() replaced the probe that resolved a bare statusCode" and health() itself resolves a bare 200 -- refuted in review. Behaviour is identical. Co-Authored-By: Claude <noreply@anthropic.com>
2 parents c6f8552 + cf5d4c1 commit cd57cb5

10 files changed

Lines changed: 140 additions & 17 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 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.

README.ko.md

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

510510
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
@@ -615,7 +615,7 @@ Note: cache-fix v3.6.2 and earlier returned 404 for the bootstrap path because t
615615
| Mode | Default? | Behavior |
616616
|---|---|---|
617617
| `off` | no | Extension no-op. |
618-
| `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. |
618+
| `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. |
619619
| `strip` | opt-in | Detect AND remove the token from the `anthropic-beta` header before forwarding. Annotation: `auto_1m_action: "stripped"`. |
620620

621621
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
@@ -505,7 +505,7 @@ cache-fix 的 `bootstrap-defense` 扩展提供三种模式,通过 `CACHE_FIX_B
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 日志行。该行在进程生命周期内锁定为首次检测(建议文本不会变化,扩展重载也不会重新触发)。不修改请求。 |
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) 了解确认代理可见信号是 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+
});

test/proxy-held-port.test.mjs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,15 @@ const launcherPath = join(dirname(fileURLToPath(import.meta.url)), "..", "bin",
4848
// them, so the body is tested before the code.
4949
const OUTAGE = { REFUSED: "refused", RESET: "reset", DEGRADED: "degraded" };
5050
function classify(body) {
51-
// A BOUNDARY GUARD, NOT A LIVE PATH: every caller now hands this an ERR: string,
52-
// because health() replaced the probe that resolved a bare statusCode. Kept
53-
// because this is a shared helper with a history of differently shaped probes,
54-
// and the answer to one is "that was a reply, not an outage", not a TypeError.
55-
if (typeof body !== "string") return null;
56-
if (!body.startsWith("ERR:")) return null;
51+
// A BOUNDARY GUARD, and it is load-bearing under concurrency. Not every probe
52+
// in this file resolves a BODY -- the readiness loops resolve a bare status
53+
// code -- and `freePort()` releases a port before its caller binds it, so a
54+
// neighbouring test file can be holding the number this one just drew. Its
55+
// 200 then reaches here as a Number and used to throw
56+
// `body.startsWith is not a function` out of the helper whose whole job is to
57+
// answer "is this an outage". A non-string is not an ERR: body; it is not an
58+
// outage either.
59+
if (typeof body !== "string" || !body.startsWith("ERR:")) return null;
5760
if (/"carrying"\s*:\s*"gap-relay"/.test(body)) return null;
5861
if (/"status"\s*:\s*"degraded"/.test(body)) return OUTAGE.DEGRADED;
5962
if (/ECONNREFUSED|ETIMEDOUT|HUNG/.test(body)) return OUTAGE.REFUSED;

test/request-capture.test.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66

7+
import { runOnRequest } from "../proxy/pipeline.mjs";
78
import ext, {
89
resolveCaptureKey,
910
buildCaptureRecord,
@@ -178,3 +179,45 @@ test("request records carry a join id", () => {
178179
assert.equal(r.id, "cap999");
179180
assert.ok(r.body, "the request record still carries the body it always did");
180181
});
182+
183+
test("request-capture: enabled — records the Messages API only, never another route", async () => {
184+
// Scope has two halves and neither was pinned: the pipeline's route filter
185+
// (no `routes` here, so it defaults to messages) and this file's body gate.
186+
// Distinct session ids: _bootWrittenFor is module-scoped, so a mutation that
187+
// makes one of these write cannot burn a sibling case's boot record.
188+
const dir = await mkdtemp(join(tmpdir(), "capture-test-"));
189+
const prevConfig = process.env.CLAUDE_CONFIG_DIR;
190+
const prevFlag = process.env.CACHE_FIX_REQUEST_CAPTURE;
191+
process.env.CLAUDE_CONFIG_DIR = dir;
192+
process.env.CACHE_FIX_REQUEST_CAPTURE = "1";
193+
try {
194+
// Inner half — an UNTAGGED caller, which the route filter admits, so only the
195+
// body gate is left.
196+
await ext.onRequest({
197+
body: { events: [{ type: "worker_started", at: 1 }] },
198+
headers: { "x-session-id": "scope-check" },
199+
});
200+
assert.deepEqual(await readdir(dir), [],
201+
"a non-Messages body was captured — the corpus would carry shapes replay cannot drive");
202+
203+
// Outer half — a MESSAGES body on the bootstrap route, so the gate above
204+
// cannot be what drops it. Declaring `routes` here would widen the corpus.
205+
await runOnRequest(
206+
{ ...makeCtx({ headers: { "x-session-id": "scope-route" } }), meta: { route: "bootstrap" } },
207+
[ext],
208+
);
209+
assert.deepEqual(await readdir(dir), [],
210+
"the bootstrap route reached the capture hook");
211+
212+
// PREMISE, so the case cannot pass because capture was simply off: the same
213+
// setup with a Messages body must write.
214+
await ext.onRequest(makeCtx({ headers: { "x-session-id": "scope-premise" } }));
215+
assert.ok((await readdir(dir)).length, "premise: capture is on, so a Messages body must write");
216+
} finally {
217+
if (prevConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR;
218+
else process.env.CLAUDE_CONFIG_DIR = prevConfig;
219+
if (prevFlag === undefined) delete process.env.CACHE_FIX_REQUEST_CAPTURE;
220+
else process.env.CACHE_FIX_REQUEST_CAPTURE = prevFlag;
221+
await rm(dir, { recursive: true, force: true });
222+
}
223+
});

0 commit comments

Comments
 (0)