Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 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.

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

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]` 접미사를 제거하기 전에 보냅니다)임을 확인하는 바이너리 워크를 참조하세요.
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 for the life of the process (the advice never changes; an extension reload does not re-arm it). 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 README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ cache-fix 的 `bootstrap-defense` 扩展提供三种模式,通过 `CACHE_FIX_B
| 模式 | 默认? | 行为 |
|---|---|---|
| `off` | 否 | 扩展无操作。 |
| `warn` | 是 | 检测标记。将注释存储到每个会话 JSON (`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`) 并发出 stderr 日志行。不修改请求。 |
| `warn` | 是 | 检测标记。将注释存储到每个会话 JSON (`auto_1m_detected`, `auto_1m_action: "warn"`, `auto_1m_advice`) 并发出 stderr 日志行。该行在进程生命周期内锁定为首次检测(建议文本不会变化,扩展重载也不会重新触发)。不修改请求。 |
| `strip` | 主动选择 | 在转发前检测并从 `anthropic-beta` 头中删除标记。注释:`auto_1m_action: "stripped"`。 |

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]` 后缀)的二进制步行。
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 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. |
| `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
17 changes: 16 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,14 @@ export function joinBetaTokens(tokens) {
return tokens.join(", ");
}

// PROCESS-DURABLE: `loadExtensions` cache-busts every import, so a module-scoped
// `let` is re-armed on every extension reload and the advisory returns once per
// reload. `Symbol.for`, not `Symbol()` -- the registry is what makes every
// re-evaluated copy reach the same object. `process.env` is the other way this
// repo keeps reload-durable state (server.mjs, request-capture.mjs); a boolean
// does not need the string coercion or the leak into spawned children.
const _latch = (globalThis[Symbol.for("cache-fix.auto-1m-guard")] ??= { advised: false });

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

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

// Test seam — clears the process-wide latch. Any module instance clears it for
// all of them, which is the property the latch exists to have.
export function __resetAdvisedForTests() { _latch.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
51 changes: 51 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,53 @@ 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 ---

// The advisory sampler, shared by the two cases below. Forwards what it is not
// sampling, so it cannot swallow an unrelated line. Resets the latch on both
// sides -- a spent one makes a case appended later count 0 and read green for
// the wrong reason.
async function sampleAdvisories(fn) {
__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 { await fn(); } finally { process.stderr.write = orig; __resetAdvisedForTests(); }
return seen;
}

test("onRequest: the advisory is written once, but every request is still annotated", async () => {
const seen = await sampleAdvisories(async () => {
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`);
}
});
assert.equal(seen.length, 1, `advisory written ${seen.length}x for 5 requests`);
});

test("onRequest: the advisory latch spans the process, not one module instance", async () => {
// loadExtensions cache-busts every import (pipeline.mjs), so this module is
// re-evaluated inside ONE process on every reload -- and a module-scoped latch
// re-arms there, returning the advisory this exists to silence.
const href = new URL("../proxy/extensions/auto-1m-guard.mjs", import.meta.url).href;
const a = await import(`${href}?latch=a`);
const b = await import(`${href}?latch=b`);
assert.notEqual(a.default, b.default,
"premise: both imports resolved to the same module, so this case proves nothing");

const seen = await sampleAdvisories(async () => {
for (const m of [a, b]) {
await m.default.onRequest(mkCtx({ headers: { "anthropic-beta": STD_BETAS_WITH_1M } }));
}
});
assert.equal(seen.length, 1,
`two module instances in one process wrote ${seen.length} advisories`);
});
Loading
Loading