client-detector.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { describe, expect, test } from "vitest";
  2. import {
  3. BUILTIN_CLIENT_KEYWORDS,
  4. CLAUDE_CODE_KEYWORD_PREFIX,
  5. detectClientFull,
  6. isBuiltinKeyword,
  7. isClientAllowed,
  8. isClientAllowedDetailed,
  9. matchClientPattern,
  10. } from "@/app/v1/_lib/proxy/client-detector";
  11. import type { ProxySession } from "@/app/v1/_lib/proxy/session";
  12. type SessionOptions = {
  13. userAgent?: string | null;
  14. xApp?: string | null;
  15. dangerousBrowserAccess?: string | null;
  16. anthropicBeta?: string | null;
  17. metadataUserId?: string | null;
  18. };
  19. function createMockSession(options: SessionOptions = {}): ProxySession {
  20. const headers = new Headers();
  21. if (options.xApp !== undefined && options.xApp !== null) {
  22. headers.set("x-app", options.xApp);
  23. }
  24. if (options.dangerousBrowserAccess !== undefined && options.dangerousBrowserAccess !== null) {
  25. headers.set("anthropic-dangerous-direct-browser-access", options.dangerousBrowserAccess);
  26. }
  27. if (options.anthropicBeta !== undefined && options.anthropicBeta !== null) {
  28. headers.set("anthropic-beta", options.anthropicBeta);
  29. }
  30. const message: Record<string, unknown> = {};
  31. if (options.metadataUserId !== undefined && options.metadataUserId !== null) {
  32. message.metadata = { user_id: options.metadataUserId };
  33. }
  34. return {
  35. userAgent: options.userAgent ?? null,
  36. headers,
  37. request: {
  38. message,
  39. },
  40. } as unknown as ProxySession;
  41. }
  42. function createConfirmedClaudeCodeSession(userAgent: string): ProxySession {
  43. return createMockSession({
  44. userAgent,
  45. xApp: "cli",
  46. anthropicBeta: "claude-code-test",
  47. metadataUserId: "user_123",
  48. });
  49. }
  50. describe("client-detector", () => {
  51. describe("constants", () => {
  52. test("CLAUDE_CODE_KEYWORD_PREFIX should be claude-code", () => {
  53. expect(CLAUDE_CODE_KEYWORD_PREFIX).toBe("claude-code");
  54. });
  55. test("BUILTIN_CLIENT_KEYWORDS should contain 7 items", () => {
  56. expect(BUILTIN_CLIENT_KEYWORDS.size).toBe(7);
  57. });
  58. });
  59. describe("isBuiltinKeyword", () => {
  60. test.each([
  61. "claude-code",
  62. "claude-code-cli",
  63. "claude-code-cli-sdk",
  64. "claude-code-vscode",
  65. "claude-code-sdk-ts",
  66. "claude-code-sdk-py",
  67. "claude-code-gh-action",
  68. ])("should return true for builtin keyword: %s", (pattern) => {
  69. expect(isBuiltinKeyword(pattern)).toBe(true);
  70. });
  71. test.each([
  72. "gemini-cli",
  73. "codex-cli",
  74. "custom-pattern",
  75. ])("should return false for non-builtin keyword: %s", (pattern) => {
  76. expect(isBuiltinKeyword(pattern)).toBe(false);
  77. });
  78. });
  79. describe("confirmClaudeCodeSignals via detectClientFull", () => {
  80. test("should confirm when all 4 strong signals are present", () => {
  81. const session = createMockSession({
  82. userAgent: "claude-cli/1.0.0 (external, cli)",
  83. xApp: "cli",
  84. anthropicBeta: "interleaved-thinking-2025-05-14",
  85. metadataUserId: "user_abc",
  86. });
  87. const result = detectClientFull(session, "claude-code");
  88. expect(result.hubConfirmed).toBe(true);
  89. expect(result.signals).toEqual([
  90. "x-app-cli",
  91. "ua-prefix",
  92. "betas-present",
  93. "metadata-user-id",
  94. ]);
  95. expect(result.supplementary).toEqual([]);
  96. });
  97. test("should confirm when anthropic-beta has claude-code- prefix (backwards compat)", () => {
  98. const session = createMockSession({
  99. userAgent: "claude-cli/1.0.0 (external, cli)",
  100. xApp: "cli",
  101. anthropicBeta: "claude-code-cache-control-20260101",
  102. metadataUserId: "user_abc",
  103. });
  104. const result = detectClientFull(session, "claude-code");
  105. expect(result.hubConfirmed).toBe(true);
  106. expect(result.signals).toEqual([
  107. "x-app-cli",
  108. "ua-prefix",
  109. "betas-present",
  110. "metadata-user-id",
  111. ]);
  112. });
  113. test.each([
  114. {
  115. name: "missing x-app",
  116. options: {
  117. userAgent: "claude-cli/1.0.0 (external, cli)",
  118. anthropicBeta: "some-beta",
  119. metadataUserId: "user_abc",
  120. },
  121. },
  122. {
  123. name: "missing ua-prefix",
  124. options: {
  125. userAgent: "GeminiCLI/1.0",
  126. xApp: "cli",
  127. anthropicBeta: "some-beta",
  128. metadataUserId: "user_abc",
  129. },
  130. },
  131. {
  132. name: "missing betas-present",
  133. options: {
  134. userAgent: "claude-cli/1.0.0 (external, cli)",
  135. xApp: "cli",
  136. metadataUserId: "user_abc",
  137. },
  138. },
  139. {
  140. name: "missing metadata-user-id",
  141. options: {
  142. userAgent: "claude-cli/1.0.0 (external, cli)",
  143. xApp: "cli",
  144. anthropicBeta: "some-beta",
  145. },
  146. },
  147. ])("should not confirm with only 3-of-4 signals: $name", ({ options }) => {
  148. const session = createMockSession(options);
  149. const result = detectClientFull(session, "claude-code");
  150. expect(result.hubConfirmed).toBe(false);
  151. expect(result.signals.length).toBe(3);
  152. });
  153. test("should not confirm with 0 strong signals", () => {
  154. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  155. const result = detectClientFull(session, "claude-code");
  156. expect(result.hubConfirmed).toBe(false);
  157. expect(result.signals).toEqual([]);
  158. });
  159. test("should collect supplementary signal without counting it", () => {
  160. const session = createMockSession({
  161. userAgent: "claude-cli/1.0.0 (external, cli)",
  162. xApp: "cli",
  163. dangerousBrowserAccess: "true",
  164. });
  165. const result = detectClientFull(session, "claude-code");
  166. expect(result.hubConfirmed).toBe(false);
  167. expect(result.signals).toEqual(["x-app-cli", "ua-prefix"]);
  168. expect(result.supplementary).toEqual(["dangerous-browser-access"]);
  169. });
  170. });
  171. describe("extractSubClient via detectClientFull", () => {
  172. test.each([
  173. ["cli", "claude-code-cli"],
  174. ["sdk-cli", "claude-code-cli-sdk"],
  175. ["claude-vscode", "claude-code-vscode"],
  176. ["sdk-ts", "claude-code-sdk-ts"],
  177. ["sdk-py", "claude-code-sdk-py"],
  178. ["claude-code-github-action", "claude-code-gh-action"],
  179. ])("should map entrypoint %s to %s", (entrypoint, expectedSubClient) => {
  180. const session = createConfirmedClaudeCodeSession(
  181. `claude-cli/1.2.3 (external, ${entrypoint})`
  182. );
  183. const result = detectClientFull(session, "claude-code");
  184. expect(result.hubConfirmed).toBe(true);
  185. expect(result.subClient).toBe(expectedSubClient);
  186. });
  187. test("should return null for unknown entrypoint", () => {
  188. const session = createConfirmedClaudeCodeSession(
  189. "claude-cli/1.2.3 (external, unknown-entry)"
  190. );
  191. const result = detectClientFull(session, "claude-code");
  192. expect(result.hubConfirmed).toBe(true);
  193. expect(result.subClient).toBeNull();
  194. });
  195. test("should return null for malformed UA", () => {
  196. const session = createConfirmedClaudeCodeSession("claude-cli 1.2.3 (external, cli)");
  197. const result = detectClientFull(session, "claude-code");
  198. expect(result.hubConfirmed).toBe(false);
  199. expect(result.subClient).toBeNull();
  200. });
  201. test("should return null when UA has no parentheses section", () => {
  202. const session = createMockSession({
  203. userAgent: "claude-cli/1.2.3 external, cli",
  204. xApp: "cli",
  205. anthropicBeta: "claude-code-a",
  206. metadataUserId: "user_abc",
  207. });
  208. const result = detectClientFull(session, "claude-code");
  209. expect(result.hubConfirmed).toBe(true);
  210. expect(result.subClient).toBeNull();
  211. });
  212. });
  213. describe("matchClientPattern builtin keyword path", () => {
  214. test("should match wildcard claude-code when 3-of-3 is confirmed", () => {
  215. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  216. expect(matchClientPattern(session, "claude-code")).toBe(true);
  217. });
  218. test("should match claude-code-cli for cli entrypoint", () => {
  219. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  220. expect(matchClientPattern(session, "claude-code-cli")).toBe(true);
  221. });
  222. test("should match claude-code-vscode for claude-vscode entrypoint", () => {
  223. const session = createConfirmedClaudeCodeSession(
  224. "claude-cli/1.2.3 (external, claude-vscode, agent-sdk/0.1.0)"
  225. );
  226. expect(matchClientPattern(session, "claude-code-vscode")).toBe(true);
  227. });
  228. test("should return false when sub-client does not match", () => {
  229. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-py)");
  230. expect(matchClientPattern(session, "claude-code-sdk-ts")).toBe(false);
  231. });
  232. test("should return false when only 3-of-4 signals are present (missing metadata-user-id)", () => {
  233. const session = createMockSession({
  234. userAgent: "claude-cli/1.2.3 (external, cli)",
  235. xApp: "cli",
  236. anthropicBeta: "non-claude-code",
  237. });
  238. expect(matchClientPattern(session, "claude-code")).toBe(false);
  239. });
  240. });
  241. describe("matchClientPattern custom substring path", () => {
  242. test("should match gemini-cli against GeminiCLI", () => {
  243. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  244. expect(matchClientPattern(session, "gemini-cli")).toBe(true);
  245. });
  246. test("should match codex-cli against codex_cli", () => {
  247. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  248. expect(matchClientPattern(session, "codex-cli")).toBe(true);
  249. });
  250. test("should return false when User-Agent is empty", () => {
  251. const session = createMockSession({ userAgent: " " });
  252. expect(matchClientPattern(session, "gemini-cli")).toBe(false);
  253. });
  254. test("should return false when custom pattern is not found", () => {
  255. const session = createMockSession({ userAgent: "Mozilla/5.0 Compatible" });
  256. expect(matchClientPattern(session, "gemini-cli")).toBe(false);
  257. });
  258. test("should return false when pattern normalizes to empty", () => {
  259. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  260. expect(matchClientPattern(session, "-_-")).toBe(false);
  261. });
  262. });
  263. describe("isClientAllowed", () => {
  264. test("should reject when blocked matches even if allowed also matches", () => {
  265. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  266. expect(isClientAllowed(session, ["claude-code"], ["claude-code"])).toBe(false);
  267. });
  268. test("should allow when allowedClients and blockedClients are both empty", () => {
  269. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  270. expect(isClientAllowed(session, [], [])).toBe(true);
  271. });
  272. test("should allow when allowedClients match", () => {
  273. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  274. expect(isClientAllowed(session, ["gemini-cli"])).toBe(true);
  275. });
  276. test("should reject when allowedClients are set but none match", () => {
  277. const session = createMockSession({ userAgent: "UnknownClient/1.0" });
  278. expect(isClientAllowed(session, ["gemini-cli"])).toBe(false);
  279. });
  280. test("should reject when only blockedClients are set and blocked matches", () => {
  281. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  282. expect(isClientAllowed(session, [], ["gemini-cli"])).toBe(false);
  283. });
  284. test("should allow when only blockedClients are set and blocked does not match", () => {
  285. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  286. expect(isClientAllowed(session, [], ["codex-cli"])).toBe(true);
  287. });
  288. test("should allow when blocked does not match and allowed matches", () => {
  289. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  290. expect(isClientAllowed(session, ["codex-cli"], ["gemini-cli"])).toBe(true);
  291. });
  292. });
  293. describe("isClientAllowedDetailed", () => {
  294. test("should return no_restriction when both lists are empty", () => {
  295. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  296. const result = isClientAllowedDetailed(session, [], []);
  297. expect(result).toEqual({
  298. allowed: true,
  299. matchType: "no_restriction",
  300. matchedPattern: undefined,
  301. detectedClient: undefined,
  302. checkedAllowlist: [],
  303. checkedBlocklist: [],
  304. });
  305. });
  306. test("should return blocklist_hit with matched pattern", () => {
  307. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  308. const result = isClientAllowedDetailed(session, [], ["gemini-cli"]);
  309. expect(result.allowed).toBe(false);
  310. expect(result.matchType).toBe("blocklist_hit");
  311. expect(result.matchedPattern).toBe("gemini-cli");
  312. expect(result.detectedClient).toBe("GeminiCLI/1.0");
  313. expect(result.checkedBlocklist).toEqual(["gemini-cli"]);
  314. });
  315. test("should return allowlist_miss when no allowlist pattern matches", () => {
  316. const session = createMockSession({ userAgent: "UnknownClient/1.0" });
  317. const result = isClientAllowedDetailed(session, ["gemini-cli", "codex-cli"], []);
  318. expect(result.allowed).toBe(false);
  319. expect(result.matchType).toBe("allowlist_miss");
  320. expect(result.matchedPattern).toBeUndefined();
  321. expect(result.detectedClient).toBe("UnknownClient/1.0");
  322. expect(result.checkedAllowlist).toEqual(["gemini-cli", "codex-cli"]);
  323. });
  324. test("should return allowed when allowlist matches", () => {
  325. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  326. const result = isClientAllowedDetailed(session, ["gemini-cli"], []);
  327. expect(result.allowed).toBe(true);
  328. expect(result.matchType).toBe("allowed");
  329. expect(result.matchedPattern).toBe("gemini-cli");
  330. expect(result.detectedClient).toBe("GeminiCLI/1.0");
  331. });
  332. test("blocklist takes precedence over allowlist", () => {
  333. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  334. const result = isClientAllowedDetailed(session, ["claude-code"], ["claude-code"]);
  335. expect(result.allowed).toBe(false);
  336. expect(result.matchType).toBe("blocklist_hit");
  337. expect(result.matchedPattern).toBe("claude-code");
  338. });
  339. test("should detect sub-client for builtin keywords", () => {
  340. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  341. const result = isClientAllowedDetailed(session, ["claude-code"], []);
  342. expect(result.allowed).toBe(true);
  343. expect(result.matchType).toBe("allowed");
  344. expect(result.detectedClient).toBe("claude-code-sdk-ts");
  345. expect(result.matchedPattern).toBe("claude-code");
  346. });
  347. test("should return allowed when only blocklist set and no match", () => {
  348. const session = createMockSession({ userAgent: "CodexCLI/1.0" });
  349. const result = isClientAllowedDetailed(session, [], ["gemini-cli"]);
  350. expect(result.allowed).toBe(true);
  351. expect(result.matchType).toBe("allowed");
  352. expect(result.detectedClient).toBe("CodexCLI/1.0");
  353. });
  354. test("should return no_restriction when blockedClients is undefined and allowlist empty", () => {
  355. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  356. const result = isClientAllowedDetailed(session, []);
  357. expect(result.allowed).toBe(true);
  358. expect(result.matchType).toBe("no_restriction");
  359. });
  360. test("should capture first matching blocked pattern", () => {
  361. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  362. const result = isClientAllowedDetailed(
  363. session,
  364. [],
  365. ["codex-cli", "gemini-cli", "factory-cli"]
  366. );
  367. expect(result.allowed).toBe(false);
  368. expect(result.matchType).toBe("blocklist_hit");
  369. expect(result.matchedPattern).toBe("gemini-cli");
  370. });
  371. test("should include signals and hubConfirmed when builtin keyword is in allowlist", () => {
  372. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  373. const result = isClientAllowedDetailed(session, ["claude-code"], []);
  374. expect(result.signals).toEqual([
  375. "x-app-cli",
  376. "ua-prefix",
  377. "betas-present",
  378. "metadata-user-id",
  379. ]);
  380. expect(result.hubConfirmed).toBe(true);
  381. });
  382. test("should include signals and hubConfirmed when builtin keyword is in blocklist", () => {
  383. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  384. const result = isClientAllowedDetailed(session, [], ["claude-code"]);
  385. expect(result.signals).toEqual([
  386. "x-app-cli",
  387. "ua-prefix",
  388. "betas-present",
  389. "metadata-user-id",
  390. ]);
  391. expect(result.hubConfirmed).toBe(true);
  392. });
  393. test("should not include signals when no builtin keyword is in lists", () => {
  394. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  395. const result = isClientAllowedDetailed(session, ["gemini-cli"], []);
  396. expect(result.signals).toBeUndefined();
  397. expect(result.hubConfirmed).toBeUndefined();
  398. });
  399. });
  400. describe("detectClientFull", () => {
  401. test("should return matched=true for confirmed claude-code wildcard", () => {
  402. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  403. const result = detectClientFull(session, "claude-code");
  404. expect(result).toEqual({
  405. matched: true,
  406. hubConfirmed: true,
  407. subClient: "claude-code-sdk-ts",
  408. signals: ["x-app-cli", "ua-prefix", "betas-present", "metadata-user-id"],
  409. supplementary: [],
  410. });
  411. });
  412. test("should return matched=false for confirmed but different builtin sub-client", () => {
  413. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  414. const result = detectClientFull(session, "claude-code-cli");
  415. expect(result.hubConfirmed).toBe(true);
  416. expect(result.subClient).toBe("claude-code-sdk-ts");
  417. expect(result.matched).toBe(false);
  418. });
  419. test("should use custom normalization path for non-builtin patterns", () => {
  420. const session = createMockSession({ userAgent: "GeminiCLI/0.22.5" });
  421. const result = detectClientFull(session, "gemini-cli");
  422. expect(result.matched).toBe(true);
  423. expect(result.hubConfirmed).toBe(false);
  424. expect(result.subClient).toBeNull();
  425. });
  426. test("should return matched=false for custom pattern when User-Agent is missing", () => {
  427. const session = createMockSession({ userAgent: null });
  428. const result = detectClientFull(session, "gemini-cli");
  429. expect(result.matched).toBe(false);
  430. expect(result.hubConfirmed).toBe(false);
  431. expect(result.signals).toEqual([]);
  432. expect(result.supplementary).toEqual([]);
  433. });
  434. });
  435. });