@bitkyc08/opencodex 2.64.0-preview.20260923 → 2.65.0-preview.20260925

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-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-BEq4OCOz.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.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-D5eN1TID.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-Bh_ErQDh.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +0 -1
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Claude Desktop picker mode: when to terminate claude.ai, and the pieces that do it.
3
+ *
4
+ * Picker mode lists opencodex models by name in Desktop's Code-tab picker while Desktop stays
5
+ * first-party. Desktop's own egress profile (wp4) points the app at the intercept CONNECT proxy;
6
+ * for every tunnel the proxy asks `selectTunnel`, and only `claude.ai:443` can ever come back as
7
+ * `intercept`. Everything else, and claude.ai whenever any condition fails, is a blind tunnel.
8
+ *
9
+ * The decision is cached by `refresh()` and only read by `selectTunnel`, so a CONNECT never
10
+ * waits on the keychain or the catalog. Arming needs all of: macOS, the persisted resolved mode is
11
+ * first-party (observation-aware, so a pre-field first-party install counts), Desktop intent on,
12
+ * `claudeCode.intercept.picker !== false`, the disarm latch clear, the picker listener up and the
13
+ * current picker CA trusted in the login keychain. `disarm()` stops terminating immediately and
14
+ * latches; only the picker controller's owner-only `rearm()` clears the latch.
15
+ */
16
+ import { join } from "node:path";
17
+ import type { OcxConfig } from "../../types";
18
+ import type { ClaudeDesktopMode } from "../desktop-first-party";
19
+ import type { TunnelDecision } from "./connect-proxy";
20
+ import type { PemKeyPair } from "./local-ca";
21
+ import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca";
22
+ import { startPickerListener, type PickerListenerHandle } from "./picker-listener";
23
+ import { createPickerModelSnapshot, type PickerModelSnapshot, type PickerRouteInput } from "./picker-models";
24
+ import { inspectPickerTrust, type PickerTrustState, type SecurityRunner } from "./picker-trust";
25
+
26
+ export type TunnelChoice = TunnelDecision;
27
+
28
+ /** A claude.ai CONNECT that arrives before the first refresh waits at most this long. */
29
+ export const PICKER_STARTUP_WAIT_MS = 3_000;
30
+ export const PICKER_REFRESH_INTERVAL_MS = 60_000;
31
+ export const PICKER_TRUST_TTL_MS = 30_000;
32
+ /** Discovery for the picker list is refreshed in the background once the snapshot is this old. */
33
+ export const PICKER_MODELS_MAX_AGE_MS = 5 * 60_000;
34
+ export const PICKER_MODELS_FILE = "models.json";
35
+
36
+ export type PickerRuntimeReason =
37
+ | "active"
38
+ | "starting"
39
+ | "unsupported_platform"
40
+ | "not_desired"
41
+ | "disarmed"
42
+ | "busy"
43
+ | "trust_untrusted"
44
+ | "trust_unknown"
45
+ | "listener_failed"
46
+ | "refresh_failed"
47
+ | "stopped";
48
+
49
+ export interface PickerRuntimeStatus {
50
+ desired: boolean;
51
+ supported: boolean;
52
+ trust: PickerTrustState;
53
+ listenerReady: boolean;
54
+ effective: boolean;
55
+ latched: boolean;
56
+ reason: PickerRuntimeReason;
57
+ models: number;
58
+ snapshotAt: number | null;
59
+ /** Last time Desktop fetched a bootstrap through the picker listener (null = not since start). */
60
+ lastBootstrapAt: number | null;
61
+ }
62
+
63
+ export interface PickerRuntime {
64
+ selectTunnel(host: string, port: number): TunnelChoice | null | Promise<TunnelChoice | null>;
65
+ refreshTrust(): Promise<PickerTrustState>;
66
+ refresh(): Promise<void>;
67
+ disarm(): void;
68
+ rearm(): Promise<void>;
69
+ ensureStarted(): Promise<void>;
70
+ start(): Promise<void>;
71
+ readonly ready: Promise<void>;
72
+ status(): PickerRuntimeStatus;
73
+ stop(): Promise<void>;
74
+ }
75
+
76
+ export interface CreatePickerRuntimeOptions {
77
+ config: OcxConfig;
78
+ /** Persisted config for every decision; defaults to `loadConfig`. */
79
+ readConfig?: () => OcxConfig;
80
+ /** True while the picker controller holds its lock; refresh() then never arms. */
81
+ isBusy?: () => boolean;
82
+ configDir: string;
83
+ loadRoutes: () => Promise<PickerRouteInput>;
84
+ security?: SecurityRunner;
85
+ platform?: NodeJS.Platform;
86
+ now?: () => number;
87
+ trustTtlMs?: number;
88
+ startupWaitMs?: number;
89
+ refreshIntervalMs?: number;
90
+ /** Test seam: the observation-aware resolved Desktop mode for a config. */
91
+ resolveMode?: (config: OcxConfig) => ClaudeDesktopMode | Promise<ClaudeDesktopMode>;
92
+ /** Test seam: the claude.ai terminator. */
93
+ startListener?: typeof startPickerListener;
94
+ log?: (line: string) => void;
95
+ }
96
+
97
+ /**
98
+ * Whether picker mode should run for this persisted config and resolved mode. Desktop intent uses
99
+ * the durable-intent rule of `claudeDesktopIntegrationEnabled` (src/codex/desired-state.ts): only an
100
+ * explicit `false` turns it off. The picker preference is the same: on unless explicitly false.
101
+ */
102
+ export function pickerDesired(
103
+ config: Pick<OcxConfig, "claudeCode" | "clientIntegrations">,
104
+ mode: ClaudeDesktopMode,
105
+ platform: NodeJS.Platform = process.platform,
106
+ ): boolean {
107
+ return platform === "darwin"
108
+ && config.clientIntegrations?.["claude-desktop"] !== false
109
+ && mode === "first-party"
110
+ && config.claudeCode?.intercept?.picker !== false;
111
+ }
112
+
113
+ async function defaultResolveMode(config: OcxConfig): Promise<ClaudeDesktopMode> {
114
+ // Dynamic: desktop-first-party imports intercept/runtime, which imports this module.
115
+ const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../desktop-first-party");
116
+ return resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config));
117
+ }
118
+
119
+ async function defaultReadConfig(): Promise<() => OcxConfig> {
120
+ const { loadConfig } = await import("../../config");
121
+ return loadConfig;
122
+ }
123
+
124
+ export function createPickerRuntime(options: CreatePickerRuntimeOptions): PickerRuntime {
125
+ const platform = options.platform ?? process.platform;
126
+ const now = options.now ?? Date.now;
127
+ const trustTtlMs = options.trustTtlMs ?? PICKER_TRUST_TTL_MS;
128
+ const startupWaitMs = options.startupWaitMs ?? PICKER_STARTUP_WAIT_MS;
129
+ const refreshIntervalMs = options.refreshIntervalMs ?? PICKER_REFRESH_INTERVAL_MS;
130
+ const resolveMode = options.resolveMode ?? defaultResolveMode;
131
+ const startListener = options.startListener ?? startPickerListener;
132
+ const log = options.log ?? (() => {});
133
+
134
+ let desired = false;
135
+ let armed = false;
136
+ let latched = false;
137
+ let stopped = false;
138
+ let started = false;
139
+ let firstRefreshDone = false;
140
+ let generation = 0;
141
+ let reason: PickerRuntimeReason = "starting";
142
+ let trust: PickerTrustState = "unknown";
143
+ let trustCheckedAt = -Infinity;
144
+ let trustFor: string | null = null;
145
+ let ca: PickerCa | null = null;
146
+ let caSha1: string | null = null;
147
+ let leaf: PemKeyPair | null = null;
148
+ let snapshot: PickerModelSnapshot | null = null;
149
+ let listener: PickerListenerHandle | null = null;
150
+ let listenerPromise: Promise<PickerListenerHandle> | null = null;
151
+ let lastBootstrapAt: number | null = null;
152
+ let interval: ReturnType<typeof setInterval> | null = null;
153
+ let readConfig: (() => OcxConfig) | null = options.readConfig ?? null;
154
+ let resolveReady!: () => void;
155
+ const ready = new Promise<void>(resolve => { resolveReady = resolve; });
156
+
157
+ function closeListener(): void {
158
+ const pending = listenerPromise;
159
+ listenerPromise = null;
160
+ listener = null;
161
+ if (pending) void pending.then(handle => handle.close()).catch(() => {});
162
+ }
163
+
164
+ function ensureMaterial(): { ca: PickerCa; leaf: PemKeyPair; sha1: string } {
165
+ if (!ca || !leaf || !caSha1) {
166
+ ca = ensurePickerCa(options.configDir);
167
+ leaf = issuePickerLeaf(ca, options.configDir);
168
+ caSha1 = pickerCaFingerprints(ca.certPem).sha1;
169
+ }
170
+ return { ca, leaf, sha1: caSha1 };
171
+ }
172
+
173
+ function ensureSnapshot(): PickerModelSnapshot {
174
+ snapshot ??= createPickerModelSnapshot(options.loadRoutes, join(pickerStateDir(options.configDir), PICKER_MODELS_FILE));
175
+ return snapshot;
176
+ }
177
+
178
+ async function inspectTrust(force: boolean): Promise<PickerTrustState> {
179
+ const { sha1 } = ensureMaterial();
180
+ if (!force && trustFor === sha1 && now() - trustCheckedAt < trustTtlMs) return trust;
181
+ trust = await inspectPickerTrust(pickerLeafCertPath(options.configDir), sha1, options.security, platform);
182
+ trustFor = sha1;
183
+ trustCheckedAt = now();
184
+ return trust;
185
+ }
186
+
187
+ /** CA, leaf, snapshot and listener. Resolves false when a disarm overtook it. */
188
+ async function startPieces(gen: number): Promise<boolean> {
189
+ const material = ensureMaterial();
190
+ ensureSnapshot().refreshIfStale(PICKER_MODELS_MAX_AGE_MS);
191
+ if (!listenerPromise) {
192
+ const current = startListener({
193
+ leaf: material.leaf,
194
+ models: () => {
195
+ lastBootstrapAt = now();
196
+ return ensureSnapshot().current()?.models ?? [];
197
+ },
198
+ log,
199
+ });
200
+ listenerPromise = current;
201
+ current.catch(() => { if (listenerPromise === current) listenerPromise = null; });
202
+ }
203
+ const pending = listenerPromise;
204
+ const handle = await pending;
205
+ if (gen !== generation || latched || stopped || listenerPromise !== pending) return false;
206
+ listener = handle;
207
+ return true;
208
+ }
209
+
210
+ async function evaluate(bypassBusy: boolean, forceTrust: boolean): Promise<void> {
211
+ const gen = generation;
212
+ if (stopped) return;
213
+ try {
214
+ readConfig ??= await defaultReadConfig();
215
+ const fresh = readConfig();
216
+ const mode = await resolveMode(fresh);
217
+ if (gen !== generation || stopped) return;
218
+ desired = pickerDesired(fresh, mode, platform);
219
+ if (!desired || latched) {
220
+ armed = false;
221
+ reason = platform !== "darwin" ? "unsupported_platform" : latched ? "disarmed" : "not_desired";
222
+ closeListener();
223
+ return;
224
+ }
225
+ if (!bypassBusy && options.isBusy?.()) {
226
+ reason = armed ? reason : "busy";
227
+ return;
228
+ }
229
+ let up: boolean;
230
+ try {
231
+ up = await startPieces(gen);
232
+ } catch {
233
+ armed = false;
234
+ reason = "listener_failed";
235
+ return;
236
+ }
237
+ if (!up) return;
238
+ const state = await inspectTrust(forceTrust);
239
+ if (gen !== generation || latched || stopped) return;
240
+ armed = state === "trusted" && listener !== null;
241
+ reason = armed ? "active" : state === "unknown" ? "trust_unknown" : state === "unsupported" ? "unsupported_platform" : "trust_untrusted";
242
+ } catch (error) {
243
+ if (gen !== generation) return;
244
+ armed = false;
245
+ reason = "refresh_failed";
246
+ log(`picker refresh failed (${error instanceof Error ? error.name : "error"})`);
247
+ }
248
+ }
249
+
250
+ function currentChoice(): TunnelChoice {
251
+ return armed && listener && !latched && !stopped
252
+ ? { kind: "intercept", port: listener.port }
253
+ : { kind: "blind" };
254
+ }
255
+
256
+ return {
257
+ ready,
258
+ selectTunnel(host, port) {
259
+ if (host.toLowerCase() !== PICKER_HOST || port !== 443) return null;
260
+ if (!started || firstRefreshDone || stopped) return currentChoice();
261
+ let timer: ReturnType<typeof setTimeout> | undefined;
262
+ const timeout = new Promise<"timeout">(resolve => { timer = setTimeout(() => resolve("timeout"), startupWaitMs); });
263
+ return Promise.race([ready.then(() => "ready" as const), timeout]).then(outcome => {
264
+ clearTimeout(timer);
265
+ return outcome === "ready" ? currentChoice() : { kind: "blind" as const };
266
+ });
267
+ },
268
+ async refreshTrust() {
269
+ return inspectTrust(true);
270
+ },
271
+ async refresh() {
272
+ await evaluate(false, false);
273
+ },
274
+ disarm() {
275
+ latched = true;
276
+ armed = false;
277
+ generation += 1;
278
+ reason = "disarmed";
279
+ closeListener();
280
+ },
281
+ async rearm() {
282
+ if (stopped) return;
283
+ latched = false;
284
+ await evaluate(true, true);
285
+ },
286
+ async ensureStarted() {
287
+ if (latched || stopped) return;
288
+ await startPieces(generation);
289
+ },
290
+ start() {
291
+ if (started) return ready;
292
+ started = true;
293
+ void evaluate(false, false).finally(() => {
294
+ firstRefreshDone = true;
295
+ resolveReady();
296
+ });
297
+ interval = setInterval(() => { void evaluate(false, false); }, refreshIntervalMs);
298
+ (interval as { unref?: () => void }).unref?.();
299
+ return ready;
300
+ },
301
+ status() {
302
+ const current = snapshot?.current() ?? null;
303
+ return {
304
+ desired,
305
+ supported: platform === "darwin",
306
+ trust,
307
+ listenerReady: listener !== null,
308
+ effective: armed && listener !== null && !latched && !stopped,
309
+ latched,
310
+ reason: stopped ? "stopped" : reason,
311
+ models: current?.models.length ?? 0,
312
+ snapshotAt: current?.builtAt ?? null,
313
+ lastBootstrapAt,
314
+ };
315
+ },
316
+ async stop() {
317
+ stopped = true;
318
+ armed = false;
319
+ generation += 1;
320
+ if (interval) clearInterval(interval);
321
+ interval = null;
322
+ firstRefreshDone = true;
323
+ resolveReady();
324
+ const pending = listenerPromise;
325
+ listenerPromise = null;
326
+ listener = null;
327
+ if (pending) await pending.then(handle => handle.close(), () => {});
328
+ },
329
+ };
330
+ }
@@ -0,0 +1,125 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "./picker-ca";
5
+
6
+ /** Trust is scoped to the current root fingerprint and a verified persisted leaf. */
7
+ export type PickerTrustState = "trusted" | "untrusted" | "unsupported" | "unknown";
8
+ export interface SecurityResult { code: number | null; stdout: string; stderr: string }
9
+ export type SecurityRunner = (args: readonly string[]) => Promise<SecurityResult>;
10
+
11
+ export const defaultSecurityRunner: SecurityRunner = async args => {
12
+ const child = Bun.spawn(["/usr/bin/security", ...args], { stdout: "pipe", stderr: "pipe" });
13
+ const [stdout, stderr, code] = await Promise.all([
14
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
15
+ ]);
16
+ return { code, stdout, stderr };
17
+ };
18
+
19
+ export function loginKeychainPath(home = homedir()): string {
20
+ return join(home, "Library", "Keychains", "login.keychain-db");
21
+ }
22
+
23
+ function hasFingerprint(output: string, expected: string): boolean {
24
+ const normalized = expected.replace(/:/g, "").toUpperCase();
25
+ if (!/^[0-9A-F]{40}$/.test(normalized)) return false;
26
+ return output.split(/\r?\n/).some(line => {
27
+ const match = /^SHA-1 hash:\s*([0-9a-fA-F:]{40,59})\s*$/.exec(line.trim());
28
+ return !!match && match[1]!.replace(/:/g, "").toUpperCase() === normalized;
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Whether the current CA's user trust settings carry a policy string (a host scope such as the
34
+ * `-s claude.ai` earlier builds used). verify-cert honours those, but Chromium skips them, so
35
+ * Desktop would reject the picker leaf; the CA then counts as untrusted and trust is added again,
36
+ * which replaces the setting. `null` when the settings cannot be read; the caller then reports
37
+ * `unknown`, because arming on a setting Chromium skips would cut Desktop off from claude.ai.
38
+ */
39
+ async function hostScopedTrust(caSha1: string, run: SecurityRunner): Promise<boolean | null> {
40
+ let dir: string | undefined;
41
+ try {
42
+ dir = mkdtempSync(join(tmpdir(), "ocx-picker-trust-"));
43
+ const file = join(dir, "trust-settings.plist");
44
+ if ((await run(["trust-settings-export", file])).code !== 0) return null;
45
+ const xml = readFileSync(file, "utf8");
46
+ const at = xml.indexOf(`<key>${caSha1.replace(/:/g, "").toUpperCase()}</key>`);
47
+ if (at < 0) return false;
48
+ // The entry's trustSettings array holds flat dictionaries, so its first </array> ends it.
49
+ const end = xml.indexOf("</array>", at);
50
+ return xml.slice(at, end < 0 ? undefined : end).includes("<key>kSecTrustSettingsPolicyString</key>");
51
+ } catch { // no-excuse-ok: catch -- unreadable trust settings are no evidence of a host scope.
52
+ return null;
53
+ } finally {
54
+ if (dir) rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ }
57
+
58
+ export async function inspectPickerTrust(
59
+ leafPath: string,
60
+ caSha1: string,
61
+ run: SecurityRunner = defaultSecurityRunner,
62
+ platform: NodeJS.Platform = process.platform,
63
+ ): Promise<PickerTrustState> {
64
+ if (platform !== "darwin") return "unsupported";
65
+ const keychain = loginKeychainPath();
66
+ try {
67
+ const found = await run(["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, keychain]);
68
+ if (found.code === 1) return "untrusted";
69
+ if (found.code !== 0) return "unknown";
70
+ if (!hasFingerprint(found.stdout, caSha1)) return "untrusted";
71
+ const verified = await run(["verify-cert", "-q", "-L", "-c", leafPath,
72
+ "-p", "ssl", "-n", PICKER_HOST, "-k", keychain]);
73
+ if (verified.code === 0) {
74
+ const scoped = await hostScopedTrust(caSha1, run);
75
+ return scoped === null ? "unknown" : scoped ? "untrusted" : "trusted";
76
+ }
77
+ return verified.code === 1 ? "untrusted" : "unknown";
78
+ } catch { // no-excuse-ok: catch -- OS command unavailable or denied; never claim trust.
79
+ return "unknown";
80
+ }
81
+ }
82
+
83
+ export async function trustPickerCa(
84
+ caPath: string,
85
+ run: SecurityRunner = defaultSecurityRunner,
86
+ platform: NodeJS.Platform = process.platform,
87
+ ): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> {
88
+ if (platform !== "darwin") return { ok: false, reason: "unsupported" };
89
+ try {
90
+ // No `-s <host>` policy string: Chromium (Claude Desktop) skips trust settings that carry one,
91
+ // so a host-scoped setting leaves Desktop rejecting the picker leaf. The CA's critical name
92
+ // constraints already limit it to claude.ai; macOS verify-cert rejects any other name.
93
+ const result = await run(["add-trusted-cert", "-r", "trustRoot", "-p", "ssl",
94
+ "-k", loginKeychainPath(), caPath]);
95
+ return result.code === 0 ? { ok: true } : { ok: false, reason: "declined_or_failed" };
96
+ } catch { // no-excuse-ok: catch -- user decline and command failure share a safe result.
97
+ return { ok: false, reason: "declined_or_failed" };
98
+ }
99
+ }
100
+
101
+ export async function untrustPickerCa(
102
+ caPath: string,
103
+ fingerprintSha1: string,
104
+ run: SecurityRunner = defaultSecurityRunner,
105
+ platform: NodeJS.Platform = process.platform,
106
+ ): Promise<{ ok: boolean }> {
107
+ if (platform !== "darwin") return { ok: false };
108
+ const keychain = loginKeychainPath();
109
+ // Listed means the current picker CA is in the login keychain; unlisted means nothing to remove,
110
+ // which is success, so a machine that never trusted it never sees a keychain prompt for it.
111
+ const listed = async (): Promise<boolean> => {
112
+ const found = await run(["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, keychain]);
113
+ if (found.code === 1) return false;
114
+ if (found.code !== 0) throw new Error("find-certificate failed");
115
+ return hasFingerprint(found.stdout, fingerprintSha1);
116
+ };
117
+ try {
118
+ if (!(await listed())) return { ok: true };
119
+ const removed = await run(["remove-trusted-cert", caPath]);
120
+ const deleted = await run(["delete-certificate", "-Z", fingerprintSha1, keychain]);
121
+ return { ok: removed.code === 0 && deleted.code === 0 && !(await listed()) };
122
+ } catch { // no-excuse-ok: catch -- failed removal must be visible to the caller.
123
+ return { ok: false };
124
+ }
125
+ }
@@ -1,9 +1,13 @@
1
1
  import type { Server } from "bun";
2
2
  import type { OcxConfig } from "../../types";
3
3
  import { getConfigDir } from "../../config/paths";
4
- import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
4
+ import type { DesktopPickerController } from "../desktop-picker";
5
+ import { CLAUDE_INTERCEPT_HOSTS, isBrowserConnect, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
5
6
  import { startClaudeInterceptListener } from "./listener";
6
7
  import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
8
+ import type { PickerRouteInput } from "./picker-models";
9
+ import { createPickerRuntime, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
10
+ import type { SecurityRunner } from "./picker-trust";
7
11
 
8
12
  /**
9
13
  * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener).
@@ -11,6 +15,13 @@ import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocal
11
15
  * Started next to the public listener, torn down with it. The proxy port is derived from the
12
16
  * public port unless configured, because Claude Code's `settings.json` must name a port that
13
17
  * survives restarts; the TLS listener is ephemeral and only ever reached through the proxy.
18
+ *
19
+ * Picker mode adds a second CONNECT proxy on the next port, used as Claude Desktop's pinned egress
20
+ * proxy. Desktop also hands that proxy to the Claude Code processes it spawns, so the choice is per
21
+ * client: Claude Code (no User-Agent on CONNECT) trusts only the intercept CA and gets the
22
+ * api.anthropic.com intercept and nothing else; the app itself (a browser User-Agent) trusts only
23
+ * the login keychain and never meets the api.anthropic.com intercept, and only its claude.ai
24
+ * tunnels may be terminated by the picker runtime (src/claude/intercept/picker-runtime.ts).
14
25
  */
15
26
 
16
27
  export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;
@@ -27,9 +38,17 @@ export function claudeInterceptProxyPort(config: Pick<OcxConfig, "claudeCode">,
27
38
  return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET;
28
39
  }
29
40
 
41
+ /** Desktop's egress proxy for picker mode: the port after the intercept proxy (before it at 65535). */
42
+ export function claudePickerProxyPort(config: Pick<OcxConfig, "claudeCode">, publicPort: number): number {
43
+ const interceptPort = claudeInterceptProxyPort(config, publicPort);
44
+ return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1;
45
+ }
46
+
30
47
  export interface ClaudeInterceptState {
31
48
  proxyPort: number;
32
49
  caCertPath: string;
50
+ /** Desktop egress proxy for picker mode; null when the picker is not wired or could not bind. */
51
+ pickerProxyPort: number | null;
33
52
  }
34
53
 
35
54
  export interface ClaudeInterceptHandle<T = undefined> extends ClaudeInterceptState {
@@ -38,12 +57,44 @@ export interface ClaudeInterceptHandle<T = undefined> extends ClaudeInterceptSta
38
57
  }
39
58
 
40
59
  let activeState: ClaudeInterceptState | null = null;
60
+ let activePicker: PickerRuntime | null = null;
61
+ let activeController: DesktopPickerController | null = null;
41
62
 
42
63
  /** Live intercept endpoints, or `null` when the pair is not running in this process. */
43
64
  export function getClaudeInterceptState(): ClaudeInterceptState | null {
44
65
  return activeState;
45
66
  }
46
67
 
68
+ /** The running picker runtime, or `null` when picker mode is not wired in this process. */
69
+ export function getClaudePickerRuntime(): PickerRuntime | null {
70
+ return activePicker;
71
+ }
72
+
73
+ /** The picker controller that owns every picker mutation while this server runs, or `null`. */
74
+ export function getClaudePickerController(): DesktopPickerController | null {
75
+ return activeController;
76
+ }
77
+
78
+ /**
79
+ * Persist `claudeCode.intercept.picker` through the field-scoped writer and adopt the committed
80
+ * subtree into the live config, so a later whole-config save neither reverts nor re-applies it.
81
+ */
82
+ export async function createPickerPreferenceWriter(live: OcxConfig): Promise<(value: boolean) => boolean> {
83
+ const { adoptPersistedClaudeCode, mutatePersistedConfig } = await import("../../config");
84
+ return value => {
85
+ const outcome = mutatePersistedConfig(persisted => {
86
+ const claudeCode = persisted.claudeCode ?? {};
87
+ const intercept = claudeCode.intercept ?? {};
88
+ if (intercept.picker === value) return { changed: false, value: structuredClone(persisted.claudeCode) };
89
+ persisted.claudeCode = { ...claudeCode, intercept: { ...intercept, picker: value } };
90
+ return { changed: true, value: structuredClone(persisted.claudeCode) };
91
+ });
92
+ if (outcome.status === "unavailable") return false;
93
+ adoptPersistedClaudeCode(live, outcome.value);
94
+ return true;
95
+ };
96
+ }
97
+
47
98
  export interface StartClaudeInterceptOptions<T> {
48
99
  config: OcxConfig;
49
100
  /** Bound public port; the derived proxy port is offset from it. */
@@ -56,6 +107,13 @@ export interface StartClaudeInterceptOptions<T> {
56
107
  dispatch: (req: Request, server: Server<T>) => Promise<Response>;
57
108
  maxRequestBodySize?: number;
58
109
  configDir?: string;
110
+ /** Routes for Desktop's Code-tab picker. Picker mode is wired only when this is given. */
111
+ loadPickerRoutes?: () => Promise<PickerRouteInput>;
112
+ /** Test seam: builds the picker runtime. */
113
+ createPicker?: (options: CreatePickerRuntimeOptions) => PickerRuntime;
114
+ /** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */
115
+ pickerSecurity?: SecurityRunner;
116
+ pickerPlatform?: NodeJS.Platform;
59
117
  }
60
118
 
61
119
  /**
@@ -84,13 +142,102 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
84
142
  await listener.stop(true);
85
143
  throw error;
86
144
  }
87
- const state: ClaudeInterceptState = { proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir) };
145
+ // Widened on purpose: assignments happen in nested awaits the catch below must still see.
146
+ let picker = null as PickerRuntime | null;
147
+ let pickerProxy = null as ConnectProxyHandle | null;
148
+ let controller = null as DesktopPickerController | null;
149
+ let pickerProxyLive = false;
150
+ try {
151
+ if (options.loadPickerRoutes) {
152
+ picker = (options.createPicker ?? createPickerRuntime)({
153
+ config: options.config,
154
+ configDir,
155
+ loadRoutes: options.loadPickerRoutes,
156
+ // The controller's lock: while it is held, periodic refreshes never arm.
157
+ isBusy: () => controller?.busy() ?? false,
158
+ // Metadata only: method, bootstrap or other, status, and the rewrite outcome.
159
+ log: line => console.log(`[claude-picker] ${line}`),
160
+ ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}),
161
+ ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
162
+ });
163
+ const runtime = picker;
164
+ const interceptPort = listener.port!;
165
+ try {
166
+ pickerProxy = await startConnectProxy(claudePickerProxyPort(options.config, options.publicPort), {
167
+ interceptPort,
168
+ // No host list here: the choice below depends on which client opened the tunnel.
169
+ interceptHosts: [],
170
+ selectTunnel: (host, port, request) => {
171
+ // Desktop hands its pinned egress proxy to the Claude Code processes it spawns, so their
172
+ // api.anthropic.com traffic arrives here too and gets the same intercept as on the Claude
173
+ // Code proxy. Those processes trust only the intercept CA, so the picker never terminates
174
+ // their claude.ai tunnels; only the app's own (browser) CONNECTs reach the picker.
175
+ if (!isBrowserConnect(request)) {
176
+ return port === 443 && (CLAUDE_INTERCEPT_HOSTS as readonly string[]).includes(host.toLowerCase())
177
+ ? { kind: "intercept", port: interceptPort }
178
+ : { kind: "blind" };
179
+ }
180
+ return runtime.selectTunnel(host, port);
181
+ },
182
+ });
183
+ pickerProxyLive = true;
184
+ } catch (error) {
185
+ // Picker mode is optional: a busy port leaves the intercept pair running without it.
186
+ console.warn(`⚠ Claude Desktop picker proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
187
+ await runtime.stop();
188
+ picker = null;
189
+ }
190
+ if (picker) {
191
+ // Dynamic: the controller reaches desktop-first-party, which imports this module.
192
+ const [{ createDesktopPickerController }, { loadConfig }, persistPreference] = await Promise.all([
193
+ import("../desktop-picker"),
194
+ import("../../config"),
195
+ createPickerPreferenceWriter(options.config),
196
+ ]);
197
+ const boundProxy = pickerProxy;
198
+ controller = createDesktopPickerController({
199
+ runtime: picker,
200
+ readConfig: loadConfig,
201
+ persistPreference,
202
+ proxyPort: () => (pickerProxyLive && boundProxy ? boundProxy.port : null),
203
+ configDir,
204
+ ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}),
205
+ ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
206
+ });
207
+ await picker.start();
208
+ }
209
+ }
210
+ } catch (error) {
211
+ // Construction or start failed after the CONNECT proxy bound: release every socket first,
212
+ // so the lifecycle's catch never leaves a bound port without a handle.
213
+ pickerProxyLive = false;
214
+ controller = null;
215
+ await picker?.stop();
216
+ await pickerProxy?.close();
217
+ await proxy.close();
218
+ await listener.stop(true);
219
+ throw error;
220
+ }
221
+ const state: ClaudeInterceptState = {
222
+ proxyPort: proxy.port,
223
+ caCertPath: claudeInterceptCaCertPath(configDir),
224
+ pickerProxyPort: picker && pickerProxy ? pickerProxy.port : null,
225
+ };
88
226
  activeState = state;
227
+ activePicker = picker;
228
+ activeController = controller;
229
+ const ownPicker = picker;
230
+ const ownController = controller;
89
231
  return {
90
232
  ...state,
91
233
  listener,
92
234
  stop: async () => {
93
235
  if (activeState === state) activeState = null;
236
+ if (activePicker === ownPicker) activePicker = null;
237
+ if (activeController === ownController) activeController = null;
238
+ pickerProxyLive = false;
239
+ await ownPicker?.stop();
240
+ await pickerProxy?.close();
94
241
  await proxy.close();
95
242
  await listener.stop(true);
96
243
  },