@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -0,0 +1,179 @@
1
+ import { BlockList, createServer, connect, isIP, type Server, type Socket } from "node:net";
2
+
3
+ /**
4
+ * Loopback HTTP CONNECT proxy for Claude Code.
5
+ *
6
+ * Claude Code honours `HTTPS_PROXY` and opens `CONNECT <host>:443` for every upstream. This
7
+ * proxy splices tunnels for the intercepted hosts onto the local TLS listener (which holds a
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.
10
+ *
11
+ * Only CONNECT is served. Plain proxied HTTP requests are refused: Claude Code never sends
12
+ * them, and answering them would turn this socket into a generic forward proxy.
13
+ */
14
+
15
+ export const CLAUDE_INTERCEPT_HOSTS = ["api.anthropic.com"] as const;
16
+
17
+ const MAX_HEAD_BYTES = 8 * 1024;
18
+ const HEAD_TIMEOUT_MS = 10_000;
19
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000;
20
+
21
+ export interface ConnectProxyOptions {
22
+ /** Loopback port of the TLS listener that terminates intercepted tunnels. */
23
+ interceptPort: number;
24
+ /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */
25
+ interceptHosts?: readonly string[];
26
+ /** Test seam: dial the real destination for a blind tunnel. */
27
+ dialUpstream?: (host: string, port: number) => Socket;
28
+ }
29
+
30
+ export interface ConnectProxyHandle {
31
+ port: number;
32
+ close(): Promise<void>;
33
+ }
34
+
35
+ interface ConnectTarget {
36
+ host: string;
37
+ port: number;
38
+ }
39
+
40
+ /** Parse `CONNECT host:port HTTP/1.1` from a request head; `null` for anything else. */
41
+ export function parseConnectRequestLine(head: string): ConnectTarget | null {
42
+ const requestLine = head.split("\r\n", 1)[0] ?? "";
43
+ const match = /^CONNECT\s+(\S+)\s+HTTP\/1\.[01]$/.exec(requestLine);
44
+ if (!match) return null;
45
+ const authority = match[1]!;
46
+ // Bracketed IPv6 (`[::1]:443`) and plain `host:port`.
47
+ const ipv6 = /^\[([^\]]+)\]:(\d{1,5})$/.exec(authority);
48
+ const hostPort = ipv6 ?? /^([^:]+):(\d{1,5})$/.exec(authority);
49
+ if (!hostPort) return null;
50
+ const port = Number(hostPort[2]);
51
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
52
+ return { host: hostPort[1]!.toLowerCase().replace(/\.$/, ""), port };
53
+ }
54
+
55
+ // 127/8 and ::1, which `BlockList` also matches in IPv4-mapped form (`::ffff:127.0.0.1`,
56
+ // `::ffff:7f00:1`). The unspecified addresses dial the local host too.
57
+ const LOCAL_TARGETS = new BlockList();
58
+ LOCAL_TARGETS.addSubnet("127.0.0.0", 8, "ipv4");
59
+ LOCAL_TARGETS.addAddress("0.0.0.0", "ipv4");
60
+ LOCAL_TARGETS.addAddress("::1", "ipv6");
61
+ LOCAL_TARGETS.addAddress("::", "ipv6");
62
+
63
+ export function isLoopbackTarget(host: string): boolean {
64
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
65
+ const family = isIP(host);
66
+ if (family !== 0) return LOCAL_TARGETS.check(host, family === 6 ? "ipv6" : "ipv4");
67
+ // `127.1`, `0x7f000001`, `2130706433`: resolver shorthand for a loopback literal, not a name.
68
+ return /^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host);
69
+ }
70
+
71
+ function respond(socket: Socket, status: number, reason: string): void {
72
+ if (socket.destroyed) return;
73
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
74
+ }
75
+
76
+ function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
77
+ const teardown = () => {
78
+ client.destroy();
79
+ upstream.destroy();
80
+ };
81
+ client.on("error", teardown);
82
+ upstream.on("error", teardown);
83
+ client.on("close", () => upstream.destroy());
84
+ upstream.on("close", () => client.destroy());
85
+ client.setTimeout(0);
86
+ client.setNoDelay(true);
87
+ upstream.setNoDelay(true);
88
+ if (pending.length > 0) upstream.write(pending);
89
+ client.pipe(upstream);
90
+ upstream.pipe(client);
91
+ }
92
+
93
+ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream">>): void {
94
+ let head: Buffer = Buffer.alloc(0);
95
+ socket.on("error", () => socket.destroy());
96
+ socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout"));
97
+
98
+ const onData = (chunk: Buffer) => {
99
+ head = head.length === 0 ? chunk : Buffer.concat([head, chunk]);
100
+ const end = head.indexOf("\r\n\r\n");
101
+ if (end === -1) {
102
+ if (head.length > MAX_HEAD_BYTES) {
103
+ socket.off("data", onData);
104
+ respond(socket, 431, "Request Header Fields Too Large");
105
+ }
106
+ return;
107
+ }
108
+ socket.off("data", onData);
109
+ socket.pause();
110
+ const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1"));
111
+ // Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello).
112
+ const pending = head.subarray(end + 4);
113
+ if (!target) {
114
+ respond(socket, 405, "Method Not Allowed");
115
+ return;
116
+ }
117
+ if (isLoopbackTarget(target.host)) {
118
+ respond(socket, 403, "Forbidden");
119
+ return;
120
+ }
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;
142
+ }
143
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
144
+ splice(socket, upstream, pending);
145
+ socket.resume();
146
+ });
147
+ };
148
+ socket.on("data", onData);
149
+ }
150
+
151
+ /** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */
152
+ export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise<ConnectProxyHandle> {
153
+ const resolved = {
154
+ interceptPort: options.interceptPort,
155
+ interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS,
156
+ dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })),
157
+ };
158
+ return new Promise((resolve, reject) => {
159
+ const server: Server = createServer(socket => handleConnection(socket, resolved));
160
+ const sockets = new Set<Socket>();
161
+ server.on("connection", socket => {
162
+ sockets.add(socket);
163
+ socket.once("close", () => sockets.delete(socket));
164
+ });
165
+ server.once("error", reject);
166
+ server.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
167
+ server.off("error", reject);
168
+ const address = server.address();
169
+ const boundPort = address && typeof address === "object" ? address.port : port;
170
+ resolve({
171
+ port: boundPort,
172
+ close: () => new Promise<void>(done => {
173
+ for (const socket of sockets) socket.destroy();
174
+ server.close(() => done());
175
+ }),
176
+ });
177
+ });
178
+ });
179
+ }
@@ -0,0 +1,122 @@
1
+ import type { Server } from "bun";
2
+ import type { PemKeyPair } from "./local-ca";
3
+
4
+ /**
5
+ * TLS listener that terminates intercepted `api.anthropic.com` tunnels.
6
+ *
7
+ * The CONNECT proxy splices a Claude Code tunnel onto this socket; the client then speaks
8
+ * HTTPS believing it reached Anthropic. Messages traffic is rewritten to a loopback URL and
9
+ * handed to the router's own request handler, which already knows how to route mapped models
10
+ * to providers and pass genuine Claude models through with the caller's subscription
11
+ * credential. Every other path (usage, feedback, model listings, …) is relayed verbatim to
12
+ * the real upstream so the client keeps behaving like a first-party install.
13
+ */
14
+
15
+ export const CLAUDE_INTERCEPT_UPSTREAM = "https://api.anthropic.com";
16
+
17
+ const INTERCEPTED_PATHS = new Set(["/v1/messages", "/v1/messages/count_tokens"]);
18
+
19
+ const HOP_BY_HOP_HEADERS = new Set([
20
+ "connection", "keep-alive", "transfer-encoding", "upgrade", "te", "trailer",
21
+ "proxy-authenticate", "proxy-authorization", "proxy-connection", "host", "content-length",
22
+ "accept-encoding",
23
+ ]);
24
+
25
+ // fetch() transparently decodes the body, so the encoding headers would describe bytes the
26
+ // client never sees.
27
+ const RESPONSE_STRIP_HEADERS = new Set([
28
+ "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length",
29
+ ]);
30
+
31
+ export function isClaudeInterceptedPath(pathname: string, method: string): boolean {
32
+ return method === "POST" && INTERCEPTED_PATHS.has(pathname);
33
+ }
34
+
35
+ /**
36
+ * Rebuild the request as if the client had dialled the router directly on loopback: the
37
+ * admission and Host checks then take the same path a plain loopback bind always has.
38
+ */
39
+ export function rewriteInterceptedRequest(req: Request, loopbackOrigin: string): Request {
40
+ const url = new URL(req.url);
41
+ const headers = new Headers(req.headers);
42
+ headers.set("host", loopbackOrigin.replace(/^https?:\/\//, ""));
43
+ return new Request(`${loopbackOrigin}${url.pathname}${url.search}`, {
44
+ method: req.method,
45
+ headers,
46
+ body: req.body,
47
+ signal: req.signal,
48
+ redirect: "manual",
49
+ // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec.
50
+ duplex: "half",
51
+ });
52
+ }
53
+
54
+ export function forwardHeadersForUpstream(source: Headers): Headers {
55
+ const headers = new Headers();
56
+ source.forEach((value, name) => {
57
+ if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value);
58
+ });
59
+ return headers;
60
+ }
61
+
62
+ export async function relayToUpstream(req: Request, upstreamBase: string, fetchImpl: typeof fetch = fetch): Promise<Response> {
63
+ const url = new URL(req.url);
64
+ const target = `${upstreamBase.replace(/\/$/, "")}${url.pathname}${url.search}`;
65
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
66
+ let upstream: Response;
67
+ try {
68
+ upstream = await fetchImpl(target, {
69
+ method: req.method,
70
+ headers: forwardHeadersForUpstream(req.headers),
71
+ body: hasBody ? req.body : undefined,
72
+ signal: req.signal,
73
+ redirect: "manual",
74
+ // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec.
75
+ duplex: "half",
76
+ });
77
+ } catch (error) {
78
+ return Response.json(
79
+ { type: "error", error: { type: "api_error", message: `intercept relay failed: ${error instanceof Error ? error.message : String(error)}` } },
80
+ { status: 502 },
81
+ );
82
+ }
83
+ const headers = new Headers();
84
+ upstream.headers.forEach((value, name) => {
85
+ if (!RESPONSE_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, value);
86
+ });
87
+ return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers });
88
+ }
89
+
90
+ export interface ClaudeInterceptListenerOptions<T = undefined> {
91
+ leaf: PemKeyPair;
92
+ /** Router request handler; receives the loopback-rewritten request and the intercept server. */
93
+ dispatch: (req: Request, server: Server<T>) => Promise<Response>;
94
+ upstreamBase?: string;
95
+ maxRequestBodySize?: number;
96
+ idleTimeout?: number;
97
+ fetchImpl?: typeof fetch;
98
+ /** Test seam: bind a fixed port instead of an ephemeral one. */
99
+ port?: number;
100
+ }
101
+
102
+ /** Bind the intercept TLS listener on an ephemeral loopback port. */
103
+ export function startClaudeInterceptListener<T = undefined>(options: ClaudeInterceptListenerOptions<T>): Server<T> {
104
+ const upstreamBase = options.upstreamBase ?? CLAUDE_INTERCEPT_UPSTREAM;
105
+ let loopbackOrigin = "";
106
+ const server = Bun.serve<T>({
107
+ port: options.port ?? 0,
108
+ hostname: "127.0.0.1",
109
+ tls: { cert: options.leaf.certPem, key: options.leaf.keyPem },
110
+ idleTimeout: options.idleTimeout ?? 255,
111
+ ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}),
112
+ async fetch(req, requestServer) {
113
+ const url = new URL(req.url);
114
+ if (isClaudeInterceptedPath(url.pathname, req.method)) {
115
+ return options.dispatch(rewriteInterceptedRequest(req, loopbackOrigin), requestServer);
116
+ }
117
+ return relayToUpstream(req, upstreamBase, options.fetchImpl);
118
+ },
119
+ });
120
+ loopbackOrigin = `http://127.0.0.1:${server.port}`;
121
+ return server;
122
+ }
@@ -0,0 +1,298 @@
1
+ import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, X509Certificate, type KeyObject } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { withClientLifecycleSync } from "../../client/lifecycle-lock";
5
+
6
+ /**
7
+ * Local certificate authority for the Claude intercept listener.
8
+ *
9
+ * Claude Code opens `CONNECT api.anthropic.com:443` through `HTTPS_PROXY` and then expects a
10
+ * TLS server that presents a certificate for that name. This module mints that authority and
11
+ * its leaves with nothing but `node:crypto`: no OpenSSL shell-out, no third-party ASN.1
12
+ * library. The CA is trusted by the Claude Code process alone via `NODE_EXTRA_CA_CERTS`; it is
13
+ * never installed into an OS trust store.
14
+ *
15
+ * Only the subset of X.509 needed for a v3 CA and a serverAuth leaf is encoded. ECDSA P-256 is
16
+ * used for both, which Node's TLS stack accepts without any additional configuration.
17
+ */
18
+
19
+ export const CLAUDE_INTERCEPT_CA_COMMON_NAME = "opencodex Claude Intercept CA";
20
+ const ORGANIZATION = "opencodex";
21
+ const CA_VALIDITY_DAYS = 3650;
22
+ const LEAF_VALIDITY_DAYS = 365;
23
+
24
+ // ── DER encoding ────────────────────────────────────────────────────────────────
25
+
26
+ function derLength(length: number): Uint8Array {
27
+ if (length < 0x80) return Uint8Array.of(length);
28
+ const bytes: number[] = [];
29
+ let rest = length;
30
+ while (rest > 0) {
31
+ bytes.unshift(rest & 0xff);
32
+ rest >>= 8;
33
+ }
34
+ return Uint8Array.of(0x80 | bytes.length, ...bytes);
35
+ }
36
+
37
+ function concat(...parts: Uint8Array[]): Uint8Array {
38
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
39
+ const out = new Uint8Array(total);
40
+ let offset = 0;
41
+ for (const part of parts) {
42
+ out.set(part, offset);
43
+ offset += part.length;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function tlv(tag: number, body: Uint8Array): Uint8Array {
49
+ return concat(Uint8Array.of(tag), derLength(body.length), body);
50
+ }
51
+
52
+ const sequence = (...parts: Uint8Array[]) => tlv(0x30, concat(...parts));
53
+ const set = (...parts: Uint8Array[]) => tlv(0x31, concat(...parts));
54
+ const boolean = (value: boolean) => tlv(0x01, Uint8Array.of(value ? 0xff : 0x00));
55
+ const octetString = (body: Uint8Array) => tlv(0x04, body);
56
+ const utf8String = (text: string) => tlv(0x0c, new TextEncoder().encode(text));
57
+ const contextTag = (n: number, body: Uint8Array, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, body);
58
+
59
+ function integer(bytes: Uint8Array): Uint8Array {
60
+ return tlv(0x02, (bytes[0]! & 0x80) !== 0 ? concat(Uint8Array.of(0), bytes) : bytes);
61
+ }
62
+
63
+ function bitString(bytes: Uint8Array, unusedBits = 0): Uint8Array {
64
+ return tlv(0x03, concat(Uint8Array.of(unusedBits), bytes));
65
+ }
66
+
67
+ function objectIdentifier(dotted: string): Uint8Array {
68
+ const arcs = dotted.split(".").map(Number);
69
+ const bytes: number[] = [arcs[0]! * 40 + arcs[1]!];
70
+ for (const arc of arcs.slice(2)) {
71
+ const encoded: number[] = [arc & 0x7f];
72
+ let rest = arc >> 7;
73
+ while (rest > 0) {
74
+ encoded.unshift((rest & 0x7f) | 0x80);
75
+ rest >>= 7;
76
+ }
77
+ bytes.push(...encoded);
78
+ }
79
+ return tlv(0x06, Uint8Array.from(bytes));
80
+ }
81
+
82
+ function utcTime(date: Date): Uint8Array {
83
+ const pad = (n: number) => String(n).padStart(2, "0");
84
+ const text = `${pad(date.getUTCFullYear() % 100)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`
85
+ + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
86
+ return tlv(0x17, new TextEncoder().encode(text));
87
+ }
88
+
89
+ const OID = {
90
+ commonName: "2.5.4.3",
91
+ organization: "2.5.4.10",
92
+ ecdsaWithSha256: "1.2.840.10045.4.3.2",
93
+ basicConstraints: "2.5.29.19",
94
+ keyUsage: "2.5.29.15",
95
+ subjectAltName: "2.5.29.17",
96
+ extendedKeyUsage: "2.5.29.37",
97
+ subjectKeyIdentifier: "2.5.29.14",
98
+ authorityKeyIdentifier: "2.5.29.35",
99
+ serverAuth: "1.3.6.1.5.5.7.3.1",
100
+ } as const;
101
+
102
+ function distinguishedName(commonName: string): Uint8Array {
103
+ return sequence(
104
+ set(sequence(objectIdentifier(OID.organization), utf8String(ORGANIZATION))),
105
+ set(sequence(objectIdentifier(OID.commonName), utf8String(commonName))),
106
+ );
107
+ }
108
+
109
+ function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Array {
110
+ return critical
111
+ ? sequence(objectIdentifier(oid), boolean(true), octetString(value))
112
+ : sequence(objectIdentifier(oid), octetString(value));
113
+ }
114
+
115
+ function subjectPublicKeyInfo(key: KeyObject): Uint8Array {
116
+ return new Uint8Array(key.export({ type: "spki", format: "der" }));
117
+ }
118
+
119
+ /** RFC 5280 permits any unique octet string; a truncated SHA-256 of the SPKI is stable and collision-safe. */
120
+ function keyIdentifier(key: KeyObject): Uint8Array {
121
+ return new Uint8Array(createHash("sha256").update(subjectPublicKeyInfo(key)).digest()).subarray(0, 20);
122
+ }
123
+
124
+ function randomSerial(): Uint8Array {
125
+ const serial = crypto.getRandomValues(new Uint8Array(16));
126
+ serial[0]! &= 0x7f;
127
+ if (serial[0] === 0) serial[0] = 1;
128
+ return serial;
129
+ }
130
+
131
+ function toPem(label: string, der: Uint8Array): string {
132
+ const base64 = Buffer.from(der).toString("base64").replace(/(.{64})/g, "$1\n").trimEnd();
133
+ return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`;
134
+ }
135
+
136
+ interface IssueOptions {
137
+ subject: Uint8Array;
138
+ issuer: Uint8Array;
139
+ subjectKey: KeyObject;
140
+ signingKey: KeyObject;
141
+ validityDays: number;
142
+ extensions: Uint8Array[];
143
+ }
144
+
145
+ function issueCertificate(options: IssueOptions): Uint8Array {
146
+ // Back-date slightly so a client whose clock trails ours by a few minutes still accepts it.
147
+ const notBefore = new Date(Date.now() - 5 * 60_000);
148
+ const notAfter = new Date(notBefore.getTime() + options.validityDays * 86_400_000);
149
+ const algorithm = sequence(objectIdentifier(OID.ecdsaWithSha256));
150
+ const tbsCertificate = sequence(
151
+ contextTag(0, tlv(0x02, Uint8Array.of(2))),
152
+ integer(randomSerial()),
153
+ algorithm,
154
+ options.issuer,
155
+ sequence(utcTime(notBefore), utcTime(notAfter)),
156
+ options.subject,
157
+ subjectPublicKeyInfo(options.subjectKey),
158
+ contextTag(3, sequence(...options.extensions)),
159
+ );
160
+ const signature = new Uint8Array(sign("sha256", tbsCertificate, { key: options.signingKey, dsaEncoding: "der" }));
161
+ return sequence(tbsCertificate, algorithm, bitString(signature));
162
+ }
163
+
164
+ // ── Authority + leaves ──────────────────────────────────────────────────────────
165
+
166
+ export interface PemKeyPair {
167
+ certPem: string;
168
+ keyPem: string;
169
+ }
170
+
171
+ export interface LocalInterceptCa extends PemKeyPair {
172
+ publicKey: KeyObject;
173
+ privateKey: KeyObject;
174
+ }
175
+
176
+ export function createLocalInterceptCa(): LocalInterceptCa {
177
+ const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
178
+ const name = distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME);
179
+ const der = issueCertificate({
180
+ subject: name,
181
+ issuer: name,
182
+ subjectKey: publicKey,
183
+ signingKey: privateKey,
184
+ validityDays: CA_VALIDITY_DAYS,
185
+ extensions: [
186
+ extension(OID.basicConstraints, true, sequence(boolean(true), tlv(0x02, Uint8Array.of(0)))),
187
+ // keyCertSign | cRLSign
188
+ extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)),
189
+ extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))),
190
+ ],
191
+ });
192
+ return {
193
+ certPem: toPem("CERTIFICATE", der),
194
+ keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string,
195
+ publicKey,
196
+ privateKey,
197
+ };
198
+ }
199
+
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 {
202
+ if (hosts.length === 0) throw new Error("intercept leaf requires at least one host");
203
+ const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
204
+ const der = issueCertificate({
205
+ subject: distinguishedName(hosts[0]!),
206
+ issuer: distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME),
207
+ subjectKey: publicKey,
208
+ signingKey: ca.privateKey,
209
+ validityDays: LEAF_VALIDITY_DAYS,
210
+ extensions: [
211
+ extension(OID.basicConstraints, true, sequence()),
212
+ // digitalSignature
213
+ extension(OID.keyUsage, true, bitString(Uint8Array.of(0x80), 7)),
214
+ extension(OID.extendedKeyUsage, false, sequence(objectIdentifier(OID.serverAuth))),
215
+ extension(OID.subjectAltName, false, sequence(
216
+ ...hosts.map(host => contextTag(2, new TextEncoder().encode(host), false)),
217
+ )),
218
+ extension(OID.authorityKeyIdentifier, false, sequence(contextTag(0, keyIdentifier(ca.publicKey), false))),
219
+ ],
220
+ });
221
+ return {
222
+ certPem: toPem("CERTIFICATE", der),
223
+ keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string,
224
+ };
225
+ }
226
+
227
+ // ── Persistence ─────────────────────────────────────────────────────────────────
228
+
229
+ export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept";
230
+ export const CLAUDE_INTERCEPT_CA_CERT_FILE = "ca.pem";
231
+ const CA_KEY_FILE = "ca.key";
232
+
233
+ export function claudeInterceptStateDir(configDir: string): string {
234
+ return join(configDir, CLAUDE_INTERCEPT_STATE_DIR);
235
+ }
236
+
237
+ /** Path Claude Code must see in `NODE_EXTRA_CA_CERTS`. Stable across restarts. */
238
+ export function claudeInterceptCaCertPath(configDir: string): string {
239
+ return join(claudeInterceptStateDir(configDir), CLAUDE_INTERCEPT_CA_CERT_FILE);
240
+ }
241
+
242
+ function writeFileAtomic(path: string, contents: string, mode: number): void {
243
+ const tmp = `${path}.${process.pid}.tmp`;
244
+ writeFileSync(tmp, contents, { mode });
245
+ try { chmodSync(tmp, mode); } catch { /* best-effort on platforms without POSIX modes */ }
246
+ renameSync(tmp, path);
247
+ }
248
+
249
+ function loadPersistedCa(dir: string): LocalInterceptCa | null {
250
+ const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE);
251
+ const keyPath = join(dir, CA_KEY_FILE);
252
+ if (!existsSync(certPath) || !existsSync(keyPath)) return null;
253
+ try {
254
+ const certPem = readFileSync(certPath, "utf8");
255
+ const keyPem = readFileSync(keyPath, "utf8");
256
+ const privateKey = createPrivateKey(keyPem);
257
+ const publicKey = createPublicKey(keyPem);
258
+ const certificate = new X509Certificate(certPem);
259
+ if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)) return null;
260
+ return { certPem, keyPem, publicKey, privateKey };
261
+ } catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below.
262
+ return null;
263
+ }
264
+ }
265
+
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);
273
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
274
+ // A separate SQLite namespace binds exclusion to the explicit CA directory.
275
+ // The OS releases it on crash; a contending caller fails before touching either
276
+ // PEM. Readers also take the lease so they cannot observe half a publication.
277
+ return withClientLifecycleSync(() => {
278
+ const existing = loadPersistedCa(dir);
279
+ if (existing) return existing;
280
+ const ca = createLocalInterceptCa();
281
+ writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600);
282
+ writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644);
283
+ return ca;
284
+ }, { lockPath: join(dir, "ca-publication.sqlite") });
285
+ }
286
+
287
+ /** Startup may race a settings apply publishing the same CA. Retry only lease
288
+ * contention, before binding listeners, without blocking the main event loop. */
289
+ export async function ensureLocalInterceptCaForStartup(configDir: string): Promise<LocalInterceptCa> {
290
+ for (let attempt = 0; ; attempt += 1) {
291
+ try { return ensureLocalInterceptCa(configDir); }
292
+ catch (error) {
293
+ if (attempt >= 49 || !(error instanceof Error)
294
+ || !("code" in error) || error.code !== "client_lifecycle_busy") throw error;
295
+ await new Promise(resolve => setTimeout(resolve, 100));
296
+ }
297
+ }
298
+ }