@bitkyc08/opencodex 2.64.0 → 2.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-DGLte6IR.js +50 -0
  5. package/gui/dist/assets/Tray-D4bA1vff.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-Bi1K37Wl.js} +12 -12
  8. package/gui/dist/assets/tray-data-BZXhB3qz.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Account-risk notice for Claude Desktop first-party mode.
3
+ *
4
+ * First-party mode sends the Claude subscription's Claude Code traffic through the local
5
+ * interception proxy. Every surface that offers, applies or reports first-party shows this text
6
+ * (CLI, management status, native toggle, dashboard, docs), so it has one owner and cannot drift.
7
+ */
8
+ export const FIRST_PARTY_ACCOUNT_RISK = {
9
+ code: "first_party_account_suspension_risk",
10
+ message: "First-party mode sends Claude subscription traffic through a local interception proxy. "
11
+ + "Anthropic may treat this as a violation of its terms and suspend the account. "
12
+ + "Use it at your own risk; gateway mode is the default.",
13
+ } as const;
14
+
15
+ export type FirstPartyAccountRisk = { code: typeof FIRST_PARTY_ACCOUNT_RISK.code; message: string };
16
+
17
+ /** A fresh copy for JSON payloads, so no caller can mutate the shared constant. */
18
+ export function firstPartyAccountRisk(): FirstPartyAccountRisk {
19
+ return { code: FIRST_PARTY_ACCOUNT_RISK.code, message: FIRST_PARTY_ACCOUNT_RISK.message };
20
+ }
@@ -6,9 +6,12 @@
6
6
  * subscription-preserving launch deliberately sets no token, so the CLI can never
7
7
  * refresh its picker list itself — it reads whatever cache exists. We therefore
8
8
  * pre-write the cache in the exact on-disk schema the CLI uses:
9
- * { baseUrl, fetchedAt, models: [{ id, display_name? }] } (mode 0600)
10
- * mirroring its `/^(claude|anthropic)/i` usable-id filter. The picker validates
9
+ * { baseUrl, fetchedAt, models: [{ id, display_name?, description? }] } (mode 0600)
10
+ * mirroring the picker rule that the id must contain `claude` or `anthropic`.
11
+ * Current aliases are `ocx-claude-*`, so an anchored `^(claude|anthropic)` filter
12
+ * would drop every newly minted routed model. The picker validates
11
13
  * only `baseUrl === ANTHROPIC_BASE_URL`, so a foreign base URL is simply ignored.
14
+ * `description` replaces the picker's generic "From gateway" line (Claude Code >= 2.1.257).
12
15
  */
13
16
  import { mkdirSync, writeFileSync } from "node:fs";
14
17
  import { homedir } from "node:os";
@@ -19,6 +22,7 @@ import type { OcxConfig } from "../types";
19
22
  export interface GatewayModelRow {
20
23
  id: string;
21
24
  display_name?: string;
25
+ description?: string;
22
26
  }
23
27
 
24
28
  export interface GatewayModelCacheRefreshOptions {
@@ -49,14 +53,18 @@ export function claudeConfigDir(): string {
49
53
  export function writeGatewayModelCache(baseUrl: string, models: readonly GatewayModelRow[], configDir = claudeConfigDir()): string | null {
50
54
  try {
51
55
  // Mirror the CLI's usable-id filter so our file matches what it would cache.
52
- const usable = models.filter(m => /^(claude|anthropic)/i.test(m.id));
56
+ const usable = models.filter(m => /(claude|anthropic)/i.test(m.id));
53
57
  const cacheDir = join(configDir, "cache");
54
58
  mkdirSync(cacheDir, { recursive: true });
55
59
  const path = join(cacheDir, "gateway-models.json");
56
60
  const payload = {
57
61
  baseUrl,
58
62
  fetchedAt: Date.now(),
59
- models: usable.map(m => (m.display_name === undefined ? { id: m.id } : { id: m.id, display_name: m.display_name })),
63
+ models: usable.map(m => ({
64
+ id: m.id,
65
+ ...(m.display_name === undefined ? {} : { display_name: m.display_name }),
66
+ ...(m.description === undefined ? {} : { description: m.description }),
67
+ })),
60
68
  };
61
69
  writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 });
62
70
  return path;
@@ -110,6 +118,7 @@ export async function refreshGatewayModelCacheFromProxy(
110
118
  .map(m => ({
111
119
  id: m.id as string,
112
120
  display_name: typeof m.display_name === "string" ? m.display_name : undefined,
121
+ description: typeof m.description === "string" ? m.description : undefined,
113
122
  }));
114
123
  return writeGatewayModelCache(baseUrl, models, options.configDir);
115
124
  } catch {
@@ -442,13 +442,16 @@ function translateAnthropicRequest(
442
442
  if (outputConfigFormat) body.text = { format: outputConfigFormat };
443
443
  let cacheKeySource: ClaudeCacheKeySource = null;
444
444
  if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
445
- body.user = raw.metadata.user_id;
445
+ const userIdHash = createHash("sha256").update(raw.metadata.user_id).digest("hex");
446
+ // OpenAI and Azure reject `user` longer than 64 chars, and Claude Code's metadata.user_id
447
+ // is a JSON blob well past that; send its 64-char hash instead of the raw value.
448
+ body.user = raw.metadata.user_id.length <= 64 ? raw.metadata.user_id : userIdHash;
446
449
  // OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send
447
450
  // their session id; without it consecutive /v1/messages turns reported
448
451
  // cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's
449
452
  // metadata.user_id embeds the session uuid, so hashing it yields a stable
450
453
  // per-session key with a bounded length/charset.
451
- body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32);
454
+ body.prompt_cache_key = userIdHash.slice(0, 32);
452
455
  cacheKeySource = "metadata";
453
456
  } else if (systemParts.length > 0) {
454
457
  // Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the
@@ -6,7 +6,8 @@ import { BlockList, createServer, connect, isIP, type Server, type Socket } from
6
6
  * Claude Code honours `HTTPS_PROXY` and opens `CONNECT <host>:443` for every upstream. This
7
7
  * proxy splices tunnels for the intercepted hosts onto the local TLS listener (which holds a
8
8
  * leaf certificate for them) and blindly relays every other tunnel to its real destination,
9
- * so telemetry, OAuth refresh and claude.ai traffic stay native and opaque to opencodex.
9
+ * so telemetry and OAuth refresh stay native and opaque to opencodex. Picker mode may
10
+ * terminate only claude.ai tunnels, selected independently for each connection.
10
11
  *
11
12
  * Only CONNECT is served. Plain proxied HTTP requests are refused: Claude Code never sends
12
13
  * them, and answering them would turn this socket into a generic forward proxy.
@@ -15,6 +16,7 @@ import { BlockList, createServer, connect, isIP, type Server, type Socket } from
15
16
  export const CLAUDE_INTERCEPT_HOSTS = ["api.anthropic.com"] as const;
16
17
 
17
18
  const MAX_HEAD_BYTES = 8 * 1024;
19
+ const MAX_PENDING_BYTES = 16 * 1024 * 1024;
18
20
  const HEAD_TIMEOUT_MS = 10_000;
19
21
  const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000;
20
22
 
@@ -23,10 +25,43 @@ export interface ConnectProxyOptions {
23
25
  interceptPort: number;
24
26
  /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */
25
27
  interceptHosts?: readonly string[];
28
+ /** Per-connection override, consulted before interceptHosts; null keeps the default. */
29
+ selectTunnel?: (host: string, port: number, request: ConnectRequestInfo) => TunnelDecision | null | Promise<TunnelDecision | null>;
26
30
  /** Test seam: dial the real destination for a blind tunnel. */
27
31
  dialUpstream?: (host: string, port: number) => Socket;
28
32
  }
29
33
 
34
+ export type TunnelDecision = { kind: "intercept"; port: number } | { kind: "blind" };
35
+
36
+ /** What the CONNECT head says about its client, for tunnel choice only. Never logged. */
37
+ export interface ConnectRequestInfo {
38
+ /** The CONNECT request's User-Agent header, or null when it sent none. */
39
+ userAgent: string | null;
40
+ }
41
+
42
+ /**
43
+ * Chromium (Claude Desktop's app) sends its browser User-Agent on CONNECT; the Claude Code
44
+ * processes Desktop spawns send none. The two trust different CAs, so the tunnel choice uses it.
45
+ *
46
+ * This is a routing hint, not a trust boundary: a local client can send any User-Agent. Neither
47
+ * answer grants anything a local process lacks already. A non-browser tunnel reaches the
48
+ * api.anthropic.com intercept, which the Claude Code proxy offers every local process; a browser
49
+ * tunnel reaches the claude.ai relay, which verifies upstream and injects no credential. A client
50
+ * that lies only breaks its own TLS, because each terminator presents a certificate only its
51
+ * intended client trusts.
52
+ */
53
+ export function isBrowserConnect(request: ConnectRequestInfo): boolean {
54
+ return request.userAgent !== null && /^Mozilla\//.test(request.userAgent);
55
+ }
56
+
57
+ function connectRequestInfo(head: string): ConnectRequestInfo {
58
+ const match = /\r\nuser-agent:[ \t]*([^\r\n]*)/i.exec(head);
59
+ return { userAgent: match ? match[1]!.trim() : null };
60
+ }
61
+
62
+ type ResolvedConnectProxyOptions = Required<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream">>
63
+ & Pick<ConnectProxyOptions, "selectTunnel">;
64
+
30
65
  export interface ConnectProxyHandle {
31
66
  port: number;
32
67
  close(): Promise<void>;
@@ -90,7 +125,7 @@ function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
90
125
  upstream.pipe(client);
91
126
  }
92
127
 
93
- function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream">>): void {
128
+ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions): void {
94
129
  let head: Buffer = Buffer.alloc(0);
95
130
  socket.on("error", () => socket.destroy());
96
131
  socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout"));
@@ -98,7 +133,8 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
98
133
  const onData = (chunk: Buffer) => {
99
134
  head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
100
135
  const end = head.indexOf("\r\n\r\n");
101
- if (end === -1) {
136
+ // The cap holds however the head arrives: across reads or in one oversized read.
137
+ if (end === -1 || end + 4 > MAX_HEAD_BYTES) {
102
138
  if (head.length > MAX_HEAD_BYTES) {
103
139
  socket.off("data", onData);
104
140
  respond(socket, 431, "Request Header Fields Too Large");
@@ -109,7 +145,7 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
109
145
  socket.pause();
110
146
  const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1"));
111
147
  // Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello).
112
- const pending = head.subarray(end + 4);
148
+ let pending = head.subarray(end + 4);
113
149
  if (!target) {
114
150
  respond(socket, 405, "Method Not Allowed");
115
151
  return;
@@ -118,31 +154,71 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
118
154
  respond(socket, 403, "Forbidden");
119
155
  return;
120
156
  }
121
- const intercept = target.port === 443 && options.interceptHosts.includes(target.host);
122
- const upstream = intercept
123
- ? connect({ host: "127.0.0.1", port: options.interceptPort })
124
- : options.dialUpstream(target.host, target.port);
125
- let established = false;
126
- const connectTimer = setTimeout(() => {
127
- if (!established) {
128
- upstream.destroy();
129
- respond(socket, 504, "Gateway Timeout");
130
- }
131
- }, UPSTREAM_CONNECT_TIMEOUT_MS);
132
- upstream.once("error", () => {
133
- clearTimeout(connectTimer);
134
- if (!established) respond(socket, 502, "Bad Gateway");
135
- });
136
- upstream.once("connect", () => {
137
- established = true;
138
- clearTimeout(connectTimer);
139
- if (socket.destroyed) {
140
- upstream.destroy();
141
- return;
157
+ const dialFor = (selected: TunnelDecision | null): void => {
158
+ if (socket.destroyed) return;
159
+ const choice = selected ?? (target.port === 443 && options.interceptHosts.includes(target.host)
160
+ ? { kind: "intercept" as const, port: options.interceptPort }
161
+ : { kind: "blind" as const });
162
+ const upstream = choice.kind === "intercept"
163
+ ? connect({ host: "127.0.0.1", port: choice.port })
164
+ : options.dialUpstream(target.host, target.port);
165
+ let established = false;
166
+ const connectTimer = setTimeout(() => {
167
+ if (!established) {
168
+ upstream.destroy();
169
+ respond(socket, 504, "Gateway Timeout");
170
+ }
171
+ }, UPSTREAM_CONNECT_TIMEOUT_MS);
172
+ upstream.once("error", () => {
173
+ clearTimeout(connectTimer);
174
+ if (!established) respond(socket, 502, "Bad Gateway");
175
+ });
176
+ upstream.once("connect", () => {
177
+ established = true;
178
+ clearTimeout(connectTimer);
179
+ if (socket.destroyed) {
180
+ upstream.destroy();
181
+ return;
182
+ }
183
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
184
+ splice(socket, upstream, pending);
185
+ socket.resume();
186
+ });
187
+ };
188
+ if (!options.selectTunnel) {
189
+ dialFor(null);
190
+ return;
191
+ }
192
+ let decision: ReturnType<NonNullable<ConnectProxyOptions["selectTunnel"]>>;
193
+ try {
194
+ decision = options.selectTunnel(target.host, target.port, connectRequestInfo(head.subarray(0, end).toString("latin1")));
195
+ } catch {
196
+ dialFor({ kind: "blind" });
197
+ return;
198
+ }
199
+ if (!decision || typeof (decision as Promise<TunnelDecision | null>).then !== "function") {
200
+ dialFor(decision as TunnelDecision | null);
201
+ return;
202
+ }
203
+ // A paused socket does not notice a peer FIN until its readable side is drained.
204
+ // Hold later tunnel bytes here so a departing client cannot trigger a stale dial.
205
+ const onPendingReadable = () => {
206
+ let chunk: Buffer | null;
207
+ while ((chunk = socket.read() as Buffer | null) !== null) {
208
+ if (pending.length + chunk.length > MAX_PENDING_BYTES) {
209
+ socket.destroy();
210
+ return;
211
+ }
212
+ pending = Buffer.concat([pending, chunk]);
142
213
  }
143
- socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
144
- splice(socket, upstream, pending);
145
- socket.resume();
214
+ };
215
+ const onPendingEnd = () => socket.destroy();
216
+ socket.on("readable", onPendingReadable);
217
+ socket.once("end", onPendingEnd);
218
+ void Promise.resolve(decision).catch(() => ({ kind: "blind" as const })).then(choice => {
219
+ socket.off("readable", onPendingReadable);
220
+ socket.off("end", onPendingEnd);
221
+ dialFor(choice);
146
222
  });
147
223
  };
148
224
  socket.on("data", onData);
@@ -150,9 +226,10 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
150
226
 
151
227
  /** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */
152
228
  export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise<ConnectProxyHandle> {
153
- const resolved = {
229
+ const resolved: ResolvedConnectProxyOptions = {
154
230
  interceptPort: options.interceptPort,
155
231
  interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS,
232
+ selectTunnel: options.selectTunnel,
156
233
  dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })),
157
234
  };
158
235
  return new Promise((resolve, reject) => {
@@ -91,6 +91,7 @@ const OID = {
91
91
  organization: "2.5.4.10",
92
92
  ecdsaWithSha256: "1.2.840.10045.4.3.2",
93
93
  basicConstraints: "2.5.29.19",
94
+ nameConstraints: "2.5.29.30",
94
95
  keyUsage: "2.5.29.15",
95
96
  subjectAltName: "2.5.29.17",
96
97
  extendedKeyUsage: "2.5.29.37",
@@ -112,6 +113,23 @@ function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Arra
112
113
  : sequence(objectIdentifier(oid), octetString(value));
113
114
  }
114
115
 
116
+ /** iPAddress bases (address + mask, all zero) that cover every IPv4 and every IPv6 address. */
117
+ export const ALL_IP_ADDRESS_BASES: readonly Uint8Array[] = [new Uint8Array(8), new Uint8Array(32)];
118
+
119
+ /**
120
+ * RFC 5280 NameConstraints. permittedSubtrees holds one dNSName base per name. A DNS-only permitted
121
+ * list leaves other name forms unconstrained, so excludedSubtrees names every IPv4 and IPv6
122
+ * address unless the caller opts out.
123
+ */
124
+ function nameConstraints(permitted: readonly string[], excludeAllIpAddresses: boolean): Uint8Array {
125
+ const subtrees = permitted.map(name => sequence(contextTag(2, new TextEncoder().encode(name), false)));
126
+ const excluded = ALL_IP_ADDRESS_BASES.map(base => sequence(contextTag(7, base, false)));
127
+ return sequence(
128
+ contextTag(0, concat(...subtrees)),
129
+ ...(excludeAllIpAddresses ? [contextTag(1, concat(...excluded))] : []),
130
+ );
131
+ }
132
+
115
133
  function subjectPublicKeyInfo(key: KeyObject): Uint8Array {
116
134
  return new Uint8Array(key.export({ type: "spki", format: "der" }));
117
135
  }
@@ -173,9 +191,16 @@ export interface LocalInterceptCa extends PemKeyPair {
173
191
  privateKey: KeyObject;
174
192
  }
175
193
 
176
- export function createLocalInterceptCa(): LocalInterceptCa {
194
+ export interface AuthorityOptions {
195
+ commonName: string;
196
+ permittedDnsNames?: readonly string[];
197
+ /** With permittedDnsNames: also exclude every IP address (default true). */
198
+ excludeAllIpAddresses?: boolean;
199
+ }
200
+
201
+ export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa {
177
202
  const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
178
- const name = distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME);
203
+ const name = distinguishedName(options.commonName);
179
204
  const der = issueCertificate({
180
205
  subject: name,
181
206
  issuer: name,
@@ -187,6 +212,9 @@ export function createLocalInterceptCa(): LocalInterceptCa {
187
212
  // keyCertSign | cRLSign
188
213
  extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)),
189
214
  extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))),
215
+ ...(options.permittedDnsNames?.length
216
+ ? [extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames, options.excludeAllIpAddresses !== false))]
217
+ : []),
190
218
  ],
191
219
  });
192
220
  return {
@@ -197,13 +225,27 @@ export function createLocalInterceptCa(): LocalInterceptCa {
197
225
  };
198
226
  }
199
227
 
200
- /** Issue a serverAuth leaf for `hosts` (first entry becomes the CN; all become SAN dNSNames). */
201
- export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair {
228
+ export function createLocalInterceptCa(): LocalInterceptCa {
229
+ return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME });
230
+ }
231
+
232
+ /** IPv4 literal to its four octets, or null. Only the leaf SAN encoder needs it. */
233
+ function ipv4Octets(host: string): Uint8Array | null {
234
+ const parts = host.split(".");
235
+ if (parts.length !== 4 || !parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return null;
236
+ return Uint8Array.from(parts.map(Number));
237
+ }
238
+
239
+ /**
240
+ * Issue a serverAuth leaf for `hosts` (first entry becomes the CN). Names become SAN dNSNames and
241
+ * IPv4 literals become iPAddress entries.
242
+ */
243
+ export function issueServerLeaf(ca: LocalInterceptCa, issuerCommonName: string, hosts: readonly string[]): PemKeyPair {
202
244
  if (hosts.length === 0) throw new Error("intercept leaf requires at least one host");
203
245
  const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
204
246
  const der = issueCertificate({
205
247
  subject: distinguishedName(hosts[0]!),
206
- issuer: distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME),
248
+ issuer: distinguishedName(issuerCommonName),
207
249
  subjectKey: publicKey,
208
250
  signingKey: ca.privateKey,
209
251
  validityDays: LEAF_VALIDITY_DAYS,
@@ -213,7 +255,10 @@ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly st
213
255
  extension(OID.keyUsage, true, bitString(Uint8Array.of(0x80), 7)),
214
256
  extension(OID.extendedKeyUsage, false, sequence(objectIdentifier(OID.serverAuth))),
215
257
  extension(OID.subjectAltName, false, sequence(
216
- ...hosts.map(host => contextTag(2, new TextEncoder().encode(host), false)),
258
+ ...hosts.map(host => {
259
+ const octets = ipv4Octets(host);
260
+ return octets ? contextTag(7, octets, false) : contextTag(2, new TextEncoder().encode(host), false);
261
+ }),
217
262
  )),
218
263
  extension(OID.authorityKeyIdentifier, false, sequence(contextTag(0, keyIdentifier(ca.publicKey), false))),
219
264
  ],
@@ -224,6 +269,10 @@ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly st
224
269
  };
225
270
  }
226
271
 
272
+ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair {
273
+ return issueServerLeaf(ca, CLAUDE_INTERCEPT_CA_COMMON_NAME, hosts);
274
+ }
275
+
227
276
  // ── Persistence ─────────────────────────────────────────────────────────────────
228
277
 
229
278
  export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept";
@@ -246,7 +295,7 @@ function writeFileAtomic(path: string, contents: string, mode: number): void {
246
295
  renameSync(tmp, path);
247
296
  }
248
297
 
249
- function loadPersistedCa(dir: string): LocalInterceptCa | null {
298
+ function loadPersistedCa(dir: string, accept?: (cert: X509Certificate) => boolean): LocalInterceptCa | null {
250
299
  const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE);
251
300
  const keyPath = join(dir, CA_KEY_FILE);
252
301
  if (!existsSync(certPath) || !existsSync(keyPath)) return null;
@@ -256,32 +305,41 @@ function loadPersistedCa(dir: string): LocalInterceptCa | null {
256
305
  const privateKey = createPrivateKey(keyPem);
257
306
  const publicKey = createPublicKey(keyPem);
258
307
  const certificate = new X509Certificate(certPem);
259
- if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)) return null;
308
+ if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)
309
+ || (accept && !accept(certificate))) return null;
260
310
  return { certPem, keyPem, publicKey, privateKey };
261
311
  } catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below.
262
312
  return null;
263
313
  }
264
314
  }
265
315
 
266
- /**
267
- * Load the persisted authority under `<configDir>/claude-intercept/`, minting one when absent
268
- * or unreadable. The private key is written 0600; the certificate is world-readable because
269
- * `NODE_EXTRA_CA_CERTS` only needs the public half.
270
- */
271
- export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa {
272
- const dir = claudeInterceptStateDir(configDir);
316
+ /** Persist an authority under its own lease, replacing unreadable or rejected pairs. */
317
+ export function ensurePersistedAuthority(
318
+ dir: string,
319
+ options: AuthorityOptions,
320
+ lockName = "ca-publication.sqlite",
321
+ accept?: (cert: X509Certificate) => boolean,
322
+ ): LocalInterceptCa {
273
323
  mkdirSync(dir, { recursive: true, mode: 0o700 });
274
324
  // A separate SQLite namespace binds exclusion to the explicit CA directory.
275
325
  // The OS releases it on crash; a contending caller fails before touching either
276
326
  // PEM. Readers also take the lease so they cannot observe half a publication.
277
327
  return withClientLifecycleSync(() => {
278
- const existing = loadPersistedCa(dir);
328
+ const existing = loadPersistedCa(dir, accept);
279
329
  if (existing) return existing;
280
- const ca = createLocalInterceptCa();
330
+ const ca = createCertificateAuthority(options);
281
331
  writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600);
282
332
  writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644);
283
333
  return ca;
284
- }, { lockPath: join(dir, "ca-publication.sqlite") });
334
+ }, { lockPath: join(dir, lockName) });
335
+ }
336
+
337
+ /** Preserve the original intercept CA path, name, permissions and extension set. */
338
+ export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa {
339
+ return ensurePersistedAuthority(
340
+ claudeInterceptStateDir(configDir),
341
+ { commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME },
342
+ );
285
343
  }
286
344
 
287
345
  /** Startup may race a settings apply publishing the same CA. Retry only lease
@@ -0,0 +1,145 @@
1
+ import type { OcxClaudeCodeConfig } from "../../types";
2
+
3
+ /**
4
+ * First-party model bindings for Claude Code traffic that reaches opencodex through the local
5
+ * intercept pair (Claude Desktop's Code tab and the standalone `claude` CLI in first-party mode).
6
+ *
7
+ * In first-party mode Claude Desktop's Code tab picker is owned by claude.ai: its rows come from
8
+ * the account's model selector config and no local setting can add one. What opencodex does see
9
+ * is the picker's Anthropic model id on every Messages request. A binding maps such an id
10
+ * (`claude-sonnet-4-6`) to an opencodex route (`xai/grok-4.7`), so picking that row in Desktop is
11
+ * served by the bound model. The picker keeps Anthropic's label; the binding only changes which
12
+ * model answers.
13
+ *
14
+ * Bindings live in `claudeCode.intercept.modelMap` and apply ONLY to requests that arrived on the
15
+ * `claude-intercept` ingress. They are overlaid on the global `claudeCode.modelMap` for that one
16
+ * request (binding wins per key), so every existing resolution rule still applies: alias first,
17
+ * Desktop 3P alias, exact key, date-suffix-stripped key, `[1m]` strip and `--fast` decode. The
18
+ * overlay is a request-scoped view of `claudeCode`; the live config object is never copied or
19
+ * persisted with the merged map.
20
+ */
21
+
22
+ /** Anthropic picker id shape. Desktop only ever sends `claude-*` ids from its picker. */
23
+ const BINDING_ID_PATTERN = /^claude-[a-z0-9][a-z0-9.\-]*$/i;
24
+ const MAX_BINDING_ID_LENGTH = 128;
25
+ const MAX_BINDING_ROUTE_LENGTH = 256;
26
+ const NATIVE_ROUTE_PREFIX = "native/";
27
+
28
+ /**
29
+ * Picker ids Claude Desktop's Code tab offered on 2026-09-23 (the claude.ai model selector config
30
+ * for a Max account). claude.ai owns this list; it is a suggestion for the dashboard, never a
31
+ * restriction, so any `claude-` id is accepted.
32
+ */
33
+ export const DESKTOP_PICKER_ID_SUGGESTIONS: readonly string[] = [
34
+ "claude-opus-5-5",
35
+ "claude-sonnet-5",
36
+ "claude-fable-5-1",
37
+ "claude-haiku-4-5",
38
+ "claude-opus-5",
39
+ "claude-fable-5",
40
+ "claude-opus-4-8",
41
+ "claude-opus-4-7",
42
+ "claude-opus-4-6",
43
+ "claude-sonnet-4-6",
44
+ ];
45
+
46
+ export function isInterceptBindingId(value: unknown): value is string {
47
+ return typeof value === "string" && value.length <= MAX_BINDING_ID_LENGTH && BINDING_ID_PATTERN.test(value);
48
+ }
49
+
50
+ export function isInterceptBindingRoute(value: unknown): value is string {
51
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_BINDING_ROUTE_LENGTH && !/\s/.test(value);
52
+ }
53
+
54
+ /**
55
+ * Router model id for a binding target written in the Desktop route vocabulary. `native/<slug>`
56
+ * is the native OpenAI pool's pseudo-provider and resolves to the bare slug, exactly like a
57
+ * Desktop 3P alias does (src/claude/inbound-model-options.ts); every other route is used as is.
58
+ */
59
+ export function normalizeBindingTarget(route: string): string {
60
+ return route.startsWith(NATIVE_ROUTE_PREFIX) && route.length > NATIVE_ROUTE_PREFIX.length
61
+ ? route.slice(NATIVE_ROUTE_PREFIX.length)
62
+ : route;
63
+ }
64
+
65
+ /** Valid bindings from a config value; malformed entries are ignored rather than routed. */
66
+ export function readInterceptBindings(cc: OcxClaudeCodeConfig | undefined): Record<string, string> {
67
+ const raw = cc?.intercept?.modelMap;
68
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
69
+ const out: Record<string, string> = {};
70
+ for (const [id, route] of Object.entries(raw)) {
71
+ if (isInterceptBindingId(id) && isInterceptBindingRoute(route)) out[id] = route;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * The `claudeCode` view a Messages or count_tokens handler resolves models against. Requests on
78
+ * any other ingress, or with no bindings, get the live object back unchanged. Only binding
79
+ * targets are normalized; global `modelMap` values keep their verbatim semantics.
80
+ */
81
+ export function claudeCodeForIngress(
82
+ cc: OcxClaudeCodeConfig | undefined,
83
+ claudeIntercept: boolean,
84
+ ): OcxClaudeCodeConfig | undefined {
85
+ if (!claudeIntercept) return cc;
86
+ const bindings = readInterceptBindings(cc);
87
+ const ids = Object.keys(bindings);
88
+ if (ids.length === 0) return cc;
89
+ const overlay: Record<string, string> = { ...(cc?.modelMap ?? {}) };
90
+ for (const id of ids) overlay[id] = normalizeBindingTarget(bindings[id]!);
91
+ return { ...(cc ?? {}), modelMap: overlay };
92
+ }
93
+
94
+ export interface InterceptBindingPatch {
95
+ set?: Record<string, string>;
96
+ remove?: string[];
97
+ }
98
+
99
+ export type InterceptBindingPatchResult =
100
+ | { ok: true; bindings: Record<string, string>; changed: boolean }
101
+ | { ok: false; error: string };
102
+
103
+ /** Parse an untrusted PUT body into a patch. */
104
+ export function parseInterceptBindingPatch(body: unknown): InterceptBindingPatch | { error: string } {
105
+ if (!body || typeof body !== "object" || Array.isArray(body)) return { error: "body must be an object" };
106
+ const { set, remove, ...rest } = body as Record<string, unknown>;
107
+ const unknown = Object.keys(rest);
108
+ if (unknown.length > 0) return { error: `unknown field: ${unknown[0]}` };
109
+ if (set === undefined && remove === undefined) return { error: "set or remove is required" };
110
+ const patch: InterceptBindingPatch = {};
111
+ if (set !== undefined) {
112
+ if (!set || typeof set !== "object" || Array.isArray(set)) return { error: "set must be an object of picker id to route" };
113
+ const entries: Record<string, string> = {};
114
+ for (const [id, route] of Object.entries(set as Record<string, unknown>)) {
115
+ if (!isInterceptBindingId(id)) return { error: `invalid picker id: ${id} (expected a claude- model id)` };
116
+ if (!isInterceptBindingRoute(route)) return { error: `invalid route for ${id}` };
117
+ entries[id] = route;
118
+ }
119
+ patch.set = entries;
120
+ }
121
+ if (remove !== undefined) {
122
+ if (!Array.isArray(remove) || remove.some(id => typeof id !== "string")) return { error: "remove must be an array of picker ids" };
123
+ patch.remove = remove as string[];
124
+ }
125
+ return patch;
126
+ }
127
+
128
+ /**
129
+ * Apply a patch to the current bindings. Routes must be in `availableRoutes` (the Desktop route
130
+ * vocabulary, native routes included); removing an unbound id is a no-op.
131
+ */
132
+ export function applyInterceptBindingPatch(
133
+ current: Record<string, string>,
134
+ patch: InterceptBindingPatch,
135
+ availableRoutes: ReadonlySet<string>,
136
+ ): InterceptBindingPatchResult {
137
+ const next: Record<string, string> = { ...current };
138
+ for (const [id, route] of Object.entries(patch.set ?? {})) {
139
+ if (!availableRoutes.has(route)) return { ok: false, error: `route is not available: ${route}` };
140
+ next[id] = route;
141
+ }
142
+ for (const id of patch.remove ?? []) delete next[id];
143
+ const changed = JSON.stringify(Object.entries(next).sort()) !== JSON.stringify(Object.entries(current).sort());
144
+ return { ok: true, bindings: next, changed };
145
+ }