Răsfoiți Sursa

fix: extend codex-cli client restrictions for new UA variants + mask error (#937)

* fix: extend codex-cli client restrictions for codex-tui/codex_exec + mask error details

- Add codex-tui (v0.115.0+) and codex_exec UA matching to codex-cli preset
- Add children selection (CLI/TUI, VS Code, Desktop, Exec) like claude-code
- Move codex-cli to 2nd position in preset list (after claude-code)
- Remove allowed list and signal details from client restriction errors
- Add i18n labels for codex children across all 5 languages and 3 UI entry points

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review findings -- stale test index, missing test translations

- Fix geminiPreset lookup in client-presets.test.ts to use .find() by value
  instead of hardcoded index [1] (which now points to codex-cli after reorder)
- Add missing codex sub-client keys to client-restrictions-editor test data

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR #937 review findings -- vscode parent test, lowercase guard

- Add missing test: codex_vscode UA matching codex-cli parent allowlist
- Add dev-time assertion enforcing lowercase invariant in CODEX_FAMILY_RULES

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
miraserver 3 săptămâni în urmă
părinte
comite
6ec31fc687

+ 4 - 1
messages/en/dashboard.json

@@ -1961,7 +1961,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "{count} selected"
     },

+ 4 - 1
messages/en/settings/providers/form/sections.json

@@ -335,7 +335,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "{count} selected"
     },

+ 4 - 1
messages/ja/dashboard.json

@@ -1897,7 +1897,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "{count} 件選択"
     },

+ 4 - 1
messages/ja/settings/providers/form/sections.json

@@ -336,7 +336,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "{count} 件選択"
     },

+ 4 - 1
messages/ru/dashboard.json

@@ -1945,7 +1945,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "Выбрано: {count}"
     },

+ 4 - 1
messages/ru/settings/providers/form/sections.json

@@ -336,7 +336,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "Выбрано: {count}"
     },

+ 4 - 1
messages/zh-CN/dashboard.json

@@ -1920,7 +1920,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "已选 {count} 项"
     },

+ 4 - 1
messages/zh-CN/settings/providers/form/sections.json

@@ -81,7 +81,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "已选 {count} 项"
     },

+ 4 - 1
messages/zh-TW/dashboard.json

@@ -1905,7 +1905,10 @@
         "sdk-ts": "SDK(TypeScript)",
         "sdk-py": "SDK(Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "已選 {count} 項"
     },

+ 4 - 1
messages/zh-TW/settings/providers/form/sections.json

@@ -336,7 +336,10 @@
         "sdk-ts": "SDK (TypeScript)",
         "sdk-py": "SDK (Python)",
         "cli-sdk": "CLI SDK",
-        "gh-action": "GitHub Action"
+        "gh-action": "GitHub Action",
+        "codex-cli-core": "CLI / TUI",
+        "desktop": "Desktop",
+        "exec": "Exec"
       },
       "nSelected": "已選 {count} 項"
     },

+ 3 - 0
src/app/[locale]/dashboard/_components/user/forms/user-form.tsx

@@ -408,6 +408,9 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
                 "sdk-py": tUserEdit("subClients.sdk-py"),
                 "cli-sdk": tUserEdit("subClients.cli-sdk"),
                 "gh-action": tUserEdit("subClients.gh-action"),
+                "codex-cli-core": tUserEdit("subClients.codex-cli-core"),
+                desktop: tUserEdit("subClients.desktop"),
+                exec: tUserEdit("subClients.exec"),
               },
               nSelected: tUserEdit("nSelected", { count: "{count}" }),
             }}

+ 3 - 0
src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts

@@ -213,6 +213,9 @@ export function useUserTranslations(
         "sdk-py": t("userEditSection.subClients.sdk-py"),
         "cli-sdk": t("userEditSection.subClients.cli-sdk"),
         "gh-action": t("userEditSection.subClients.gh-action"),
+        "codex-cli-core": t("userEditSection.subClients.codex-cli-core"),
+        desktop: t("userEditSection.subClients.desktop"),
+        exec: t("userEditSection.subClients.exec"),
       },
       nSelected: t("userEditSection.nSelected", { count: "{count}" }),
       limitRules: {

+ 5 - 0
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx

@@ -306,6 +306,11 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) {
                     "sdk-py": t("sections.routing.clientRestrictions.subClients.sdk-py"),
                     "cli-sdk": t("sections.routing.clientRestrictions.subClients.cli-sdk"),
                     "gh-action": t("sections.routing.clientRestrictions.subClients.gh-action"),
+                    "codex-cli-core": t(
+                      "sections.routing.clientRestrictions.subClients.codex-cli-core"
+                    ),
+                    desktop: t("sections.routing.clientRestrictions.subClients.desktop"),
+                    exec: t("sections.routing.clientRestrictions.subClients.exec"),
                   },
                   nSelected: t("sections.routing.clientRestrictions.nSelected", {
                     count: "{count}",

+ 43 - 11
src/app/v1/_lib/proxy/client-detector.ts

@@ -33,17 +33,49 @@ export interface ClientRestrictionResult {
 
 const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, "");
 
-function matchesCodexDesktopAlias(pattern: string, userAgent: string): boolean {
-  if (!/^codex desktop\b/i.test(userAgent)) {
-    return false;
+// Map: UA prefix regex -> set of alias values that should match
+// All matchValues must be lowercase (pattern is lowercased before lookup)
+const CODEX_FAMILY_RULES: Array<{ test: RegExp; matchValues: Set<string> }> = [
+  {
+    test: /^codex desktop\b/i,
+    matchValues: new Set(["codex-cli", "codex desktop"]),
+  },
+  {
+    test: /^codex[_-]?tui\b/i,
+    matchValues: new Set(["codex-cli", "codex_cli_core"]),
+  },
+  {
+    test: /^codex[_-]?cli[_-]?rs\b/i,
+    matchValues: new Set(["codex-cli", "codex_cli_core"]),
+  },
+  {
+    test: /^codex[_-]?exec\b/i,
+    matchValues: new Set(["codex-cli", "codex_exec"]),
+  },
+  {
+    test: /^codex[_-]?vscode\b/i,
+    matchValues: new Set(["codex-cli", "codex_vscode"]),
+  },
+];
+
+if (process.env.NODE_ENV !== "production") {
+  for (const rule of CODEX_FAMILY_RULES) {
+    for (const v of rule.matchValues) {
+      if (v !== v.toLowerCase()) {
+        throw new Error(`CODEX_FAMILY_RULES matchValue "${v}" must be lowercase`);
+      }
+    }
   }
+}
 
+function matchesCodexFamilyAlias(pattern: string, userAgent: string): boolean {
   const normalizedPattern = pattern.trim().toLowerCase();
-  return (
-    normalizedPattern === "codex-cli" ||
-    normalizedPattern === "codex_vscode" ||
-    normalizedPattern === "codex desktop"
-  );
+  for (const rule of CODEX_FAMILY_RULES) {
+    if (rule.test.test(userAgent)) {
+      return rule.matchValues.has(normalizedPattern);
+    }
+  }
+  return false;
 }
 
 function globMatch(pattern: string, text: string): boolean {
@@ -144,7 +176,7 @@ export function matchClientPattern(session: ProxySession, pattern: string): bool
       return false;
     }
 
-    if (matchesCodexDesktopAlias(pattern, ua)) {
+    if (matchesCodexFamilyAlias(pattern, ua)) {
       return true;
     }
 
@@ -187,7 +219,7 @@ export function detectClientFull(session: ProxySession, pattern: string): Client
   } else {
     const ua = session.userAgent?.trim();
     if (ua) {
-      if (matchesCodexDesktopAlias(pattern, ua)) {
+      if (matchesCodexFamilyAlias(pattern, ua)) {
         matched = true;
       } else if (pattern.includes("*")) {
         matched = globMatch(pattern, ua);
@@ -247,7 +279,7 @@ export function isClientAllowedDetailed(
   const matches = (pattern: string): boolean => {
     if (!isBuiltinKeyword(pattern)) {
       if (!ua) return false;
-      if (matchesCodexDesktopAlias(pattern, ua)) {
+      if (matchesCodexFamilyAlias(pattern, ua)) {
         return true;
       }
       if (pattern.includes("*")) {

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

@@ -32,15 +32,10 @@ export class ProxyClientGuard {
 
     if (!result.allowed) {
       const detected = result.detectedClient ? ` (detected: ${result.detectedClient})` : "";
-      let message: string;
-      if (result.matchType === "blocklist_hit") {
-        message = `Client blocked by pattern: ${result.matchedPattern}${detected}`;
-      } else {
-        message = `Client not in allowed list: [${allowedClients.join(", ")}]${detected}`;
-      }
-      if (result.signals) {
-        message += `\nSignals(${result.signals.length}/4): [${result.signals.join(", ")}]`;
-      }
+      const message =
+        result.matchType === "blocklist_hit"
+          ? `Client blocked${detected}`
+          : `Client not allowed${detected}`;
       return ProxyResponses.buildError(400, message, "invalid_request_error");
     }
 

+ 3 - 0
src/components/form/client-restrictions-editor.test.tsx

@@ -31,6 +31,9 @@ const TEST_TRANSLATIONS = {
     "sdk-py": "SDK (Python)",
     "cli-sdk": "CLI SDK",
     "gh-action": "GitHub Action",
+    "codex-cli-core": "CLI / TUI",
+    desktop: "Desktop",
+    exec: "Exec",
   },
   nSelected: "{count} selected",
 };

+ 28 - 1
src/lib/client-restrictions/client-presets.test.ts

@@ -52,9 +52,36 @@ describe("client restriction presets", () => {
     ).toEqual(["claude-code-sdk-ts", "codex-cli", "my-ide"]);
   });
 
+  describe("codex-cli preset children", () => {
+    test("codex-cli preset has children", () => {
+      const codexPreset = CLIENT_RESTRICTION_PRESET_OPTIONS.find((p) => p.value === "codex-cli");
+      expect(codexPreset).toBeDefined();
+      expect(codexPreset!.children).toBeDefined();
+      expect(codexPreset!.children!.length).toBe(4);
+    });
+
+    test("codex-cli aliases include new values", () => {
+      const codexPreset = CLIENT_RESTRICTION_PRESET_OPTIONS.find((p) => p.value === "codex-cli");
+      expect(codexPreset!.aliases).toContain("codex_cli_core");
+      expect(codexPreset!.aliases).toContain("codex_exec");
+      expect(codexPreset!.aliases).toContain("codex_vscode");
+      expect(codexPreset!.aliases).toContain("Codex Desktop");
+    });
+
+    test("new codex aliases are recognized as preset values", () => {
+      expect(isPresetClientValue("codex_cli_core")).toBe(true);
+      expect(isPresetClientValue("codex_exec")).toBe(true);
+    });
+
+    test("codex-cli is in second position (after claude-code)", () => {
+      expect(CLIENT_RESTRICTION_PRESET_OPTIONS[0].value).toBe("claude-code");
+      expect(CLIENT_RESTRICTION_PRESET_OPTIONS[1].value).toBe("codex-cli");
+    });
+  });
+
   describe("child selection helpers", () => {
     const claudeCodePreset = CLIENT_RESTRICTION_PRESET_OPTIONS[0];
-    const geminiPreset = CLIENT_RESTRICTION_PRESET_OPTIONS[1];
+    const geminiPreset = CLIENT_RESTRICTION_PRESET_OPTIONS.find((p) => p.value === "gemini-cli")!;
     const allChildValues = claudeCodePreset.children!.map((c) => c.value);
 
     test("getSelectedChildren returns all children when parent value is present", () => {

+ 10 - 1
src/lib/client-restrictions/client-presets.ts

@@ -32,9 +32,18 @@ export const CLIENT_RESTRICTION_PRESET_OPTIONS: readonly ClientRestrictionPreset
       { value: "claude-code-gh-action", labelKey: "gh-action" },
     ],
   },
+  {
+    value: "codex-cli",
+    aliases: ["codex-cli", "codex_cli_core", "codex_vscode", "Codex Desktop", "codex_exec"],
+    children: [
+      { value: "codex_cli_core", labelKey: "codex-cli-core" },
+      { value: "codex_vscode", labelKey: "vscode" },
+      { value: "Codex Desktop", labelKey: "desktop" },
+      { value: "codex_exec", labelKey: "exec" },
+    ],
+  },
   { value: "gemini-cli", aliases: ["gemini-cli"] },
   { value: "factory-cli", aliases: ["factory-cli"] },
-  { value: "codex-cli", aliases: ["codex-cli", "codex_vscode", "Codex Desktop"] },
 ];
 
 const PRESET_OPTION_MAP = new Map(

+ 117 - 4
tests/unit/proxy/client-detector.test.ts

@@ -312,9 +312,29 @@ describe("client-detector", () => {
       expect(matchClientPattern(session, "codex-cli")).toBe(true);
     });
 
-    test("should match codex_vscode against codex desktop alias", () => {
+    test("should NOT match codex_vscode against codex desktop UA (different family)", () => {
       const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
-      expect(matchClientPattern(session, "codex_vscode")).toBe(true);
+      expect(matchClientPattern(session, "codex_vscode")).toBe(false);
+    });
+
+    test("should match codex-tui UA against codex-cli", () => {
+      const session = createMockSession({
+        userAgent:
+          "codex-tui/0.115.0 (Mac OS 15.7.3; arm64) Apple_Terminal/455.1 (codex-tui; 0.115.0)",
+      });
+      expect(matchClientPattern(session, "codex-cli")).toBe(true);
+    });
+
+    test("should match codex_cli_rs UA against codex-cli", () => {
+      const session = createMockSession({
+        userAgent: "codex_cli_rs/0.114.0 (Windows 10.0.26200; x86_64) xterm-256color",
+      });
+      expect(matchClientPattern(session, "codex-cli")).toBe(true);
+    });
+
+    test("should match codex_exec UA against codex-cli", () => {
+      const session = createMockSession({ userAgent: "codex_exec/1.0.0" });
+      expect(matchClientPattern(session, "codex-cli")).toBe(true);
     });
 
     test("should return false when User-Agent is empty", () => {
@@ -594,6 +614,99 @@ describe("client-detector", () => {
     });
   });
 
+  describe("codex family UA matching via matchesCodexFamilyAlias", () => {
+    test("codex-tui UA matches codex-cli allowlist", () => {
+      const session = createMockSession({
+        userAgent:
+          "codex-tui/0.115.0 (Mac OS 15.7.3; arm64) Apple_Terminal/455.1 (codex-tui; 0.115.0)",
+      });
+      const result = isClientAllowedDetailed(session, ["codex-cli"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex-cli");
+    });
+
+    test("codex_cli_rs UA matches codex-cli allowlist", () => {
+      const session = createMockSession({
+        userAgent: "codex_cli_rs/0.114.0 (Windows 10.0.26200; x86_64) xterm-256color",
+      });
+      const result = isClientAllowedDetailed(session, ["codex-cli"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex-cli");
+    });
+
+    test("codex_exec UA matches codex-cli allowlist", () => {
+      const session = createMockSession({ userAgent: "codex_exec/1.0.0" });
+      const result = isClientAllowedDetailed(session, ["codex-cli"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex-cli");
+    });
+
+    test("codex_vscode UA matches codex-cli allowlist", () => {
+      const session = createMockSession({ userAgent: "codex_vscode/1.0.0" });
+      const result = isClientAllowedDetailed(session, ["codex-cli"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex-cli");
+    });
+
+    test("codex-tui UA matches codex_cli_core child pattern", () => {
+      const session = createMockSession({
+        userAgent: "codex-tui/0.115.0 (Mac OS 15.7.3; arm64)",
+      });
+      const result = isClientAllowedDetailed(session, ["codex_cli_core"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex_cli_core");
+    });
+
+    test("codex_cli_rs UA matches codex_cli_core child pattern", () => {
+      const session = createMockSession({
+        userAgent: "codex_cli_rs/0.114.0 (Windows 10.0.26200; x86_64)",
+      });
+      const result = isClientAllowedDetailed(session, ["codex_cli_core"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex_cli_core");
+    });
+
+    test("codex_exec UA matches codex_exec child pattern", () => {
+      const session = createMockSession({ userAgent: "codex_exec/1.0.0" });
+      const result = isClientAllowedDetailed(session, ["codex_exec"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex_exec");
+    });
+
+    test("codex_vscode UA matches codex_vscode child pattern", () => {
+      const session = createMockSession({ userAgent: "codex_vscode/1.0.0" });
+      const result = isClientAllowedDetailed(session, ["codex_vscode"], []);
+      expect(result.allowed).toBe(true);
+      expect(result.matchedPattern).toBe("codex_vscode");
+    });
+
+    test("codex-tui UA does NOT match codex_vscode pattern (isolation)", () => {
+      const session = createMockSession({
+        userAgent: "codex-tui/0.115.0 (Mac OS 15.7.3; arm64)",
+      });
+      const result = isClientAllowedDetailed(session, ["codex_vscode"], []);
+      expect(result.allowed).toBe(false);
+    });
+
+    test("codex-tui UA does NOT match codex_exec pattern (isolation)", () => {
+      const session = createMockSession({ userAgent: "codex-tui/0.115.0" });
+      const result = isClientAllowedDetailed(session, ["codex_exec"], []);
+      expect(result.allowed).toBe(false);
+    });
+
+    test("Codex Desktop UA still matches codex-cli (regression)", () => {
+      const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
+      const result = isClientAllowedDetailed(session, ["codex-cli"], []);
+      expect(result.allowed).toBe(true);
+    });
+
+    test("Codex Desktop UA still matches Codex Desktop pattern (regression)", () => {
+      const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
+      const result = isClientAllowedDetailed(session, ["Codex Desktop"], []);
+      expect(result.allowed).toBe(true);
+    });
+  });
+
   describe("detectClientFull", () => {
     test("should return matched=true for confirmed claude-code wildcard", () => {
       const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)");
@@ -645,11 +758,11 @@ describe("client-detector", () => {
       expect(result.subClient).toBeNull();
     });
 
-    test("should match codex_vscode via codex desktop alias in detectClientFull", () => {
+    test("should NOT match codex_vscode via codex desktop UA (different family)", () => {
       const session = createMockSession({ userAgent: "Codex Desktop/1.0" });
       const result = detectClientFull(session, "codex_vscode");
 
-      expect(result.matched).toBe(true);
+      expect(result.matched).toBe(false);
       expect(result.hubConfirmed).toBe(false);
     });
   });

+ 4 - 2
tests/unit/proxy/client-guard.test.ts

@@ -302,7 +302,8 @@ describe("ProxyClientGuard", () => {
       expect(result).not.toBeNull();
       expect(result?.status).toBe(400);
       const body = await result!.json();
-      expect(body.error.message).toContain("Signals(3/4)");
+      expect(body.error.message).toContain("Client not allowed");
+      expect(body.error.message).not.toContain("Signals");
     });
 
     test("should reject when anthropic-beta header is missing (only 3-of-4 signals)", async () => {
@@ -318,7 +319,8 @@ describe("ProxyClientGuard", () => {
       expect(result).not.toBeNull();
       expect(result?.status).toBe(400);
       const body = await result!.json();
-      expect(body.error.message).toContain("Signals(3/4)");
+      expect(body.error.message).toContain("Client not allowed");
+      expect(body.error.message).not.toContain("Signals");
     });
 
     test("should allow when all 4 signals present with claude-code allowlist", async () => {