Răsfoiți Sursa

fix(proxy): relax Claude Code detection to 4-signal system for CLI 2.0.70 compat

CLI 2.0.70 sends feature betas (e.g. interleaved-thinking-2025-05-14)
without the claude-code- prefix, causing allowedClients rejection.

Replace the strict betas-claude-code signal (required claude-code- prefix)
with betas-present (any anthropic-beta header) and add a 4th signal
metadata-user-id (request body metadata.user_id exists). All 4 signals
must match for confirmed=true.
ding113 1 lună în urmă
părinte
comite
af072b330a

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

@@ -58,9 +58,18 @@ function confirmClaudeCodeSignals(session: ProxySession): {
     signals.push("ua-prefix");
   }
 
-  const betaHeader = session.headers.get("anthropic-beta") ?? "";
-  if (/claude-code-/i.test(betaHeader)) {
-    signals.push("betas-claude-code");
+  if (session.headers.has("anthropic-beta")) {
+    signals.push("betas-present");
+  }
+
+  const metadata = session.request.message.metadata;
+  if (
+    metadata !== null &&
+    typeof metadata === "object" &&
+    "user_id" in metadata &&
+    typeof (metadata as Record<string, unknown>).user_id === "string"
+  ) {
+    signals.push("metadata-user-id");
   }
 
   if (session.headers.get("anthropic-dangerous-direct-browser-access") === "true") {
@@ -68,7 +77,7 @@ function confirmClaudeCodeSignals(session: ProxySession): {
   }
 
   return {
-    confirmed: signals.length === 3,
+    confirmed: signals.length === 4,
     signals,
     supplementary,
   };

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

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

+ 66 - 15
tests/unit/proxy/client-detector.test.ts

@@ -15,6 +15,7 @@ type SessionOptions = {
   xApp?: string | null;
   dangerousBrowserAccess?: string | null;
   anthropicBeta?: string | null;
+  metadataUserId?: string | null;
 };
 
 function createMockSession(options: SessionOptions = {}): ProxySession {
@@ -29,11 +30,16 @@ function createMockSession(options: SessionOptions = {}): ProxySession {
     headers.set("anthropic-beta", options.anthropicBeta);
   }
 
+  const message: Record<string, unknown> = {};
+  if (options.metadataUserId !== undefined && options.metadataUserId !== null) {
+    message.metadata = { user_id: options.metadataUserId };
+  }
+
   return {
     userAgent: options.userAgent ?? null,
     headers,
     request: {
-      message: {},
+      message,
     },
   } as unknown as ProxySession;
 }
@@ -43,6 +49,7 @@ function createConfirmedClaudeCodeSession(userAgent: string): ProxySession {
     userAgent,
     xApp: "cli",
     anthropicBeta: "claude-code-test",
+    metadataUserId: "user_123",
   });
 }
 
@@ -80,25 +87,50 @@ describe("client-detector", () => {
   });
 
   describe("confirmClaudeCodeSignals via detectClientFull", () => {
-    test("should confirm when all 3 strong signals are present", () => {
+    test("should confirm when all 4 strong signals are present", () => {
       const session = createMockSession({
         userAgent: "claude-cli/1.0.0 (external, cli)",
         xApp: "cli",
-        anthropicBeta: "claude-code-cache-control-20260101",
+        anthropicBeta: "interleaved-thinking-2025-05-14",
+        metadataUserId: "user_abc",
       });
 
       const result = detectClientFull(session, "claude-code");
       expect(result.hubConfirmed).toBe(true);
-      expect(result.signals).toEqual(["x-app-cli", "ua-prefix", "betas-claude-code"]);
+      expect(result.signals).toEqual([
+        "x-app-cli",
+        "ua-prefix",
+        "betas-present",
+        "metadata-user-id",
+      ]);
       expect(result.supplementary).toEqual([]);
     });
 
+    test("should confirm when anthropic-beta has claude-code- prefix (backwards compat)", () => {
+      const session = createMockSession({
+        userAgent: "claude-cli/1.0.0 (external, cli)",
+        xApp: "cli",
+        anthropicBeta: "claude-code-cache-control-20260101",
+        metadataUserId: "user_abc",
+      });
+
+      const result = detectClientFull(session, "claude-code");
+      expect(result.hubConfirmed).toBe(true);
+      expect(result.signals).toEqual([
+        "x-app-cli",
+        "ua-prefix",
+        "betas-present",
+        "metadata-user-id",
+      ]);
+    });
+
     test.each([
       {
         name: "missing x-app",
         options: {
           userAgent: "claude-cli/1.0.0 (external, cli)",
-          anthropicBeta: "claude-code-foo",
+          anthropicBeta: "some-beta",
+          metadataUserId: "user_abc",
         },
       },
       {
@@ -106,22 +138,31 @@ describe("client-detector", () => {
         options: {
           userAgent: "GeminiCLI/1.0",
           xApp: "cli",
-          anthropicBeta: "claude-code-foo",
+          anthropicBeta: "some-beta",
+          metadataUserId: "user_abc",
+        },
+      },
+      {
+        name: "missing betas-present",
+        options: {
+          userAgent: "claude-cli/1.0.0 (external, cli)",
+          xApp: "cli",
+          metadataUserId: "user_abc",
         },
       },
       {
-        name: "missing betas-claude-code",
+        name: "missing metadata-user-id",
         options: {
           userAgent: "claude-cli/1.0.0 (external, cli)",
           xApp: "cli",
-          anthropicBeta: "not-claude-code",
+          anthropicBeta: "some-beta",
         },
       },
-    ])("should not confirm with only 2-of-3 signals: $name", ({ options }) => {
+    ])("should not confirm with only 3-of-4 signals: $name", ({ options }) => {
       const session = createMockSession(options);
       const result = detectClientFull(session, "claude-code");
       expect(result.hubConfirmed).toBe(false);
-      expect(result.signals.length).toBe(2);
+      expect(result.signals.length).toBe(3);
     });
 
     test("should not confirm with 0 strong signals", () => {
@@ -136,7 +177,6 @@ describe("client-detector", () => {
       const session = createMockSession({
         userAgent: "claude-cli/1.0.0 (external, cli)",
         xApp: "cli",
-        betas: ["not-claude-code"],
         dangerousBrowserAccess: "true",
       });
 
@@ -188,6 +228,7 @@ describe("client-detector", () => {
         userAgent: "claude-cli/1.2.3 external, cli",
         xApp: "cli",
         anthropicBeta: "claude-code-a",
+        metadataUserId: "user_abc",
       });
       const result = detectClientFull(session, "claude-code");
 
@@ -219,7 +260,7 @@ describe("client-detector", () => {
       expect(matchClientPattern(session, "claude-code-sdk-ts")).toBe(false);
     });
 
-    test("should return false when only 2-of-3 signals are present", () => {
+    test("should return false when only 3-of-4 signals are present (missing metadata-user-id)", () => {
       const session = createMockSession({
         userAgent: "claude-cli/1.2.3 (external, cli)",
         xApp: "cli",
@@ -383,14 +424,24 @@ describe("client-detector", () => {
     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.signals).toEqual([
+        "x-app-cli",
+        "ua-prefix",
+        "betas-present",
+        "metadata-user-id",
+      ]);
       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.signals).toEqual([
+        "x-app-cli",
+        "ua-prefix",
+        "betas-present",
+        "metadata-user-id",
+      ]);
       expect(result.hubConfirmed).toBe(true);
     });
 
@@ -411,7 +462,7 @@ describe("client-detector", () => {
         matched: true,
         hubConfirmed: true,
         subClient: "claude-code-sdk-ts",
-        signals: ["x-app-cli", "ua-prefix", "betas-claude-code"],
+        signals: ["x-app-cli", "ua-prefix", "betas-present", "metadata-user-id"],
         supplementary: [],
       });
     });

+ 73 - 0
tests/unit/proxy/client-guard.test.ts

@@ -29,6 +29,26 @@ function createMockSession(
   } as unknown as ProxySession;
 }
 
+// Helper for fully-confirmed Claude Code CLI sessions (all 4 signals)
+function createClaudeCodeSession(
+  userAgent: string,
+  allowedClients: string[] = [],
+  blockedClients: string[] = [],
+  betaHeader = "claude-code-test"
+): ProxySession {
+  const headers = new Headers();
+  headers.set("x-app", "cli");
+  headers.set("anthropic-beta", betaHeader);
+  return {
+    userAgent,
+    headers,
+    request: { message: { metadata: { user_id: "user_123" } } },
+    authState: {
+      user: { allowedClients, blockedClients },
+    },
+  } as unknown as ProxySession;
+}
+
 describe("ProxyClientGuard", () => {
   describe("when authState is missing", () => {
     test("should allow request when authState is undefined", async () => {
@@ -245,4 +265,57 @@ describe("ProxyClientGuard", () => {
       expect(result).toBeNull();
     });
   });
+
+  describe("claude-code builtin keyword with 4-signal detection", () => {
+    test("should allow claude-cli/2.0.70 with non-claude-code beta header (the bug fix)", async () => {
+      // CLI 2.0.70 sends interleaved-thinking-2025-05-14, not a claude-code-* beta
+      const session = createClaudeCodeSession(
+        "claude-cli/2.0.70 (external, cli)",
+        ["claude-code"],
+        [],
+        "interleaved-thinking-2025-05-14"
+      );
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+
+    test("should reject when metadata.user_id is missing (only 3-of-4 signals)", async () => {
+      const headers = new Headers();
+      headers.set("x-app", "cli");
+      headers.set("anthropic-beta", "some-beta");
+      const session = {
+        userAgent: "claude-cli/2.0.70 (external, cli)",
+        headers,
+        request: { message: {} },
+        authState: { user: { allowedClients: ["claude-code"], blockedClients: [] } },
+      } as unknown as import("@/app/v1/_lib/proxy/session").ProxySession;
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+      const body = await result!.json();
+      expect(body.error.message).toContain("Signals(3/4)");
+    });
+
+    test("should reject when anthropic-beta header is missing (only 3-of-4 signals)", async () => {
+      const headers = new Headers();
+      headers.set("x-app", "cli");
+      const session = {
+        userAgent: "claude-cli/2.0.70 (external, cli)",
+        headers,
+        request: { message: { metadata: { user_id: "user_abc" } } },
+        authState: { user: { allowedClients: ["claude-code"], blockedClients: [] } },
+      } as unknown as import("@/app/v1/_lib/proxy/session").ProxySession;
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).not.toBeNull();
+      expect(result?.status).toBe(400);
+      const body = await result!.json();
+      expect(body.error.message).toContain("Signals(3/4)");
+    });
+
+    test("should allow when all 4 signals present with claude-code allowlist", async () => {
+      const session = createClaudeCodeSession("claude-cli/1.0.0 (external, cli)", ["claude-code"]);
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
+  });
 });