client-detector.test.ts 16 KB

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