client-detector.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  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. const LEGACY_METADATA_USER_ID =
  13. "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_sess_legacy_123";
  14. const JSON_METADATA_USER_ID = JSON.stringify({
  15. device_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  16. account_uuid: "",
  17. session_id: "sess_json_123",
  18. });
  19. type SessionOptions = {
  20. userAgent?: string | null;
  21. xApp?: string | null;
  22. dangerousBrowserAccess?: string | null;
  23. anthropicBeta?: string | null;
  24. metadataUserId?: string | null;
  25. };
  26. function createMockSession(options: SessionOptions = {}): ProxySession {
  27. const headers = new Headers();
  28. if (options.xApp !== undefined && options.xApp !== null) {
  29. headers.set("x-app", options.xApp);
  30. }
  31. if (options.dangerousBrowserAccess !== undefined && options.dangerousBrowserAccess !== null) {
  32. headers.set("anthropic-dangerous-direct-browser-access", options.dangerousBrowserAccess);
  33. }
  34. if (options.anthropicBeta !== undefined && options.anthropicBeta !== null) {
  35. headers.set("anthropic-beta", options.anthropicBeta);
  36. }
  37. const message: Record<string, unknown> = {};
  38. if (options.metadataUserId !== undefined && options.metadataUserId !== null) {
  39. message.metadata = { user_id: options.metadataUserId };
  40. }
  41. return {
  42. userAgent: options.userAgent ?? null,
  43. headers,
  44. request: {
  45. message,
  46. },
  47. } as unknown as ProxySession;
  48. }
  49. function createConfirmedClaudeCodeSession(userAgent: string): ProxySession {
  50. return createMockSession({
  51. userAgent,
  52. xApp: "cli",
  53. anthropicBeta: "claude-code-test",
  54. metadataUserId: LEGACY_METADATA_USER_ID,
  55. });
  56. }
  57. describe("client-detector", () => {
  58. describe("constants", () => {
  59. test("CLAUDE_CODE_KEYWORD_PREFIX should be claude-code", () => {
  60. expect(CLAUDE_CODE_KEYWORD_PREFIX).toBe("claude-code");
  61. });
  62. test("BUILTIN_CLIENT_KEYWORDS should contain 7 items", () => {
  63. expect(BUILTIN_CLIENT_KEYWORDS.size).toBe(7);
  64. });
  65. });
  66. describe("isBuiltinKeyword", () => {
  67. test.each([
  68. "claude-code",
  69. "claude-code-cli",
  70. "claude-code-cli-sdk",
  71. "claude-code-vscode",
  72. "claude-code-sdk-ts",
  73. "claude-code-sdk-py",
  74. "claude-code-gh-action",
  75. ])("should return true for builtin keyword: %s", (pattern) => {
  76. expect(isBuiltinKeyword(pattern)).toBe(true);
  77. });
  78. test.each([
  79. "gemini-cli",
  80. "codex-cli",
  81. "custom-pattern",
  82. ])("should return false for non-builtin keyword: %s", (pattern) => {
  83. expect(isBuiltinKeyword(pattern)).toBe(false);
  84. });
  85. });
  86. describe("confirmClaudeCodeSignals via detectClientFull", () => {
  87. test("should confirm when all 4 strong signals are present", () => {
  88. const session = createMockSession({
  89. userAgent: "claude-cli/1.0.0 (external, cli)",
  90. xApp: "cli",
  91. anthropicBeta: "interleaved-thinking-2025-05-14",
  92. metadataUserId: LEGACY_METADATA_USER_ID,
  93. });
  94. const result = detectClientFull(session, "claude-code");
  95. expect(result.hubConfirmed).toBe(true);
  96. expect(result.signals).toEqual([
  97. "x-app-cli",
  98. "ua-prefix",
  99. "betas-present",
  100. "metadata-user-id",
  101. ]);
  102. expect(result.supplementary).toEqual([]);
  103. });
  104. test("should confirm when anthropic-beta has claude-code- prefix (backwards compat)", () => {
  105. const session = createMockSession({
  106. userAgent: "claude-cli/1.0.0 (external, cli)",
  107. xApp: "cli",
  108. anthropicBeta: "claude-code-cache-control-20260101",
  109. metadataUserId: LEGACY_METADATA_USER_ID,
  110. });
  111. const result = detectClientFull(session, "claude-code");
  112. expect(result.hubConfirmed).toBe(true);
  113. expect(result.signals).toEqual([
  114. "x-app-cli",
  115. "ua-prefix",
  116. "betas-present",
  117. "metadata-user-id",
  118. ]);
  119. });
  120. test("should confirm when metadata.user_id uses JSON string format", () => {
  121. const session = createMockSession({
  122. userAgent: "claude-cli/2.1.78 (external, cli)",
  123. xApp: "cli",
  124. anthropicBeta: "interleaved-thinking-2025-05-14",
  125. metadataUserId: JSON_METADATA_USER_ID,
  126. });
  127. const result = detectClientFull(session, "claude-code");
  128. expect(result.hubConfirmed).toBe(true);
  129. expect(result.signals).toEqual([
  130. "x-app-cli",
  131. "ua-prefix",
  132. "betas-present",
  133. "metadata-user-id",
  134. ]);
  135. });
  136. test.each([
  137. {
  138. name: "missing x-app",
  139. options: {
  140. userAgent: "claude-cli/1.0.0 (external, cli)",
  141. anthropicBeta: "some-beta",
  142. metadataUserId: LEGACY_METADATA_USER_ID,
  143. },
  144. },
  145. {
  146. name: "missing ua-prefix",
  147. options: {
  148. userAgent: "GeminiCLI/1.0",
  149. xApp: "cli",
  150. anthropicBeta: "some-beta",
  151. metadataUserId: LEGACY_METADATA_USER_ID,
  152. },
  153. },
  154. {
  155. name: "missing betas-present",
  156. options: {
  157. userAgent: "claude-cli/1.0.0 (external, cli)",
  158. xApp: "cli",
  159. metadataUserId: LEGACY_METADATA_USER_ID,
  160. },
  161. },
  162. {
  163. name: "missing metadata-user-id",
  164. options: {
  165. userAgent: "claude-cli/1.0.0 (external, cli)",
  166. xApp: "cli",
  167. anthropicBeta: "some-beta",
  168. },
  169. },
  170. ])("should not confirm with only 3-of-4 signals: $name", ({ options }) => {
  171. const session = createMockSession(options);
  172. const result = detectClientFull(session, "claude-code");
  173. expect(result.hubConfirmed).toBe(false);
  174. expect(result.signals.length).toBe(3);
  175. });
  176. test("should not confirm with 0 strong signals", () => {
  177. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  178. const result = detectClientFull(session, "claude-code");
  179. expect(result.hubConfirmed).toBe(false);
  180. expect(result.signals).toEqual([]);
  181. });
  182. test("should collect supplementary signal without counting it", () => {
  183. const session = createMockSession({
  184. userAgent: "claude-cli/1.0.0 (external, cli)",
  185. xApp: "cli",
  186. dangerousBrowserAccess: "true",
  187. });
  188. const result = detectClientFull(session, "claude-code");
  189. expect(result.hubConfirmed).toBe(false);
  190. expect(result.signals).toEqual(["x-app-cli", "ua-prefix"]);
  191. expect(result.supplementary).toEqual(["dangerous-browser-access"]);
  192. });
  193. });
  194. describe("extractSubClient via detectClientFull", () => {
  195. test.each([
  196. ["cli", "claude-code-cli"],
  197. ["sdk-cli", "claude-code-cli-sdk"],
  198. ["claude-vscode", "claude-code-vscode"],
  199. ["sdk-ts", "claude-code-sdk-ts"],
  200. ["sdk-py", "claude-code-sdk-py"],
  201. ["claude-code-github-action", "claude-code-gh-action"],
  202. ])("should map entrypoint %s to %s", (entrypoint, expectedSubClient) => {
  203. const session = createConfirmedClaudeCodeSession(
  204. `claude-cli/1.2.3 (external, ${entrypoint})`
  205. );
  206. const result = detectClientFull(session, "claude-code");
  207. expect(result.hubConfirmed).toBe(true);
  208. expect(result.subClient).toBe(expectedSubClient);
  209. });
  210. test("should return null for unknown entrypoint", () => {
  211. const session = createConfirmedClaudeCodeSession(
  212. "claude-cli/1.2.3 (external, unknown-entry)"
  213. );
  214. const result = detectClientFull(session, "claude-code");
  215. expect(result.hubConfirmed).toBe(true);
  216. expect(result.subClient).toBeNull();
  217. });
  218. test("should return null for malformed UA", () => {
  219. const session = createConfirmedClaudeCodeSession("claude-cli 1.2.3 (external, cli)");
  220. const result = detectClientFull(session, "claude-code");
  221. expect(result.hubConfirmed).toBe(false);
  222. expect(result.subClient).toBeNull();
  223. });
  224. test("should return null when UA has no parentheses section", () => {
  225. const session = createMockSession({
  226. userAgent: "claude-cli/1.2.3 external, cli",
  227. xApp: "cli",
  228. anthropicBeta: "claude-code-a",
  229. metadataUserId: LEGACY_METADATA_USER_ID,
  230. });
  231. const result = detectClientFull(session, "claude-code");
  232. expect(result.hubConfirmed).toBe(true);
  233. expect(result.subClient).toBeNull();
  234. });
  235. });
  236. describe("matchClientPattern builtin keyword path", () => {
  237. test("should match wildcard claude-code when 3-of-3 is confirmed", () => {
  238. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  239. expect(matchClientPattern(session, "claude-code")).toBe(true);
  240. });
  241. test("should match claude-code-cli for cli entrypoint", () => {
  242. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  243. expect(matchClientPattern(session, "claude-code-cli")).toBe(true);
  244. });
  245. test("should match claude-code-vscode for claude-vscode entrypoint", () => {
  246. const session = createConfirmedClaudeCodeSession(
  247. "claude-cli/1.2.3 (external, claude-vscode, agent-sdk/0.1.0)"
  248. );
  249. expect(matchClientPattern(session, "claude-code-vscode")).toBe(true);
  250. });
  251. test("should return false when sub-client does not match", () => {
  252. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-py)");
  253. expect(matchClientPattern(session, "claude-code-sdk-ts")).toBe(false);
  254. });
  255. test("should return false when only 3-of-4 signals are present (missing metadata-user-id)", () => {
  256. const session = createMockSession({
  257. userAgent: "claude-cli/1.2.3 (external, cli)",
  258. xApp: "cli",
  259. anthropicBeta: "non-claude-code",
  260. });
  261. expect(matchClientPattern(session, "claude-code")).toBe(false);
  262. });
  263. });
  264. describe("matchClientPattern custom substring path", () => {
  265. test("should match gemini-cli against GeminiCLI", () => {
  266. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  267. expect(matchClientPattern(session, "gemini-cli")).toBe(true);
  268. });
  269. test("should match codex-cli against codex_cli", () => {
  270. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  271. expect(matchClientPattern(session, "codex-cli")).toBe(true);
  272. });
  273. test("should match codex-cli against codex desktop alias", () => {
  274. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  275. expect(matchClientPattern(session, "codex-cli")).toBe(true);
  276. });
  277. test("should match codex_vscode against codex desktop alias", () => {
  278. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  279. expect(matchClientPattern(session, "codex_vscode")).toBe(true);
  280. });
  281. test("should return false when User-Agent is empty", () => {
  282. const session = createMockSession({ userAgent: " " });
  283. expect(matchClientPattern(session, "gemini-cli")).toBe(false);
  284. });
  285. test("should return false when custom pattern is not found", () => {
  286. const session = createMockSession({ userAgent: "Mozilla/5.0 Compatible" });
  287. expect(matchClientPattern(session, "gemini-cli")).toBe(false);
  288. });
  289. test("should return false when pattern normalizes to empty", () => {
  290. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  291. expect(matchClientPattern(session, "-_-")).toBe(false);
  292. });
  293. });
  294. describe("matchClientPattern glob wildcard path", () => {
  295. test("should match codex-* against codex-cli/2.0", () => {
  296. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  297. expect(matchClientPattern(session, "codex-*")).toBe(true);
  298. });
  299. test("should not match codex-* against GeminiCLI/1.0", () => {
  300. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  301. expect(matchClientPattern(session, "codex-*")).toBe(false);
  302. });
  303. test("should match *-cli* against codex-cli/2.0", () => {
  304. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  305. expect(matchClientPattern(session, "*-cli*")).toBe(true);
  306. });
  307. test("should match bare * against any non-empty UA", () => {
  308. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  309. expect(matchClientPattern(session, "*")).toBe(true);
  310. });
  311. test("should match My*App against MyCustomApp/1.0", () => {
  312. const session = createMockSession({ userAgent: "MyCustomApp/1.0" });
  313. expect(matchClientPattern(session, "My*App*")).toBe(true);
  314. });
  315. test("should be case-insensitive for glob", () => {
  316. const session = createMockSession({ userAgent: "codex-cli/1.0" });
  317. expect(matchClientPattern(session, "CODEX-*")).toBe(true);
  318. });
  319. test("should return false for glob when UA is empty", () => {
  320. const session = createMockSession({ userAgent: " " });
  321. expect(matchClientPattern(session, "codex-*")).toBe(false);
  322. });
  323. test("should NOT normalize hyphens/underscores in glob mode", () => {
  324. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  325. expect(matchClientPattern(session, "codex-*")).toBe(false);
  326. });
  327. test("should match glob with underscores literally", () => {
  328. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  329. expect(matchClientPattern(session, "codex_*")).toBe(true);
  330. });
  331. test("consecutive wildcards ** should behave like single *", () => {
  332. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  333. expect(matchClientPattern(session, "codex-**")).toBe(true);
  334. expect(matchClientPattern(session, "**codex**")).toBe(true);
  335. });
  336. test("glob should handle regex metacharacters literally", () => {
  337. const session = createMockSession({ userAgent: "foo.bar/1.0" });
  338. expect(matchClientPattern(session, "foo.bar*")).toBe(true);
  339. expect(matchClientPattern(session, "foo*bar*")).toBe(true);
  340. const session2 = createMockSession({ userAgent: "fooXbar/1.0" });
  341. expect(matchClientPattern(session2, "foo.bar*")).toBe(false);
  342. });
  343. test("glob should handle brackets and parens literally", () => {
  344. const session = createMockSession({ userAgent: "tool[v2]/1.0" });
  345. expect(matchClientPattern(session, "tool[v2]*")).toBe(true);
  346. });
  347. test("pathological glob pattern completes quickly without ReDoS", () => {
  348. const session = createMockSession({ userAgent: `${"a".repeat(32)}b` });
  349. const pattern = "*a*a*a*a*a*a*a*a*c";
  350. const start = performance.now();
  351. const result = matchClientPattern(session, pattern);
  352. const elapsed = performance.now() - start;
  353. expect(result).toBe(false);
  354. expect(elapsed).toBeLessThan(50);
  355. });
  356. });
  357. describe("isClientAllowed", () => {
  358. test("should reject when blocked matches even if allowed also matches", () => {
  359. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  360. expect(isClientAllowed(session, ["claude-code"], ["claude-code"])).toBe(false);
  361. });
  362. test("should allow when allowedClients and blockedClients are both empty", () => {
  363. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  364. expect(isClientAllowed(session, [], [])).toBe(true);
  365. });
  366. test("should allow when allowedClients match", () => {
  367. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  368. expect(isClientAllowed(session, ["gemini-cli"])).toBe(true);
  369. });
  370. test("should reject when allowedClients are set but none match", () => {
  371. const session = createMockSession({ userAgent: "UnknownClient/1.0" });
  372. expect(isClientAllowed(session, ["gemini-cli"])).toBe(false);
  373. });
  374. test("should reject when only blockedClients are set and blocked matches", () => {
  375. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  376. expect(isClientAllowed(session, [], ["gemini-cli"])).toBe(false);
  377. });
  378. test("should allow when only blockedClients are set and blocked does not match", () => {
  379. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  380. expect(isClientAllowed(session, [], ["codex-cli"])).toBe(true);
  381. });
  382. test("should allow when blocked does not match and allowed matches", () => {
  383. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  384. expect(isClientAllowed(session, ["codex-cli"], ["gemini-cli"])).toBe(true);
  385. });
  386. test("should allow codex desktop alias when codex-cli is allowlisted", () => {
  387. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  388. expect(isClientAllowed(session, ["codex-cli"], [])).toBe(true);
  389. });
  390. });
  391. describe("isClientAllowedDetailed", () => {
  392. test("should return no_restriction when both lists are empty", () => {
  393. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  394. const result = isClientAllowedDetailed(session, [], []);
  395. expect(result).toEqual({
  396. allowed: true,
  397. matchType: "no_restriction",
  398. matchedPattern: undefined,
  399. detectedClient: undefined,
  400. checkedAllowlist: [],
  401. checkedBlocklist: [],
  402. });
  403. });
  404. test("should return blocklist_hit with matched pattern", () => {
  405. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  406. const result = isClientAllowedDetailed(session, [], ["gemini-cli"]);
  407. expect(result.allowed).toBe(false);
  408. expect(result.matchType).toBe("blocklist_hit");
  409. expect(result.matchedPattern).toBe("gemini-cli");
  410. expect(result.detectedClient).toBe("GeminiCLI/1.0");
  411. expect(result.checkedBlocklist).toEqual(["gemini-cli"]);
  412. });
  413. test("should return allowlist_miss when no allowlist pattern matches", () => {
  414. const session = createMockSession({ userAgent: "UnknownClient/1.0" });
  415. const result = isClientAllowedDetailed(session, ["gemini-cli", "codex-cli"], []);
  416. expect(result.allowed).toBe(false);
  417. expect(result.matchType).toBe("allowlist_miss");
  418. expect(result.matchedPattern).toBeUndefined();
  419. expect(result.detectedClient).toBe("UnknownClient/1.0");
  420. expect(result.checkedAllowlist).toEqual(["gemini-cli", "codex-cli"]);
  421. });
  422. test("should return allowed when allowlist matches", () => {
  423. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  424. const result = isClientAllowedDetailed(session, ["gemini-cli"], []);
  425. expect(result.allowed).toBe(true);
  426. expect(result.matchType).toBe("allowed");
  427. expect(result.matchedPattern).toBe("gemini-cli");
  428. expect(result.detectedClient).toBe("GeminiCLI/1.0");
  429. });
  430. test("blocklist takes precedence over allowlist", () => {
  431. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  432. const result = isClientAllowedDetailed(session, ["claude-code"], ["claude-code"]);
  433. expect(result.allowed).toBe(false);
  434. expect(result.matchType).toBe("blocklist_hit");
  435. expect(result.matchedPattern).toBe("claude-code");
  436. });
  437. test("should detect sub-client for builtin keywords", () => {
  438. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  439. const result = isClientAllowedDetailed(session, ["claude-code"], []);
  440. expect(result.allowed).toBe(true);
  441. expect(result.matchType).toBe("allowed");
  442. expect(result.detectedClient).toBe("claude-code-sdk-ts");
  443. expect(result.matchedPattern).toBe("claude-code");
  444. });
  445. test("should return allowed when only blocklist set and no match", () => {
  446. const session = createMockSession({ userAgent: "CodexCLI/1.0" });
  447. const result = isClientAllowedDetailed(session, [], ["gemini-cli"]);
  448. expect(result.allowed).toBe(true);
  449. expect(result.matchType).toBe("allowed");
  450. expect(result.detectedClient).toBe("CodexCLI/1.0");
  451. });
  452. test("should return no_restriction when blockedClients is undefined and allowlist empty", () => {
  453. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  454. const result = isClientAllowedDetailed(session, []);
  455. expect(result.allowed).toBe(true);
  456. expect(result.matchType).toBe("no_restriction");
  457. });
  458. test("should capture first matching blocked pattern", () => {
  459. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  460. const result = isClientAllowedDetailed(
  461. session,
  462. [],
  463. ["codex-cli", "gemini-cli", "factory-cli"]
  464. );
  465. expect(result.allowed).toBe(false);
  466. expect(result.matchType).toBe("blocklist_hit");
  467. expect(result.matchedPattern).toBe("gemini-cli");
  468. });
  469. test("should include signals and hubConfirmed when builtin keyword is in allowlist", () => {
  470. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  471. const result = isClientAllowedDetailed(session, ["claude-code"], []);
  472. expect(result.signals).toEqual([
  473. "x-app-cli",
  474. "ua-prefix",
  475. "betas-present",
  476. "metadata-user-id",
  477. ]);
  478. expect(result.hubConfirmed).toBe(true);
  479. });
  480. test("should include signals and hubConfirmed when builtin keyword is in blocklist", () => {
  481. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  482. const result = isClientAllowedDetailed(session, [], ["claude-code"]);
  483. expect(result.signals).toEqual([
  484. "x-app-cli",
  485. "ua-prefix",
  486. "betas-present",
  487. "metadata-user-id",
  488. ]);
  489. expect(result.hubConfirmed).toBe(true);
  490. });
  491. test("should not include signals when no builtin keyword is in lists", () => {
  492. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  493. const result = isClientAllowedDetailed(session, ["gemini-cli"], []);
  494. expect(result.signals).toBeUndefined();
  495. expect(result.hubConfirmed).toBeUndefined();
  496. });
  497. test("should allow when glob pattern in allowlist matches", () => {
  498. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  499. const result = isClientAllowedDetailed(session, ["codex-*"], []);
  500. expect(result.allowed).toBe(true);
  501. expect(result.matchType).toBe("allowed");
  502. expect(result.matchedPattern).toBe("codex-*");
  503. });
  504. test("should reject when glob pattern in blocklist matches", () => {
  505. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  506. const result = isClientAllowedDetailed(session, [], ["codex-*"]);
  507. expect(result.allowed).toBe(false);
  508. expect(result.matchType).toBe("blocklist_hit");
  509. expect(result.matchedPattern).toBe("codex-*");
  510. });
  511. test("should work with mix of glob and substring patterns", () => {
  512. const session = createMockSession({ userAgent: "my-custom-tool/3.0" });
  513. const result = isClientAllowedDetailed(session, ["codex-*", "custom"], []);
  514. expect(result.allowed).toBe(true);
  515. expect(result.matchType).toBe("allowed");
  516. expect(result.matchedPattern).toBe("custom");
  517. });
  518. });
  519. describe("detectClientFull", () => {
  520. test("should return matched=true for confirmed claude-code wildcard", () => {
  521. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  522. const result = detectClientFull(session, "claude-code");
  523. expect(result).toEqual({
  524. matched: true,
  525. hubConfirmed: true,
  526. subClient: "claude-code-sdk-ts",
  527. signals: ["x-app-cli", "ua-prefix", "betas-present", "metadata-user-id"],
  528. supplementary: [],
  529. });
  530. });
  531. test("should return matched=false for confirmed but different builtin sub-client", () => {
  532. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  533. const result = detectClientFull(session, "claude-code-cli");
  534. expect(result.hubConfirmed).toBe(true);
  535. expect(result.subClient).toBe("claude-code-sdk-ts");
  536. expect(result.matched).toBe(false);
  537. });
  538. test("should use custom normalization path for non-builtin patterns", () => {
  539. const session = createMockSession({ userAgent: "GeminiCLI/0.22.5" });
  540. const result = detectClientFull(session, "gemini-cli");
  541. expect(result.matched).toBe(true);
  542. expect(result.hubConfirmed).toBe(false);
  543. expect(result.subClient).toBeNull();
  544. });
  545. test("should return matched=false for custom pattern when User-Agent is missing", () => {
  546. const session = createMockSession({ userAgent: null });
  547. const result = detectClientFull(session, "gemini-cli");
  548. expect(result.matched).toBe(false);
  549. expect(result.hubConfirmed).toBe(false);
  550. expect(result.signals).toEqual([]);
  551. expect(result.supplementary).toEqual([]);
  552. });
  553. test("should match codex desktop alias in detectClientFull", () => {
  554. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  555. const result = detectClientFull(session, "codex-cli");
  556. expect(result.matched).toBe(true);
  557. expect(result.hubConfirmed).toBe(false);
  558. expect(result.subClient).toBeNull();
  559. });
  560. test("should match codex_vscode via codex desktop alias in detectClientFull", () => {
  561. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  562. const result = detectClientFull(session, "codex_vscode");
  563. expect(result.matched).toBe(true);
  564. expect(result.hubConfirmed).toBe(false);
  565. });
  566. });
  567. });