client-detector.test.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  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 NOT match codex_vscode against codex desktop UA (different family)", () => {
  278. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  279. expect(matchClientPattern(session, "codex_vscode")).toBe(false);
  280. });
  281. test("should match codex-tui UA against codex-cli", () => {
  282. const session = createMockSession({
  283. userAgent:
  284. "codex-tui/0.115.0 (Mac OS 15.7.3; arm64) Apple_Terminal/455.1 (codex-tui; 0.115.0)",
  285. });
  286. expect(matchClientPattern(session, "codex-cli")).toBe(true);
  287. });
  288. test("should match codex_cli_rs UA against codex-cli", () => {
  289. const session = createMockSession({
  290. userAgent: "codex_cli_rs/0.114.0 (Windows 10.0.26200; x86_64) xterm-256color",
  291. });
  292. expect(matchClientPattern(session, "codex-cli")).toBe(true);
  293. });
  294. test("should match codex_exec UA against codex-cli", () => {
  295. const session = createMockSession({ userAgent: "codex_exec/1.0.0" });
  296. expect(matchClientPattern(session, "codex-cli")).toBe(true);
  297. });
  298. test("should return false when User-Agent is empty", () => {
  299. const session = createMockSession({ userAgent: " " });
  300. expect(matchClientPattern(session, "gemini-cli")).toBe(false);
  301. });
  302. test("should return false when custom pattern is not found", () => {
  303. const session = createMockSession({ userAgent: "Mozilla/5.0 Compatible" });
  304. expect(matchClientPattern(session, "gemini-cli")).toBe(false);
  305. });
  306. test("should return false when pattern normalizes to empty", () => {
  307. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  308. expect(matchClientPattern(session, "-_-")).toBe(false);
  309. });
  310. });
  311. describe("matchClientPattern glob wildcard path", () => {
  312. test("should match codex-* against codex-cli/2.0", () => {
  313. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  314. expect(matchClientPattern(session, "codex-*")).toBe(true);
  315. });
  316. test("should not match codex-* against GeminiCLI/1.0", () => {
  317. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  318. expect(matchClientPattern(session, "codex-*")).toBe(false);
  319. });
  320. test("should match *-cli* against codex-cli/2.0", () => {
  321. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  322. expect(matchClientPattern(session, "*-cli*")).toBe(true);
  323. });
  324. test("should match bare * against any non-empty UA", () => {
  325. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  326. expect(matchClientPattern(session, "*")).toBe(true);
  327. });
  328. test("should match My*App against MyCustomApp/1.0", () => {
  329. const session = createMockSession({ userAgent: "MyCustomApp/1.0" });
  330. expect(matchClientPattern(session, "My*App*")).toBe(true);
  331. });
  332. test("should be case-insensitive for glob", () => {
  333. const session = createMockSession({ userAgent: "codex-cli/1.0" });
  334. expect(matchClientPattern(session, "CODEX-*")).toBe(true);
  335. });
  336. test("should return false for glob when UA is empty", () => {
  337. const session = createMockSession({ userAgent: " " });
  338. expect(matchClientPattern(session, "codex-*")).toBe(false);
  339. });
  340. test("should NOT normalize hyphens/underscores in glob mode", () => {
  341. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  342. expect(matchClientPattern(session, "codex-*")).toBe(false);
  343. });
  344. test("should match glob with underscores literally", () => {
  345. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  346. expect(matchClientPattern(session, "codex_*")).toBe(true);
  347. });
  348. test("consecutive wildcards ** should behave like single *", () => {
  349. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  350. expect(matchClientPattern(session, "codex-**")).toBe(true);
  351. expect(matchClientPattern(session, "**codex**")).toBe(true);
  352. });
  353. test("glob should handle regex metacharacters literally", () => {
  354. const session = createMockSession({ userAgent: "foo.bar/1.0" });
  355. expect(matchClientPattern(session, "foo.bar*")).toBe(true);
  356. expect(matchClientPattern(session, "foo*bar*")).toBe(true);
  357. const session2 = createMockSession({ userAgent: "fooXbar/1.0" });
  358. expect(matchClientPattern(session2, "foo.bar*")).toBe(false);
  359. });
  360. test("glob should handle brackets and parens literally", () => {
  361. const session = createMockSession({ userAgent: "tool[v2]/1.0" });
  362. expect(matchClientPattern(session, "tool[v2]*")).toBe(true);
  363. });
  364. test("pathological glob pattern completes quickly without ReDoS", () => {
  365. const session = createMockSession({ userAgent: `${"a".repeat(32)}b` });
  366. const pattern = "*a*a*a*a*a*a*a*a*c";
  367. const start = performance.now();
  368. const result = matchClientPattern(session, pattern);
  369. const elapsed = performance.now() - start;
  370. expect(result).toBe(false);
  371. expect(elapsed).toBeLessThan(50);
  372. });
  373. });
  374. describe("isClientAllowed", () => {
  375. test("should reject when blocked matches even if allowed also matches", () => {
  376. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  377. expect(isClientAllowed(session, ["claude-code"], ["claude-code"])).toBe(false);
  378. });
  379. test("should allow when allowedClients and blockedClients are both empty", () => {
  380. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  381. expect(isClientAllowed(session, [], [])).toBe(true);
  382. });
  383. test("should allow when allowedClients match", () => {
  384. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  385. expect(isClientAllowed(session, ["gemini-cli"])).toBe(true);
  386. });
  387. test("should reject when allowedClients are set but none match", () => {
  388. const session = createMockSession({ userAgent: "UnknownClient/1.0" });
  389. expect(isClientAllowed(session, ["gemini-cli"])).toBe(false);
  390. });
  391. test("should reject when only blockedClients are set and blocked matches", () => {
  392. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  393. expect(isClientAllowed(session, [], ["gemini-cli"])).toBe(false);
  394. });
  395. test("should allow when only blockedClients are set and blocked does not match", () => {
  396. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  397. expect(isClientAllowed(session, [], ["codex-cli"])).toBe(true);
  398. });
  399. test("should allow when blocked does not match and allowed matches", () => {
  400. const session = createMockSession({ userAgent: "codex_cli/2.0" });
  401. expect(isClientAllowed(session, ["codex-cli"], ["gemini-cli"])).toBe(true);
  402. });
  403. test("should allow codex desktop alias when codex-cli is allowlisted", () => {
  404. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  405. expect(isClientAllowed(session, ["codex-cli"], [])).toBe(true);
  406. });
  407. });
  408. describe("isClientAllowedDetailed", () => {
  409. test("should return no_restriction when both lists are empty", () => {
  410. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  411. const result = isClientAllowedDetailed(session, [], []);
  412. expect(result).toEqual({
  413. allowed: true,
  414. matchType: "no_restriction",
  415. matchedPattern: undefined,
  416. detectedClient: undefined,
  417. checkedAllowlist: [],
  418. checkedBlocklist: [],
  419. });
  420. });
  421. test("should return blocklist_hit with matched pattern", () => {
  422. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  423. const result = isClientAllowedDetailed(session, [], ["gemini-cli"]);
  424. expect(result.allowed).toBe(false);
  425. expect(result.matchType).toBe("blocklist_hit");
  426. expect(result.matchedPattern).toBe("gemini-cli");
  427. expect(result.detectedClient).toBe("GeminiCLI/1.0");
  428. expect(result.checkedBlocklist).toEqual(["gemini-cli"]);
  429. });
  430. test("should return allowlist_miss when no allowlist pattern matches", () => {
  431. const session = createMockSession({ userAgent: "UnknownClient/1.0" });
  432. const result = isClientAllowedDetailed(session, ["gemini-cli", "codex-cli"], []);
  433. expect(result.allowed).toBe(false);
  434. expect(result.matchType).toBe("allowlist_miss");
  435. expect(result.matchedPattern).toBeUndefined();
  436. expect(result.detectedClient).toBe("UnknownClient/1.0");
  437. expect(result.checkedAllowlist).toEqual(["gemini-cli", "codex-cli"]);
  438. });
  439. test("should return allowed when allowlist matches", () => {
  440. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  441. const result = isClientAllowedDetailed(session, ["gemini-cli"], []);
  442. expect(result.allowed).toBe(true);
  443. expect(result.matchType).toBe("allowed");
  444. expect(result.matchedPattern).toBe("gemini-cli");
  445. expect(result.detectedClient).toBe("GeminiCLI/1.0");
  446. });
  447. test("blocklist takes precedence over allowlist", () => {
  448. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  449. const result = isClientAllowedDetailed(session, ["claude-code"], ["claude-code"]);
  450. expect(result.allowed).toBe(false);
  451. expect(result.matchType).toBe("blocklist_hit");
  452. expect(result.matchedPattern).toBe("claude-code");
  453. });
  454. test("should detect sub-client for builtin keywords", () => {
  455. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  456. const result = isClientAllowedDetailed(session, ["claude-code"], []);
  457. expect(result.allowed).toBe(true);
  458. expect(result.matchType).toBe("allowed");
  459. expect(result.detectedClient).toBe("claude-code-sdk-ts");
  460. expect(result.matchedPattern).toBe("claude-code");
  461. });
  462. test("should return allowed when only blocklist set and no match", () => {
  463. const session = createMockSession({ userAgent: "CodexCLI/1.0" });
  464. const result = isClientAllowedDetailed(session, [], ["gemini-cli"]);
  465. expect(result.allowed).toBe(true);
  466. expect(result.matchType).toBe("allowed");
  467. expect(result.detectedClient).toBe("CodexCLI/1.0");
  468. });
  469. test("should return no_restriction when blockedClients is undefined and allowlist empty", () => {
  470. const session = createMockSession({ userAgent: "AnyClient/1.0" });
  471. const result = isClientAllowedDetailed(session, []);
  472. expect(result.allowed).toBe(true);
  473. expect(result.matchType).toBe("no_restriction");
  474. });
  475. test("should capture first matching blocked pattern", () => {
  476. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  477. const result = isClientAllowedDetailed(
  478. session,
  479. [],
  480. ["codex-cli", "gemini-cli", "factory-cli"]
  481. );
  482. expect(result.allowed).toBe(false);
  483. expect(result.matchType).toBe("blocklist_hit");
  484. expect(result.matchedPattern).toBe("gemini-cli");
  485. });
  486. test("should include signals and hubConfirmed when builtin keyword is in allowlist", () => {
  487. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  488. const result = isClientAllowedDetailed(session, ["claude-code"], []);
  489. expect(result.signals).toEqual([
  490. "x-app-cli",
  491. "ua-prefix",
  492. "betas-present",
  493. "metadata-user-id",
  494. ]);
  495. expect(result.hubConfirmed).toBe(true);
  496. });
  497. test("should include signals and hubConfirmed when builtin keyword is in blocklist", () => {
  498. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)");
  499. const result = isClientAllowedDetailed(session, [], ["claude-code"]);
  500. expect(result.signals).toEqual([
  501. "x-app-cli",
  502. "ua-prefix",
  503. "betas-present",
  504. "metadata-user-id",
  505. ]);
  506. expect(result.hubConfirmed).toBe(true);
  507. });
  508. test("should not include signals when no builtin keyword is in lists", () => {
  509. const session = createMockSession({ userAgent: "GeminiCLI/1.0" });
  510. const result = isClientAllowedDetailed(session, ["gemini-cli"], []);
  511. expect(result.signals).toBeUndefined();
  512. expect(result.hubConfirmed).toBeUndefined();
  513. });
  514. test("should allow when glob pattern in allowlist matches", () => {
  515. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  516. const result = isClientAllowedDetailed(session, ["codex-*"], []);
  517. expect(result.allowed).toBe(true);
  518. expect(result.matchType).toBe("allowed");
  519. expect(result.matchedPattern).toBe("codex-*");
  520. });
  521. test("should reject when glob pattern in blocklist matches", () => {
  522. const session = createMockSession({ userAgent: "codex-cli/2.0" });
  523. const result = isClientAllowedDetailed(session, [], ["codex-*"]);
  524. expect(result.allowed).toBe(false);
  525. expect(result.matchType).toBe("blocklist_hit");
  526. expect(result.matchedPattern).toBe("codex-*");
  527. });
  528. test("should work with mix of glob and substring patterns", () => {
  529. const session = createMockSession({ userAgent: "my-custom-tool/3.0" });
  530. const result = isClientAllowedDetailed(session, ["codex-*", "custom"], []);
  531. expect(result.allowed).toBe(true);
  532. expect(result.matchType).toBe("allowed");
  533. expect(result.matchedPattern).toBe("custom");
  534. });
  535. });
  536. describe("codex family UA matching via matchesCodexFamilyAlias", () => {
  537. test("codex-tui UA matches codex-cli allowlist", () => {
  538. const session = createMockSession({
  539. userAgent:
  540. "codex-tui/0.115.0 (Mac OS 15.7.3; arm64) Apple_Terminal/455.1 (codex-tui; 0.115.0)",
  541. });
  542. const result = isClientAllowedDetailed(session, ["codex-cli"], []);
  543. expect(result.allowed).toBe(true);
  544. expect(result.matchedPattern).toBe("codex-cli");
  545. });
  546. test("codex_cli_rs UA matches codex-cli allowlist", () => {
  547. const session = createMockSession({
  548. userAgent: "codex_cli_rs/0.114.0 (Windows 10.0.26200; x86_64) xterm-256color",
  549. });
  550. const result = isClientAllowedDetailed(session, ["codex-cli"], []);
  551. expect(result.allowed).toBe(true);
  552. expect(result.matchedPattern).toBe("codex-cli");
  553. });
  554. test("codex_exec UA matches codex-cli allowlist", () => {
  555. const session = createMockSession({ userAgent: "codex_exec/1.0.0" });
  556. const result = isClientAllowedDetailed(session, ["codex-cli"], []);
  557. expect(result.allowed).toBe(true);
  558. expect(result.matchedPattern).toBe("codex-cli");
  559. });
  560. test("codex_vscode UA matches codex-cli allowlist", () => {
  561. const session = createMockSession({ userAgent: "codex_vscode/1.0.0" });
  562. const result = isClientAllowedDetailed(session, ["codex-cli"], []);
  563. expect(result.allowed).toBe(true);
  564. expect(result.matchedPattern).toBe("codex-cli");
  565. });
  566. test("codex-tui UA matches codex_cli_core child pattern", () => {
  567. const session = createMockSession({
  568. userAgent: "codex-tui/0.115.0 (Mac OS 15.7.3; arm64)",
  569. });
  570. const result = isClientAllowedDetailed(session, ["codex_cli_core"], []);
  571. expect(result.allowed).toBe(true);
  572. expect(result.matchedPattern).toBe("codex_cli_core");
  573. });
  574. test("codex_cli_rs UA matches codex_cli_core child pattern", () => {
  575. const session = createMockSession({
  576. userAgent: "codex_cli_rs/0.114.0 (Windows 10.0.26200; x86_64)",
  577. });
  578. const result = isClientAllowedDetailed(session, ["codex_cli_core"], []);
  579. expect(result.allowed).toBe(true);
  580. expect(result.matchedPattern).toBe("codex_cli_core");
  581. });
  582. test("codex_exec UA matches codex_exec child pattern", () => {
  583. const session = createMockSession({ userAgent: "codex_exec/1.0.0" });
  584. const result = isClientAllowedDetailed(session, ["codex_exec"], []);
  585. expect(result.allowed).toBe(true);
  586. expect(result.matchedPattern).toBe("codex_exec");
  587. });
  588. test("codex_vscode UA matches codex_vscode child pattern", () => {
  589. const session = createMockSession({ userAgent: "codex_vscode/1.0.0" });
  590. const result = isClientAllowedDetailed(session, ["codex_vscode"], []);
  591. expect(result.allowed).toBe(true);
  592. expect(result.matchedPattern).toBe("codex_vscode");
  593. });
  594. test("codex-tui UA does NOT match codex_vscode pattern (isolation)", () => {
  595. const session = createMockSession({
  596. userAgent: "codex-tui/0.115.0 (Mac OS 15.7.3; arm64)",
  597. });
  598. const result = isClientAllowedDetailed(session, ["codex_vscode"], []);
  599. expect(result.allowed).toBe(false);
  600. });
  601. test("codex-tui UA does NOT match codex_exec pattern (isolation)", () => {
  602. const session = createMockSession({ userAgent: "codex-tui/0.115.0" });
  603. const result = isClientAllowedDetailed(session, ["codex_exec"], []);
  604. expect(result.allowed).toBe(false);
  605. });
  606. test("Codex Desktop UA still matches codex-cli (regression)", () => {
  607. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  608. const result = isClientAllowedDetailed(session, ["codex-cli"], []);
  609. expect(result.allowed).toBe(true);
  610. });
  611. test("Codex Desktop UA still matches Codex Desktop pattern (regression)", () => {
  612. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  613. const result = isClientAllowedDetailed(session, ["Codex Desktop"], []);
  614. expect(result.allowed).toBe(true);
  615. });
  616. });
  617. describe("detectClientFull", () => {
  618. test("should return matched=true for confirmed claude-code wildcard", () => {
  619. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  620. const result = detectClientFull(session, "claude-code");
  621. expect(result).toEqual({
  622. matched: true,
  623. hubConfirmed: true,
  624. subClient: "claude-code-sdk-ts",
  625. signals: ["x-app-cli", "ua-prefix", "betas-present", "metadata-user-id"],
  626. supplementary: [],
  627. });
  628. });
  629. test("should return matched=false for confirmed but different builtin sub-client", () => {
  630. const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
  631. const result = detectClientFull(session, "claude-code-cli");
  632. expect(result.hubConfirmed).toBe(true);
  633. expect(result.subClient).toBe("claude-code-sdk-ts");
  634. expect(result.matched).toBe(false);
  635. });
  636. test("should use custom normalization path for non-builtin patterns", () => {
  637. const session = createMockSession({ userAgent: "GeminiCLI/0.22.5" });
  638. const result = detectClientFull(session, "gemini-cli");
  639. expect(result.matched).toBe(true);
  640. expect(result.hubConfirmed).toBe(false);
  641. expect(result.subClient).toBeNull();
  642. });
  643. test("should return matched=false for custom pattern when User-Agent is missing", () => {
  644. const session = createMockSession({ userAgent: null });
  645. const result = detectClientFull(session, "gemini-cli");
  646. expect(result.matched).toBe(false);
  647. expect(result.hubConfirmed).toBe(false);
  648. expect(result.signals).toEqual([]);
  649. expect(result.supplementary).toEqual([]);
  650. });
  651. test("should match codex desktop alias in detectClientFull", () => {
  652. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  653. const result = detectClientFull(session, "codex-cli");
  654. expect(result.matched).toBe(true);
  655. expect(result.hubConfirmed).toBe(false);
  656. expect(result.subClient).toBeNull();
  657. });
  658. test("should NOT match codex_vscode via codex desktop UA (different family)", () => {
  659. const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
  660. const result = detectClientFull(session, "codex_vscode");
  661. expect(result.matched).toBe(false);
  662. expect(result.hubConfirmed).toBe(false);
  663. });
  664. });
  665. });