@bitkyc08/opencodex 2.68.0 → 2.69.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 (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-DDbgOTh6.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-uqKRqfvw.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CuaBJkG4.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-COppQSVq.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-mpsibsFj.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-kUE8tuAK.js +0 -86
@@ -7,6 +7,8 @@ import { classifyInterceptClient, interceptRouteFor } from "./client-class";
7
7
  import { CLAUDE_INTERCEPT_HOSTS, isBrowserConnect, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
8
8
  import { startClaudeInterceptListener } from "./listener";
9
9
  import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
10
+ import { discardPickerCaKey, ensurePickerCa } from "./picker-ca";
11
+ import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup";
10
12
  import type { PickerRouteInput } from "./picker-models";
11
13
  import { createPickerRuntime, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
12
14
  import type { SecurityRunner } from "./picker-trust";
@@ -117,6 +119,8 @@ export interface StartClaudeInterceptOptions<T> {
117
119
  loadPickerRoutes?: () => Promise<PickerRouteInput>;
118
120
  /** Test seam: builds the picker runtime. */
119
121
  createPicker?: (options: CreatePickerRuntimeOptions) => PickerRuntime;
122
+ /** Test seam: bind real CONNECT handlers on kernel-assigned ports without probe-and-release races. */
123
+ startProxy?: typeof startConnectProxy;
120
124
  /** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */
121
125
  pickerSecurity?: SecurityRunner;
122
126
  pickerPlatform?: NodeJS.Platform;
@@ -127,11 +131,16 @@ export interface StartClaudeInterceptOptions<T> {
127
131
  * rejecting; callers treat it as a degraded optional integration, never as a startup failure.
128
132
  */
129
133
  export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptions<T>): Promise<ClaudeInterceptHandle<T> | null> {
134
+ // An older release may have left the picker's exportable signing key on disk. Remove it on every
135
+ // start, even when the intercept or the picker is off, so an upgrade with the picker disabled
136
+ // does not keep the key of a CA that may still be trusted. Failure here must not block startup.
137
+ try { discardPickerCaKey(options.configDir ?? getConfigDir()); } catch { /* retried on the next start */ }
130
138
  if (!claudeInterceptEnabled(options.config)) return null;
131
139
  const explicitPort = typeof options.config.claudeCode?.intercept?.port === "number";
132
140
  if (options.requestedPort === 0 && !explicitPort) return null;
133
141
  const configDir = options.configDir ?? getConfigDir();
134
142
  const ca = await ensureLocalInterceptCaForStartup(configDir);
143
+ const startProxy = options.startProxy ?? startConnectProxy;
135
144
  const authToken = ensureClaudeInterceptProxyToken(configDir);
136
145
  const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS);
137
146
  // Refresh an env we already own (e.g. a pre-auth proxy URL left by an upgrade) before the
@@ -156,7 +165,7 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
156
165
  });
157
166
  let proxy: ConnectProxyHandle;
158
167
  try {
159
- proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), {
168
+ proxy = await startProxy(claudeInterceptProxyPort(options.config, options.publicPort), {
160
169
  interceptPort: listener.port!,
161
170
  // A real apply may recreate a missing token while this listener remains live.
162
171
  // Read current validated authority per CONNECT; absent/invalid means deny, not mint.
@@ -173,7 +182,52 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
173
182
  let pickerProxyLive = false;
174
183
  try {
175
184
  if (options.loadPickerRoutes) {
176
- picker = (options.createPicker ?? createPickerRuntime)({
185
+ // A picker authority is process-scoped, and older releases persisted an exportable ca.key
186
+ // next to the published certificate. Drop that key before anything else: a cleanup failure
187
+ // below must never leave a signing key on disk that outlives this process.
188
+ discardPickerCaKey(configDir);
189
+ const { inspectDesktopPickerProfile } = await import("../desktop-picker-profile");
190
+ // The applied profile row is durable evidence of the user's picker selection. Rotation keeps
191
+ // it in place — row id and its recorded previous selection included — so the picker proxy
192
+ // can fail to bind without losing it, and the restore enable below only updates the proxy
193
+ // URL inside the same row rather than recreating a selection around a placeholder pivot.
194
+ const pickerProfile = inspectDesktopPickerProfile({
195
+ configDir,
196
+ ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
197
+ });
198
+ const pickerProfileApplied = pickerProfile.kind === "applied";
199
+ let pickerBlocked = false;
200
+ try {
201
+ // A prior process may have died after publishing the journal but before cleanup. Finish
202
+ // that entry before rotation can replace it, then drain the newly queued predecessor.
203
+ const drain = () => drainPendingPickerCaUntrust(configDir, options.pickerSecurity, options.pickerPlatform);
204
+ if (!await drain()) pickerBlocked = true;
205
+ if (!pickerBlocked) {
206
+ ensurePickerCa(configDir, { rotation: "startup" });
207
+ if (!await drain()) pickerBlocked = true;
208
+ }
209
+ } catch (error) {
210
+ pickerBlocked = true;
211
+ console.warn(`⚠ Claude Desktop picker CA cleanup deferred: ${error instanceof Error ? error.message : String(error)}`);
212
+ }
213
+ if (pickerBlocked) {
214
+ console.warn("⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted");
215
+ if (pickerProfile.kind === "applied") {
216
+ // Keep Desktop's actual pinned egress alive without ever constructing a TLS terminator.
217
+ const port = Number(new URL(pickerProfile.proxyUrl).port);
218
+ try {
219
+ pickerProxy = await startProxy(port, {
220
+ interceptPort: listener.port!,
221
+ interceptHosts: [],
222
+ selectTunnel: () => ({ kind: "blind" }),
223
+ });
224
+ pickerProxyLive = true;
225
+ } catch (error) {
226
+ console.warn(`⚠ Claude Desktop blind egress relay could not start: ${error instanceof Error ? error.message : String(error)}`);
227
+ }
228
+ }
229
+ }
230
+ picker = pickerBlocked ? null : (options.createPicker ?? createPickerRuntime)({
177
231
  config: options.config,
178
232
  configDir,
179
233
  loadRoutes: options.loadPickerRoutes,
@@ -184,35 +238,39 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
184
238
  ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}),
185
239
  ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
186
240
  });
187
- const runtime = picker;
188
- const interceptPort = listener.port!;
189
- try {
190
- pickerProxy = await startConnectProxy(claudePickerProxyPort(options.config, options.publicPort), {
191
- interceptPort,
192
- // No authToken: Desktop's egressProxyUrl cannot present proxy credentials, so this
193
- // listener stays an unauthenticated loopback relay until the profile format can carry
194
- // one. The intercept proxy above is the credential-bearing hop.
195
- // No host list here: the choice below depends on which client opened the tunnel.
196
- interceptHosts: [],
197
- selectTunnel: (host, port, request) => {
198
- // Desktop hands its pinned egress proxy to the Claude Code processes it spawns, so their
199
- // api.anthropic.com traffic arrives here too and gets the same intercept as on the Claude
200
- // Code proxy. Those processes trust only the intercept CA, so the picker never terminates
201
- // their claude.ai tunnels; only the app's own (browser) CONNECTs reach the picker.
202
- if (!isBrowserConnect(request)) {
203
- return port === 443 && (CLAUDE_INTERCEPT_HOSTS as readonly string[]).includes(host.toLowerCase())
204
- ? { kind: "intercept", port: interceptPort }
205
- : { kind: "blind" };
206
- }
207
- return runtime.selectTunnel(host, port);
208
- },
209
- });
210
- pickerProxyLive = true;
211
- } catch (error) {
212
- // Picker mode is optional: a busy port leaves the intercept pair running without it.
213
- console.warn(`⚠ Claude Desktop picker proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
214
- await runtime.stop();
215
- picker = null;
241
+ if (picker) {
242
+ const runtime = picker;
243
+ const interceptPort = listener.port!;
244
+ try {
245
+ pickerProxy = await startProxy(claudePickerProxyPort(options.config, options.publicPort), {
246
+ interceptPort,
247
+ // No authToken: Desktop's egressProxyUrl cannot present proxy credentials, so this
248
+ // listener stays an unauthenticated loopback relay until the profile format can carry
249
+ // one. The intercept proxy above is the credential-bearing hop.
250
+ // No host list here: the choice below depends on which client opened the tunnel.
251
+ interceptHosts: [],
252
+ selectTunnel: (host, port, request) => {
253
+ // Desktop hands its pinned egress proxy to the Claude Code processes it spawns, so their
254
+ // api.anthropic.com traffic arrives here too and gets the same intercept as on the Claude
255
+ // Code proxy. Those processes trust only the intercept CA, so the picker never terminates
256
+ // their claude.ai tunnels; only the app's own (browser) CONNECTs reach the picker.
257
+ if (!isBrowserConnect(request)) {
258
+ return port === 443 && (CLAUDE_INTERCEPT_HOSTS as readonly string[]).includes(host.toLowerCase())
259
+ ? { kind: "intercept", port: interceptPort }
260
+ : { kind: "blind" };
261
+ }
262
+ return runtime.selectTunnel(host, port);
263
+ },
264
+ });
265
+ pickerProxyLive = true;
266
+ } catch (error) {
267
+ // Picker mode is optional: a busy port leaves the intercept pair running without it.
268
+ // The selected profile row survives, so the next startup retries the restore once the
269
+ // port is free again.
270
+ console.warn(`⚠ Claude Desktop picker proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
271
+ await runtime.stop();
272
+ picker = null;
273
+ }
216
274
  }
217
275
  if (picker) {
218
276
  // Dynamic: the controller reaches desktop-first-party, which imports this module.
@@ -232,6 +290,15 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
232
290
  ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
233
291
  });
234
292
  await picker.start();
293
+ // The rotated authority still needs the user's consent in the login keychain — a decline
294
+ // reports trust_pending, and the picker stays a blind tunnel until trust is granted. When
295
+ // Desktop was pinned to the picker before the restart, the regular enable flow re-trusts
296
+ // the new authority and rewrites the profile row in place, so a restart does not silently
297
+ // turn the picker off.
298
+ if (pickerProfileApplied && controller) {
299
+ void controller.enable({ persist: false, context: "server" })
300
+ .catch(error => console.warn(`⚠ Claude Desktop picker restore failed: ${error instanceof Error ? error.message : String(error)}`));
301
+ }
235
302
  }
236
303
  }
237
304
  } catch (error) {
@@ -248,7 +315,7 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
248
315
  const state: ClaudeInterceptState = {
249
316
  proxyPort: proxy.port,
250
317
  caCertPath: claudeInterceptCaCertPath(configDir),
251
- pickerProxyPort: picker && pickerProxy ? pickerProxy.port : null,
318
+ pickerProxyPort: pickerProxy?.port ?? null,
252
319
  };
253
320
  activeState = state;
254
321
  activePicker = picker;
@@ -28,7 +28,7 @@ export interface AccountRow {
28
28
  active: boolean;
29
29
  needsReauth?: boolean;
30
30
  autoSelectable?: boolean;
31
- skipReason?: "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
31
+ skipReason?: "paused" | "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
32
32
  selectionExcludedReason?: "plan_excluded";
33
33
  selectionExcludedPlan?: string;
34
34
  /** Registered credential that is still excluded from routing until validation completes. */
@@ -345,6 +345,8 @@ interface OAuthAccountDto {
345
345
  email?: string;
346
346
  active?: boolean;
347
347
  needsReauth?: boolean;
348
+ /** Present only for providers that support operator pause (generic OAuth pools). */
349
+ paused?: boolean;
348
350
  autoSelectable?: boolean;
349
351
  skipReason?: unknown;
350
352
  /** Always sent by the management route; explicitly `null` when the tier is unknown. */
@@ -355,7 +357,7 @@ interface OAuthAccountDto {
355
357
  }
356
358
 
357
359
  function isKiroSkipReason(value: unknown): value is NonNullable<AccountRow["skipReason"]> {
358
- return value === "needs_reauth" || value === "suspended"
360
+ return value === "paused" || value === "needs_reauth" || value === "suspended"
359
361
  || value === "cooldown" || value === "quota_exhausted";
360
362
  }
361
363
 
@@ -385,6 +387,7 @@ async function fetchOAuthRows(
385
387
  email: a.email,
386
388
  active: a.active ?? a.id === activeId,
387
389
  needsReauth: a.needsReauth,
390
+ ...(a.paused === true ? { paused: true } : {}),
388
391
  ...(name === "kiro" && typeof a.autoSelectable === "boolean"
389
392
  ? { autoSelectable: a.autoSelectable } : {}),
390
393
  ...(name === "kiro" && a.autoSelectable === false && isKiroSkipReason(a.skipReason)
@@ -772,14 +772,21 @@ export async function cmdPriority(args: string[], deps: AccountDeps): Promise<nu
772
772
  return 0;
773
773
  }
774
774
 
775
- /**
776
- * `ocx account pause|resume <provider> <id>` (#2702).
777
- *
778
- * The server routes have always existed; only the CLI caller was missing, so pausing an
779
- * account was dashboard-only. The issue reports these as POST; the code is PUT
780
- * (`auth-api.ts:1494`), and the route is shared by both directions with a `paused` boolean
781
- * rather than being two endpoints.
782
- */
775
+ function resolveGenericOAuthPauseTarget(accounts: unknown[], requested: string): { id: string } | { error: string } {
776
+ const rows = accounts.filter((value): value is { id: string; alias?: unknown } =>
777
+ typeof value === "object" && value !== null && typeof (value as { id?: unknown }).id === "string",
778
+ );
779
+ if (rows.some(account => account.id === requested)) return { id: requested };
780
+ const exact = rows.filter(account => account.alias === requested);
781
+ const matches = exact.length > 0
782
+ ? exact
783
+ : rows.filter(account => typeof account.alias === "string" && account.alias.toLowerCase() === requested.toLowerCase());
784
+ if (matches.length === 1) return { id: matches[0]!.id };
785
+ if (matches.length > 1) return { error: `alias "${requested}" names ${matches.length} accounts; use the account id` };
786
+ return { error: `Account not found: no OAuth account has the id or alias "${requested}"` };
787
+ }
788
+
789
+ /** Pause or resume a Codex account or a generic OAuth provider account. */
783
790
  export async function cmdPause(args: string[], deps: AccountDeps, paused: boolean): Promise<number> {
784
791
  const wantsJson = flag(args, "--json");
785
792
  const name = args.shift();
@@ -788,11 +795,40 @@ export async function cmdPause(args: string[], deps: AccountDeps, paused: boolea
788
795
  if (!name || !requestedId || args.length) return usage();
789
796
  const classified = configAndType(deps, name);
790
797
  if ("error" in classified) return usage(`Error: ${classified.error}`);
791
- if (classified.type !== "codex") {
792
- return usage(`Error: ${verb} applies to the openai Codex account pool`);
793
- }
794
798
  const baseUrl = await resolveBaseUrl(deps);
795
799
  if (!baseUrl) return proxyUnreachable();
800
+
801
+ if (classified.type === "oauth") {
802
+ if (name === "anthropic") return usage(`Error: ${verb} is not supported for the Anthropic OAuth pool`);
803
+ const list = await apiJson(deps, baseUrl, "GET", `/api/oauth/accounts?provider=${encodeURIComponent(name)}`);
804
+ if (list.status === 0) return proxyUnreachable(list.transportError);
805
+ if (list.status !== 200) return apiError(list.json, `failed to list ${name} OAuth accounts`, list.status);
806
+ const target = resolveGenericOAuthPauseTarget(Array.isArray(list.json.accounts) ? list.json.accounts : [], requestedId);
807
+ if ("error" in target) return usage(`Error: ${target.error}`);
808
+
809
+ const response = await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/pause", {
810
+ provider: name,
811
+ accountId: target.id,
812
+ paused,
813
+ });
814
+ if (response.status === 0) return proxyUnreachable(response.transportError);
815
+ if (response.status !== 200) return apiError(response.json, `failed to ${verb} ${requestedId}`, response.status);
816
+
817
+ if (wantsJson) {
818
+ console.log(JSON.stringify({ ok: true, provider: name, id: target.id, paused,
819
+ activeAccountId: response.json.activeAccountId }, null, 2));
820
+ } else {
821
+ console.log(`${name}: ${requestedId} ${paused ? "paused" : "resumed"}`);
822
+ if (response.json.activeAccountChanged === true) {
823
+ console.error(`Active account changed to ${String(response.json.activeAccountId)}.`);
824
+ }
825
+ }
826
+ return 0;
827
+ }
828
+
829
+ if (classified.type !== "codex") {
830
+ return usage(`Error: ${verb} applies to the openai Codex account pool or a generic OAuth provider`);
831
+ }
796
832
  const target = await resolveCodexAccountTarget(deps, baseUrl, requestedId);
797
833
  if ("networkDown" in target) return proxyUnreachable(target.transportError);
798
834
  if ("error" in target) return reportCodexAccountTargetError(target);
@@ -15,7 +15,7 @@ const MAIN_ALIAS = "main";
15
15
 
16
16
  export type CodexAccountTarget =
17
17
  | { id: string }
18
- | { error: string; kind: "not_found" | "ambiguous" | "reserved" }
18
+ | { error: string; kind: "not_found" | "ambiguous" | "reserved" | "unavailable" }
19
19
  | { networkDown: true; transportError?: string };
20
20
 
21
21
  /** Built-in selectors cannot be reused as Codex account aliases. */
@@ -35,18 +35,21 @@ export async function resolveCodexAccountTarget(
35
35
  requested: string,
36
36
  ): Promise<CodexAccountTarget> {
37
37
  if (requested === MAIN_ALIAS || requested === MAIN_CODEX_ACCOUNT_ID) return { id: MAIN_CODEX_ACCOUNT_ID };
38
- if (isReservedCodexAccountWord(requested)) {
39
- return { error: `"${requested}" is reserved; it clears the selection with \`ocx account use\` and names no account`, kind: "reserved" };
40
- }
41
38
  const res = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/accounts");
42
39
  if (res.status === 0) return { networkDown: true, transportError: res.transportError };
43
40
  // The list is only needed to turn an alias into an id. If the proxy cannot produce it, send
44
41
  // the argument as the id it may already be and let the route answer, as the CLI always did.
45
- if (res.status !== 200) return { id: requested };
42
+ if (res.status !== 200) {
43
+ if (!isReservedCodexAccountWord(requested)) return { id: requested };
44
+ return { error: `Cannot safely resolve reserved account selector "${requested}" while the account list is unavailable`, kind: "unavailable" };
45
+ }
46
46
  const accounts = (Array.isArray(res.json.accounts) ? res.json.accounts : [])
47
47
  .filter((entry): entry is { id: string; alias?: unknown } =>
48
48
  typeof entry === "object" && entry !== null && typeof (entry as { id?: unknown }).id === "string");
49
49
  if (accounts.some(account => account.id === requested)) return { id: requested };
50
+ if (isReservedCodexAccountWord(requested)) {
51
+ return { error: `"${requested}" is reserved; it clears the selection with \`ocx account use\` and names no account`, kind: "reserved" };
52
+ }
50
53
  const exact = accounts.filter(account => account.alias === requested);
51
54
  const matches = exact.length > 0
52
55
  ? exact
@@ -69,10 +72,12 @@ export async function resolveCodexUseTarget(
69
72
  baseUrl: string,
70
73
  requested: string,
71
74
  ): Promise<CodexUseTarget> {
72
- if (requested === AUTO_ACCOUNT_ARGUMENT) return { accountId: null };
73
75
  const target = await resolveCodexAccountTarget(deps, baseUrl, requested);
74
76
  if ("networkDown" in target) return target;
75
- if ("error" in target) return target;
77
+ if ("error" in target) {
78
+ if (requested === AUTO_ACCOUNT_ARGUMENT && target.kind === "reserved") return { accountId: null };
79
+ return target;
80
+ }
76
81
  return { accountId: target.id };
77
82
  }
78
83
 
@@ -45,6 +45,7 @@ const ACCOUNT_USAGE = `Usage:
45
45
  ocx account history openai <pool-account-id> [--limit <1-200>] [--json]
46
46
  ocx account current <provider> [--json]
47
47
  ocx account use <provider> <account-or-key-id|alias|main|auto> [--json]
48
+ ocx account clear <provider> [--json]
48
49
  ocx account refresh <provider> [--json]
49
50
  ocx account auto-switch <provider> <on|off|status|threshold <0-100>> [--json]
50
51
  ocx account alias <provider> <account-or-key-id|alias> <display-name|-> [--json]
@@ -68,8 +69,10 @@ const ACCOUNT_USAGE = `Usage:
68
69
 
69
70
  List and switch provider accounts and API-key pools (masked output only).
70
71
  'main' selects the Codex App login for the openai account pool; 'auto' clears the
71
- selection so the pool places work by its own strategy. A Codex account can be named
72
- by the alias set with 'ocx account alias' wherever an id is accepted.`;
72
+ selection so the pool places work by its own strategy — unless an account actually
73
+ carries that id, which wins, so 'ocx account clear' is the spelling that always
74
+ clears. A Codex account can be named by the alias set with 'ocx account alias'
75
+ wherever an id is accepted.`;
73
76
 
74
77
  function consumeFlag(args: string[], flag: string): boolean {
75
78
  const idx = args.indexOf(flag);
@@ -105,7 +108,8 @@ function statusText(row: AccountRow): string {
105
108
  if (row.paused) parts.push("paused");
106
109
  if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
107
110
  if (row.needsReauth && !(row.provider === "kiro" && row.skipReason === "needs_reauth")) parts.push("needs-reauth");
108
- if (row.provider === "kiro" && row.autoSelectable === false)
111
+ // A paused Kiro row already says "paused"; repeating it as a skip reason adds nothing.
112
+ if (row.provider === "kiro" && row.autoSelectable === false && !(row.paused && row.skipReason === "paused"))
109
113
  parts.push(row.skipReason ? `not-auto-selected(${row.skipReason})` : "not-auto-selected");
110
114
  if (row.validationPending) parts.push("validation-pending");
111
115
  if (row.selectionExcludedReason === "plan_excluded") {
@@ -352,6 +356,45 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
352
356
  return 0;
353
357
  }
354
358
 
359
+ /** `ocx account clear` never resolves its argument as an account id, so an account literally
360
+ * named `auto` cannot shadow the verb that returns the pool to automatic selection. */
361
+ async function cmdClear(rest: string[], deps: AccountDeps): Promise<number> {
362
+ const wantsJson = consumeFlag(rest, "--json");
363
+ const name = rest.shift();
364
+ const leftover = leftoverArgsError(rest);
365
+ if (!name || leftover) {
366
+ if (leftover) console.error(leftover);
367
+ console.error(ACCOUNT_USAGE);
368
+ return 1;
369
+ }
370
+ const config = deps.loadConfigImpl?.() ?? loadConfig();
371
+ const c = classifyAccount(config, name);
372
+ if ("error" in c) {
373
+ console.error(`Error: ${c.error}. Known candidates: ${candidateNames(config)}`);
374
+ return 1;
375
+ }
376
+ if (c.type !== "codex") {
377
+ console.error(`Error: ${name} has no automatic-selection pin to clear; clear applies to Codex account pools`);
378
+ return 1;
379
+ }
380
+ const baseUrl = await resolveBaseUrl(deps);
381
+ if (!baseUrl) return proxyUnreachable();
382
+ const res = await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/active", { accountId: null });
383
+ if (res.status === 0) return proxyUnreachable(res.transportError);
384
+ if (res.status !== 200) return apiError(res.json, `failed to clear ${name}`, res.status);
385
+ const pinDrainReason = typeof res.json.pinDrainReason === "string" ? res.json.pinDrainReason : undefined;
386
+ if (wantsJson) {
387
+ console.log(JSON.stringify({
388
+ ok: true, provider: name, type: c.type, activeId: null,
389
+ ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
390
+ }, null, 2));
391
+ } else {
392
+ console.log(`${name}: automatic account selection (pin cleared)`);
393
+ }
394
+ await explainCodexUseOutcome(deps, baseUrl, name, null, pinDrainReason);
395
+ return 0;
396
+ }
397
+
355
398
  export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promise<number> {
356
399
  const [sub, ...rest] = args;
357
400
  try {
@@ -362,6 +405,7 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
362
405
  }
363
406
  if (sub === "current") return await cmdCurrent(rest, deps);
364
407
  if (sub === "use") return await cmdUse(rest, deps);
408
+ if (sub === "clear") return await cmdClear(rest, deps);
365
409
  if (sub === "refresh") return await cmdRefresh(rest, deps);
366
410
  if (sub === "auto-switch") return await cmdAutoSwitch(rest, deps);
367
411
  if (sub === "alias" || sub === "rename") return await cmdAlias(rest, deps);
@@ -240,6 +240,25 @@ export const CAPABILITIES: readonly Capability[] = [
240
240
  json: "payload",
241
241
  details: ["Uses the exact upstream model ID after the first slash. Omitted cache rates default to zero; sibling model prices are preserved."],
242
242
  },
243
+ {
244
+ command: ["models", "set"],
245
+ summary: "Save per-model overrides for a routed model, or clear them back to the computed values.",
246
+ routes: [{ method: "PUT", path: "/api/model-settings" }],
247
+ flags: [
248
+ { name: "--context-window", value: "string", summary: "Context window in tokens; 0 or - clears the override." },
249
+ { name: "--modalities", value: "string", summary: "Comma-separated text,image,audio; - clears the override." },
250
+ { name: "--reasoning-efforts", value: "string", summary: "Comma-separated ladder; \"\" for no reasoning, - to inherit." },
251
+ { name: "--default-reasoning-effort", value: "string", summary: "Ladder member a request inherits when it omits one; - to inherit." },
252
+ { name: "--reset", value: "boolean", summary: "Clear every override on this model; cannot be combined with the options above." },
253
+ { name: "--json", value: "boolean", summary: "Emit the saved state as JSON." },
254
+ ],
255
+ mutates: true,
256
+ json: "envelope",
257
+ details: [
258
+ "Addresses a routed model as provider/model. The native openai lane and combos have no per-model overrides.",
259
+ "Unlike ocx models edit, which changes a custom model's own definition, this edits a row that already exists.",
260
+ ],
261
+ },
243
262
  {
244
263
  command: ["status"],
245
264
  summary: "Proxy status, injection state, and version skew between this CLI and the running proxy.",
@@ -522,21 +541,28 @@ export const CAPABILITIES: readonly Capability[] = [
522
541
  },
523
542
  {
524
543
  command: ["account", "pause"],
525
- summary: "Stop routing new requests to one account in the Codex pool.",
526
- // One route, both directions: `resume` is the same PUT with `paused: false`.
527
- routes: [{ method: "PUT", path: "/api/codex-auth/accounts/pause" }],
544
+ summary: "Exclude one account in a Codex or supported generic OAuth pool from automatic selection.",
545
+ // Resume uses the same endpoints with `paused: false`.
546
+ routes: [
547
+ { method: "PUT", path: "/api/codex-auth/accounts/pause" },
548
+ { method: "GET", path: "/api/oauth/accounts" },
549
+ { method: "PUT", path: "/api/oauth/accounts/pause" },
550
+ ],
528
551
  flags: [{ name: "--json", value: "boolean", summary: "Emit the pause result as JSON." }],
529
552
  mutates: true,
530
553
  json: "envelope",
531
554
  details: [
532
- "Pausing also unbinds threads pinned to the account and selects a fallback if it was active -- side effects of the route, not of the word `pause`.",
533
- "The issue that requested this reported the route as POST; it is PUT.",
555
+ "Codex pause unbinds pinned threads and selects a fallback when possible; with no fallback, a paused-but-selected Codex account still receives requests. Generic OAuth pause never dispatches to that account: it is excluded from new requests, failover and refresh, and an all-paused pool answers 403. Anthropic is unsupported.",
534
556
  ],
535
557
  },
536
558
  {
537
559
  command: ["account", "resume"],
538
- summary: "Return a paused account to the Codex pool.",
539
- routes: [{ method: "PUT", path: "/api/codex-auth/accounts/pause" }],
560
+ summary: "Return a paused account to a Codex or supported generic OAuth pool.",
561
+ routes: [
562
+ { method: "PUT", path: "/api/codex-auth/accounts/pause" },
563
+ { method: "GET", path: "/api/oauth/accounts" },
564
+ { method: "PUT", path: "/api/oauth/accounts/pause" },
565
+ ],
540
566
  flags: [{ name: "--json", value: "boolean", summary: "Emit the resume result as JSON." }],
541
567
  mutates: true,
542
568
  json: "envelope",
@@ -1,4 +1,5 @@
1
1
  import type { OcxConfig } from "../types";
2
+ import { siblingOfLivePort } from "../codex/sibling-start";
2
3
  import { injectClaudeAgentDefs } from "../claude/agents-inject";
3
4
  import { readCachedHubState } from "../client/hub-state";
4
5
  import { fetchClaudeContextWindows } from "./claude";
@@ -77,6 +78,7 @@ export async function syncClaudeAgentDefsAtProxyStartup(
77
78
  port: number,
78
79
  deps: ClaudeAgentStartupSyncDeps = {},
79
80
  ): Promise<string[] | null> {
81
+ if (siblingOfLivePort() !== null) return null;
80
82
  const inject = deps.injectAgentDefs ?? injectClaudeAgentDefs;
81
83
  const warn = deps.warn ?? (message => console.warn(message));
82
84
 
@@ -136,23 +136,36 @@ function persistPickerPreference(value: boolean): boolean {
136
136
  return outcome.status !== "unavailable";
137
137
  }
138
138
 
139
- function pickerTrustPaths(deps: ApplyProfileDeps, configDir = getConfigDir()): { caPath: string; leafPath: string; sha1: string } {
140
- const ca = (deps.ensurePickerCaImpl ?? ensurePickerCa)(configDir);
139
+ function pickerTrustPaths(deps: ApplyProfileDeps, configDir = getConfigDir()): { caPath: string; leafPath: string; sha1: string; certPem: string } {
140
+ const caPath = pickerCaCertPath(configDir);
141
+ const certPem = deps.ensurePickerCaImpl
142
+ ? deps.ensurePickerCaImpl(configDir).certPem
143
+ : readFileSync(caPath, "utf8");
141
144
  return {
142
- caPath: pickerCaCertPath(configDir),
145
+ caPath,
143
146
  leafPath: pickerLeafCertPath(configDir),
144
- sha1: pickerCaFingerprints(ca.certPem).sha1,
147
+ sha1: pickerCaFingerprints(certPem).sha1,
148
+ certPem,
145
149
  };
146
150
  }
147
151
 
148
- async function trustPickerLocally(deps: ApplyProfileDeps): Promise<{ ok: true; callerAddedTrust: boolean; caPath: string; sha1: string } | { ok: false; reason: string }> {
152
+ async function trustPickerLocally(
153
+ deps: ApplyProfileDeps,
154
+ expectedCaSha256?: string | null,
155
+ ): Promise<{ ok: true; callerAddedTrust: boolean; caPath: string; sha1: string } | { ok: false; reason: string }> {
149
156
  try {
150
157
  const configDir = getConfigDir();
151
158
  const paths = pickerTrustPaths(deps, configDir);
159
+ if (!deps.ensurePickerCaImpl) {
160
+ // The bytes in ca.pem are only safe to trust when they match the authority the running
161
+ // server actually owns — a replaced file must never land in the login keychain.
162
+ const live = pickerCaFingerprints(paths.certPem).sha256;
163
+ if (expectedCaSha256 == null || expectedCaSha256 !== live) return { ok: false, reason: "ca_unverified" };
164
+ }
152
165
  const inspect = deps.inspectPickerTrustImpl ?? inspectPickerTrust;
153
166
  const before = await inspect(paths.leafPath, paths.sha1, deps.security, deps.platform);
154
167
  if (before === "trusted") return { ok: true, callerAddedTrust: false, caPath: paths.caPath, sha1: paths.sha1 };
155
- const trust = await (deps.trustPickerCaImpl ?? trustPickerCa)(paths.caPath, deps.security, deps.platform);
168
+ const trust = await (deps.trustPickerCaImpl ?? trustPickerCa)(paths.caPath, deps.security, deps.platform, { pem: paths.certPem });
156
169
  if (!trust.ok) return { ok: false, reason: trust.reason ?? "trust_declined" };
157
170
  return { ok: true, callerAddedTrust: true, caPath: paths.caPath, sha1: paths.sha1 };
158
171
  } catch (error) {
@@ -597,17 +610,28 @@ async function handleClaudeDesktopPickerCommand(
597
610
  });
598
611
 
599
612
  if (action === "trust") {
600
- const trusted = await trustPickerLocally(deps);
613
+ if (!(await liveDesktopProxy(deps))) {
614
+ console.error("proxy_unavailable");
615
+ return 1;
616
+ }
617
+ // Verify the published CA file against the authority the live server reports — the status
618
+ // response carries caSha256, and a first enable materializes one when none exists yet.
619
+ let caSha256: string | null;
620
+ try {
621
+ caSha256 = (await pickerRuntimeRequest<{ ok?: boolean; picker?: DesktopPickerStatus }>(
622
+ "/api/claude-desktop/picker", {}, deps,
623
+ )).picker?.caSha256 ?? null;
624
+ if (caSha256 === null) caSha256 = (await sendEnable(false)).picker?.caSha256 ?? null;
625
+ } catch (error) {
626
+ console.error(error instanceof Error ? error.message : String(error));
627
+ return 1;
628
+ }
629
+ const trusted = await trustPickerLocally(deps, caSha256);
601
630
  if (!trusted.ok) {
602
631
  console.error(trusted.reason);
603
632
  return 1;
604
633
  }
605
634
  localTrust = trusted;
606
- if (!(await liveDesktopProxy(deps))) {
607
- await compensateLocalPickerTrust(localTrust, deps);
608
- console.error("proxy_unavailable");
609
- return 1;
610
- }
611
635
  }
612
636
 
613
637
  let response: PickerRouteResponse;
@@ -619,7 +643,7 @@ async function handleClaudeDesktopPickerCommand(
619
643
  return 1;
620
644
  }
621
645
  if (action === "on" && response.picker?.reason === "trust_pending") {
622
- const trusted = await trustPickerLocally(deps);
646
+ const trusted = await trustPickerLocally(deps, response.picker.caSha256);
623
647
  if (!trusted.ok) {
624
648
  console.error(trusted.reason);
625
649
  return 1;