Просмотр исходного кода

fix(proxy): read Claude Code betas signal from anthropic-beta header instead of request body

The betas-claude-code signal was checking session.request.message["betas"]
but Claude Code CLI sends beta identifiers via the anthropic-beta header,
not in the request body. This caused signal #3 to never match, making
confirmed (3/3) impossible and all builtin keyword detection fail silently.

Also adds signals/hubConfirmed to ClientRestrictionResult for observability
and appends signal diagnostics to client restriction error messages.
ding113 1 месяц назад
Родитель
Сommit
3ade40f7cb

+ 13 - 5
src/app/v1/_lib/proxy/client-detector.ts

@@ -27,6 +27,8 @@ export interface ClientRestrictionResult {
   detectedClient?: string;
   checkedAllowlist: string[];
   checkedBlocklist: string[];
+  signals?: string[];
+  hubConfirmed?: boolean;
 }
 
 const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, "");
@@ -56,11 +58,8 @@ function confirmClaudeCodeSignals(session: ProxySession): {
     signals.push("ua-prefix");
   }
 
-  const betas = session.request.message["betas"];
-  if (
-    Array.isArray(betas) &&
-    betas.some((beta) => typeof beta === "string" && /^claude-code-/i.test(beta))
-  ) {
+  const betaHeader = session.headers.get("anthropic-beta") ?? "";
+  if (/claude-code-/i.test(betaHeader)) {
     signals.push("betas-claude-code");
   }
 
@@ -178,6 +177,8 @@ export function isClientAllowedDetailed(
   const normalizedUa = normalize(ua);
   const subClient = claudeCode.confirmed ? extractSubClient(ua) : null;
   const detectedClient = subClient || ua || undefined;
+  const hasBuiltinKeyword =
+    checkedAllowlist.some(isBuiltinKeyword) || checkedBlocklist.some(isBuiltinKeyword);
 
   const matches = (pattern: string): boolean => {
     if (!isBuiltinKeyword(pattern)) {
@@ -200,6 +201,10 @@ export function isClientAllowedDetailed(
         detectedClient,
         checkedAllowlist,
         checkedBlocklist,
+        ...(hasBuiltinKeyword && {
+          signals: claudeCode.signals,
+          hubConfirmed: claudeCode.confirmed,
+        }),
       };
     }
   }
@@ -211,6 +216,7 @@ export function isClientAllowedDetailed(
       detectedClient,
       checkedAllowlist,
       checkedBlocklist,
+      ...(hasBuiltinKeyword && { signals: claudeCode.signals, hubConfirmed: claudeCode.confirmed }),
     };
   }
 
@@ -223,6 +229,7 @@ export function isClientAllowedDetailed(
       detectedClient,
       checkedAllowlist,
       checkedBlocklist,
+      ...(hasBuiltinKeyword && { signals: claudeCode.signals, hubConfirmed: claudeCode.confirmed }),
     };
   }
 
@@ -232,5 +239,6 @@ export function isClientAllowedDetailed(
     detectedClient,
     checkedAllowlist,
     checkedBlocklist,
+    ...(hasBuiltinKeyword && { signals: claudeCode.signals, hubConfirmed: claudeCode.confirmed }),
   };
 }

+ 3 - 0
src/app/v1/_lib/proxy/client-guard.ts

@@ -38,6 +38,9 @@ export class ProxyClientGuard {
       } else {
         message = `Client not in allowed list: [${allowedClients.join(", ")}]${detected}`;
       }
+      if (result.signals) {
+        message += `\nSignals(${result.signals.length}/3): [${result.signals.join(", ")}]`;
+      }
       return ProxyResponses.buildError(400, message, "invalid_request_error");
     }
 

+ 33 - 14
tests/unit/proxy/client-detector.test.ts

@@ -14,7 +14,7 @@ type SessionOptions = {
   userAgent?: string | null;
   xApp?: string | null;
   dangerousBrowserAccess?: string | null;
-  betas?: unknown;
+  anthropicBeta?: string | null;
 };
 
 function createMockSession(options: SessionOptions = {}): ProxySession {
@@ -25,17 +25,15 @@ function createMockSession(options: SessionOptions = {}): ProxySession {
   if (options.dangerousBrowserAccess !== undefined && options.dangerousBrowserAccess !== null) {
     headers.set("anthropic-dangerous-direct-browser-access", options.dangerousBrowserAccess);
   }
-
-  const message: Record<string, unknown> = {};
-  if ("betas" in options) {
-    message.betas = options.betas;
+  if (options.anthropicBeta !== undefined && options.anthropicBeta !== null) {
+    headers.set("anthropic-beta", options.anthropicBeta);
   }
 
   return {
     userAgent: options.userAgent ?? null,
     headers,
     request: {
-      message,
+      message: {},
     },
   } as unknown as ProxySession;
 }
@@ -44,7 +42,7 @@ function createConfirmedClaudeCodeSession(userAgent: string): ProxySession {
   return createMockSession({
     userAgent,
     xApp: "cli",
-    betas: ["claude-code-test"],
+    anthropicBeta: "claude-code-test",
   });
 }
 
@@ -86,7 +84,7 @@ describe("client-detector", () => {
       const session = createMockSession({
         userAgent: "claude-cli/1.0.0 (external, cli)",
         xApp: "cli",
-        betas: ["claude-code-cache-control-20260101"],
+        anthropicBeta: "claude-code-cache-control-20260101",
       });
 
       const result = detectClientFull(session, "claude-code");
@@ -100,7 +98,7 @@ describe("client-detector", () => {
         name: "missing x-app",
         options: {
           userAgent: "claude-cli/1.0.0 (external, cli)",
-          betas: ["claude-code-foo"],
+          anthropicBeta: "claude-code-foo",
         },
       },
       {
@@ -108,7 +106,7 @@ describe("client-detector", () => {
         options: {
           userAgent: "GeminiCLI/1.0",
           xApp: "cli",
-          betas: ["claude-code-foo"],
+          anthropicBeta: "claude-code-foo",
         },
       },
       {
@@ -116,7 +114,7 @@ describe("client-detector", () => {
         options: {
           userAgent: "claude-cli/1.0.0 (external, cli)",
           xApp: "cli",
-          betas: ["not-claude-code"],
+          anthropicBeta: "not-claude-code",
         },
       },
     ])("should not confirm with only 2-of-3 signals: $name", ({ options }) => {
@@ -127,7 +125,7 @@ describe("client-detector", () => {
     });
 
     test("should not confirm with 0 strong signals", () => {
-      const session = createMockSession({ userAgent: "GeminiCLI/1.0", betas: "not-array" });
+      const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
       const result = detectClientFull(session, "claude-code");
 
       expect(result.hubConfirmed).toBe(false);
@@ -189,7 +187,7 @@ describe("client-detector", () => {
       const session = createMockSession({
         userAgent: "claude-cli/1.2.3 external, cli",
         xApp: "cli",
-        betas: ["claude-code-a"],
+        anthropicBeta: "claude-code-a",
       });
       const result = detectClientFull(session, "claude-code");
 
@@ -225,7 +223,7 @@ describe("client-detector", () => {
       const session = createMockSession({
         userAgent: "claude-cli/1.2.3 (external, cli)",
         xApp: "cli",
-        betas: ["non-claude-code"],
+        anthropicBeta: "non-claude-code",
       });
       expect(matchClientPattern(session, "claude-code")).toBe(false);
     });
@@ -381,6 +379,27 @@ describe("client-detector", () => {
       expect(result.matchType).toBe("blocklist_hit");
       expect(result.matchedPattern).toBe("gemini-cli");
     });
+
+    test("should include signals and hubConfirmed when builtin keyword is in allowlist", () => {
+      const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
+      const result = isClientAllowedDetailed(session, ["claude-code"], []);
+      expect(result.signals).toEqual(["x-app-cli", "ua-prefix", "betas-claude-code"]);
+      expect(result.hubConfirmed).toBe(true);
+    });
+
+    test("should include signals and hubConfirmed when builtin keyword is in blocklist", () => {
+      const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
+      const result = isClientAllowedDetailed(session, [], ["claude-code"]);
+      expect(result.signals).toEqual(["x-app-cli", "ua-prefix", "betas-claude-code"]);
+      expect(result.hubConfirmed).toBe(true);
+    });
+
+    test("should not include signals when no builtin keyword is in lists", () => {
+      const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
+      const result = isClientAllowedDetailed(session, ["gemini-cli"], []);
+      expect(result.signals).toBeUndefined();
+      expect(result.hubConfirmed).toBeUndefined();
+    });
   });
 
   describe("detectClientFull", () => {