@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -42,6 +42,72 @@ async function status(argv: string[], deps: RuntimeApiDeps): Promise<void> {
42
42
  printData(result, wantsJson, summaryLines(result));
43
43
  }
44
44
 
45
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
46
+ return value !== null && typeof value === "object" ? value as Record<string, unknown> : undefined;
47
+ }
48
+
49
+ function desktopSwitchInertReason(reason: unknown): string {
50
+ if (reason === "client_role") return "this proxy is running in the client role";
51
+ if (reason === "non_loopback_bind_requires_admission_token") {
52
+ return "a non-loopback bind requires an admission token, so this flag is inert";
53
+ }
54
+ return "the stored setting is not effective in the current runtime configuration";
55
+ }
56
+
57
+ function desktopSwitchApplyReason(reason: unknown): string {
58
+ if (reason === "not_requested") return "no desktop switch rewrite was requested";
59
+ if (reason === "proxy_not_running") return "the proxy is not running";
60
+ if (reason === "integration_disabled") return "Codex integration is disabled";
61
+ if (reason === "write_lock_busy") return "the Codex config write lock is busy";
62
+ if (reason === "injection_refused") return "Codex config injection was refused";
63
+ return "the rewrite could not be completed";
64
+ }
65
+
66
+ function settingsUpdateLines(
67
+ result: unknown,
68
+ changed: { desktopAuthless: boolean; clientCompaction: boolean },
69
+ ): string[] {
70
+ if (!changed.desktopAuthless && !changed.clientCompaction) return ["System settings updated."];
71
+ const switches = recordValue(recordValue(result)?.codexDesktopSwitches);
72
+ if (!switches) return ["System settings updated."];
73
+
74
+ const lines: string[] = [];
75
+ const appendSwitch = (key: string, label: string): boolean => {
76
+ const state = recordValue(switches[key]);
77
+ if (!state || typeof state.stored !== "boolean" || typeof state.effective !== "boolean") return false;
78
+ lines.push(`${label}: stored ${state.stored ? "on" : "off"}.`);
79
+ // The effective value is always stated, even when it matches. Printing it only on a
80
+ // mismatch would make silence ambiguous — the reader could not tell "the stored value is
81
+ // in force" from "this build does not report effective state", and that ambiguity is a
82
+ // smaller version of the defect being fixed.
83
+ lines.push(state.effective === state.stored
84
+ ? `${label}: effective ${state.effective ? "on" : "off"}.`
85
+ : `${label}: effective ${state.effective ? "on" : "off"} because ${desktopSwitchInertReason(state.inertReason)}.`);
86
+ return true;
87
+ };
88
+
89
+ if (changed.desktopAuthless && !appendSwitch("codexDesktopAuthless", "Codex desktop authless")) {
90
+ return ["System settings updated."];
91
+ }
92
+ if (changed.clientCompaction && !appendSwitch("codexClientCompaction", "Codex client compaction")) {
93
+ return ["System settings updated."];
94
+ }
95
+
96
+ const apply = recordValue(switches.apply);
97
+ const authSource = recordValue(switches.authSource);
98
+ if (!apply || typeof apply.applied !== "boolean" || !authSource || typeof authSource.summary !== "string") {
99
+ return ["System settings updated."];
100
+ }
101
+ if (apply.applied) {
102
+ lines.push("Codex config: ~/.codex/config.toml was rewritten.");
103
+ } else {
104
+ const detail = typeof apply.detail === "string" && apply.detail.length > 0 ? ` Details: ${apply.detail}` : "";
105
+ lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail} Run 'ocx sync' to apply the stored settings.`);
106
+ }
107
+ lines.push(`Auth source: ${authSource.summary}`);
108
+ return lines;
109
+ }
110
+
45
111
  async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
46
112
  const args = [...argv];
47
113
  const wantsJson = takeFlag(args, "--json");
@@ -63,7 +129,10 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
63
129
  ...(clientCompaction !== undefined ? { codexClientCompaction: clientCompaction } : {}),
64
130
  };
65
131
  const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps);
66
- printData(result, wantsJson, ["System settings updated."]);
132
+ printData(result, wantsJson, settingsUpdateLines(result, {
133
+ desktopAuthless: desktopAuthless !== undefined,
134
+ clientCompaction: clientCompaction !== undefined,
135
+ }));
67
136
  }
68
137
 
69
138
  async function startup(argv: string[], deps: RuntimeApiDeps): Promise<void> {
@@ -130,14 +199,14 @@ export async function handleSystemCommand(argv: string[], deps: RuntimeApiDeps =
130
199
  const args = [...rest]; const wantsJson = takeFlag(args, "--json"); rejectArgs(args, USAGE);
131
200
  printData(await runtimeRequest("/api/system/codex-app-server", {}, deps), wantsJson);
132
201
  } else if (sub === "codex-restart") {
133
- // --yes required: this restarts the user's running Codex app-server, so it is exactly the
134
- // class of action that must not happen because an agent guessed a subcommand.
202
+ // --yes required: this fully quits and relaunches the user's Codex desktop app as well as
203
+ // restarting app-servers; an agent guessing a subcommand must not interrupt that session.
135
204
  const args = [...rest];
136
205
  const wantsJson = takeFlag(args, "--json");
137
206
  const yes = takeFlag(args, "--yes");
138
- if (!yes) throw new CliUsageError("system codex-restart requires --yes", USAGE);
207
+ if (!yes) throw new CliUsageError("system codex-restart requires --yes: this fully quits and relaunches the Codex desktop app and restarts its app-servers", USAGE);
139
208
  rejectArgs(args, USAGE);
140
- printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex app-server restart requested."]);
209
+ printData(await runtimeRequest("/api/system/codex-restart", { method: "POST" }, deps), wantsJson, ["Codex desktop app and app-server restart requested."]);
141
210
  } else if (sub === "update") await update(rest, deps);
142
211
  else throw new CliUsageError(`unknown system command ${sub}`, USAGE);
143
212
  });
@@ -4,6 +4,7 @@ import { readClientConnectionState, sameClientConnectionOwner } from "../client/
4
4
  import { assertClientLifecycleHeld, withClientLifecycle } from "../client/lifecycle-lock";
5
5
  import { inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store";
6
6
  import { removeOwnedConfigState, type ConfigRemovalResult } from "../lib/config-ownership";
7
+ import { windowsSecretAclReapPendingAtOrBelow } from "../lib/windows-secret-acl";
7
8
  import { sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
8
9
 
9
10
  export interface UninstallClientStateDeps {
@@ -13,6 +14,8 @@ export interface UninstallClientStateDeps {
13
14
  disconnect: (options?: Parameters<typeof disconnectClient>[0]) => Promise<unknown>;
14
15
  withLifecycle: typeof withClientLifecycle;
15
16
  remove: () => ConfigRemovalResult;
17
+ /** True while a timed-out icacls child still owns a path at or below the config directory. */
18
+ aclReapPending: (rootPath: string) => boolean;
16
19
  }
17
20
 
18
21
  const defaults: UninstallClientStateDeps = {
@@ -22,6 +25,7 @@ const defaults: UninstallClientStateDeps = {
22
25
  disconnect: options => disconnectClient(options),
23
26
  withLifecycle: withClientLifecycle,
24
27
  remove: () => removeOwnedConfigState(getConfigDir()),
28
+ aclReapPending: rootPath => windowsSecretAclReapPendingAtOrBelow(rootPath),
25
29
  };
26
30
 
27
31
  /** Restore connection-owned client artifacts before removing their ownership/recovery records. */
@@ -73,6 +77,14 @@ export async function removeOwnedConfigAfterDesktopCleanup(
73
77
  || (latestReceipt.kind === "valid" && latestReceipt.value.phase !== "complete")) {
74
78
  throw new Error("Client cleanup refused: connection or Desktop state changed before removal.");
75
79
  }
80
+ // The async ACL belt releases its caller on a stalled `icacls.exe`, which is what keeps
81
+ // startup and shutdown bounded. It is not evidence that the child released the directory,
82
+ // and on Windows a live handle makes this removal fail partway instead of cleanly. Refuse
83
+ // promptly and let the operator retry: waiting here would hand a stuck child the power to
84
+ // hang `ocx uninstall`, which is the bound the belt exists to preserve.
85
+ if (deps.aclReapPending(getConfigDir())) {
86
+ throw new Error("Client cleanup refused: ACL hardening still owns a path under the config directory.");
87
+ }
76
88
  return deps.remove();
77
89
  });
78
90
  }
@@ -812,6 +812,7 @@ export interface OpenclawModelEntry {
812
812
  id: string;
813
813
  name: string;
814
814
  contextWindow?: number;
815
+ input?: string[];
815
816
  }
816
817
 
817
818
  export interface OpenclawProviderBlock {
@@ -839,15 +840,15 @@ export interface KimiProviderBlock {
839
840
  /**
840
841
  * `max_context_size` is mandatory and must be positive, so a model with no
841
842
  * authoritative context window is omitted from the document entirely rather
842
- * than guessed at. `capabilities` is never emitted: our catalog does not
843
- * assert them, and Kimi's own inference works off OpenAI-style name prefixes
844
- * that a routed selector will not match.
843
+ * than guessed at. Catalog image input becomes `image_in`; other capabilities
844
+ * are not inferred from routed model names.
845
845
  */
846
846
  export interface KimiModelBlock {
847
847
  provider: string;
848
848
  model: string;
849
849
  max_context_size: number;
850
850
  display_name?: string;
851
+ capabilities?: ["image_in"];
851
852
  }
852
853
 
853
854
  export interface KimiGeneratedConfig {
@@ -974,10 +975,12 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
974
975
  function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig {
975
976
  const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => {
976
977
  const context = authoritativeContextWindow(model.contextWindow);
978
+ const input = [...new Set(model.inputModalities?.filter(value => ["text", "image", "video", "audio"].includes(value)))];
977
979
  return {
978
980
  id: model.namespaced,
979
981
  name: exportModelLabel(model),
980
982
  ...(context !== undefined ? { contextWindow: context } : {}),
983
+ ...(input.length > 0 ? { input } : {}),
981
984
  };
982
985
  });
983
986
  const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF);
@@ -1015,6 +1018,7 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig {
1015
1018
  model: model.namespaced,
1016
1019
  max_context_size: context,
1017
1020
  ...(model.displayName ? { display_name: model.displayName } : {}),
1021
+ ...(model.inputModalities?.includes("image") ? { capabilities: ["image_in"] as ["image_in"] } : {}),
1018
1022
  };
1019
1023
  }
1020
1024
  return {
@@ -1,17 +1,19 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import type { CodexAccount, OcxConfig } from "../types";
3
3
  import type { CodexAuthContext } from "./auth-context";
4
+ import type { ProviderApiKeySelection } from "../types/provider";
4
5
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
5
6
 
6
7
  export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
7
8
 
8
9
  /**
9
- * Account log labels come in two families (#2699):
10
+ * Account log labels come in three families:
10
11
  *
11
12
  * - `p<hex6>` (plus the literal `main`) — a Codex pool account.
12
13
  * - `o<hex6>` — a non-Codex OAuth provider account (xai, cursor, and siblings).
14
+ * - `k<hex32>` — a request-owned API-key selection, scoped to provider and reference.
13
15
  *
14
- * Both are sha256-derived digests, never an email and never a raw provider account id. That is
16
+ * Labels never contain an email, raw key/reference, or raw provider account id. That is
15
17
  * a privacy requirement, not a formatting preference: these labels are written to the usage log
16
18
  * and served over the management API.
17
19
  *
@@ -20,7 +22,16 @@ export const CODEX_ACCOUNT_LOG_LABEL_RE = /^p[a-f0-9]{6}$/;
20
22
  * accepted cost of keeping the existing `p` format byte-compatible.
21
23
  */
22
24
  export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^o[a-f0-9]{6}$/;
23
- export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6})$/;
25
+ export const KEY_ACCOUNT_LOG_LABEL_RE = /^k[a-f0-9]{32}$/;
26
+ export const ACCOUNT_LOG_LABEL_RE = /^(?:main|[po][a-f0-9]{6}|k[a-f0-9]{32})$/;
27
+
28
+ /** Digest the request-owned configured selection, never serialize its key/reference. */
29
+ export function apiKeyAccountLogLabel(provider: string, selection: ProviderApiKeySelection | undefined): `k${string}` | undefined {
30
+ if (!selection || typeof selection.reference !== "string" || !selection.reference.length) return undefined;
31
+ return `k${createHash("sha256").update(JSON.stringify([
32
+ "ocx-key-account-v1", provider, selection.entryId ?? null, selection.reference,
33
+ ])).digest("hex").slice(0, 32)}`;
34
+ }
24
35
 
25
36
  export function oauthAccountLogLabel(accountId: string, provider = ""): string {
26
37
  return `o${createHash("sha256").update(`${provider}\0${accountId}`).digest("hex").slice(0, 6)}`;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import {
5
5
  ConfigMutationLockError,
@@ -268,6 +268,23 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord
268
268
  return loadCodexAccountRecordStore()[id] ?? null;
269
269
  }
270
270
 
271
+ /**
272
+ * One store load, every record, for a caller that resolves MANY ids in a single synchronous pass.
273
+ *
274
+ * `readCodexAccountRecord` reloads, reparses and renormalizes the whole file per id. That is the
275
+ * right shape for one lookup and the wrong shape for a loop: the entitlement denial reader holds
276
+ * up to 64 accounts with four client versions each, so scoring one warm flagship request could
277
+ * perform up to 256 full-store reads on the request path.
278
+ *
279
+ * These are the same normalized records `readCodexAccountRecord` hands out, tombstones included,
280
+ * so the caller keeps its own `deletedAt` and `generation` checks instead of trusting a filtered
281
+ * view. That is the difference from `loadCodexAccountStore`, which drops both and cannot answer a
282
+ * question about credential generation.
283
+ */
284
+ export function loadCodexAccountRecordSnapshot(): Readonly<Record<string, CodexAccountCredentialRecord>> {
285
+ return loadCodexAccountRecordStore();
286
+ }
287
+
271
288
  const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
272
289
 
273
290
  function validQuotaHistoryIdentity(value: unknown): value is string {
@@ -655,10 +672,43 @@ function isRefreshLockStale(path: string): boolean {
655
672
  const parsed = JSON.parse(readFileSync(path, "utf-8")) as { acquiredAt?: unknown };
656
673
  return typeof parsed.acquiredAt !== "number" || Date.now() - parsed.acquiredAt > REFRESH_LOCK_STALE_MS;
657
674
  } catch {
658
- return true;
675
+ // The owner creates the file and writes its metadata in two steps, so a live lock is
676
+ // briefly unreadable. Age the file itself instead of calling that window stale, which
677
+ // let a waiter delete a lock whose owner was still inside its critical section.
678
+ try {
679
+ return Date.now() - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS;
680
+ } catch {
681
+ return false;
682
+ }
659
683
  }
660
684
  }
661
685
 
686
+ function releaseCodexRefreshFileLock(path: string, fd: number): void {
687
+ let owned: { dev: bigint; ino: bigint } | null = null;
688
+ try {
689
+ const info = fstatSync(fd, { bigint: true });
690
+ if (info.dev >= 0n && info.ino > 0n) owned = { dev: info.dev, ino: info.ino };
691
+ } catch { /* Unknown descriptor identity never authorizes unlink. */ }
692
+ try {
693
+ withConfigMutationLockSync(() => {
694
+ let current: { dev: bigint; ino: bigint } | null = null;
695
+ try {
696
+ const info = statSync(path, { bigint: true });
697
+ if (info.dev >= 0n && info.ino > 0n) current = { dev: info.dev, ino: info.ino };
698
+ } catch { /* Keep the lock and the callback outcome when the path probe fails. */ }
699
+ if (owned && current && current.dev === owned.dev && current.ino === owned.ino) {
700
+ try { unlinkSync(path); } catch (err) {
701
+ if (errCode(err) !== "ENOENT") throw err;
702
+ }
703
+ }
704
+ });
705
+ } catch (err) {
706
+ // Keep the descriptor alive through comparison/unlink so its inode cannot be recycled.
707
+ // Unavailable coordination leaves the path without masking the completed refresh.
708
+ if (!(err instanceof ConfigMutationLockError)) throw err;
709
+ } finally { closeSync(fd); }
710
+ }
711
+
662
712
  export async function withCodexRefreshFileLock<T>(lockKey: string, signal: AbortSignal, fn: () => Promise<T>): Promise<T> {
663
713
  hardenConfigDir();
664
714
  const dir = getConfigDir();
@@ -670,33 +720,45 @@ export async function withCodexRefreshFileLock<T>(lockKey: string, signal: Abort
670
720
  while (fd == null) {
671
721
  if (signal.aborted) throw signal.reason;
672
722
  try {
673
- fd = openSync(path, "wx", 0o600);
674
- writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n");
675
- break;
676
- } catch (err) {
677
- if (errCode(err) !== "EEXIST") throw err;
678
- if (isRefreshLockStale(path)) {
723
+ // Serialize only metadata operations, never the async refresh callback. Cooperating
724
+ // contenders cannot reclaim a successor between stale observation and path mutation.
725
+ withConfigMutationLockSync(() => {
679
726
  try {
680
- unlinkSync(path);
681
- } catch (unlinkErr) {
682
- if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr;
727
+ fd = openSync(path, "wx", 0o600);
728
+ writeFileSync(fd, JSON.stringify({ acquiredAt: Date.now(), pid: process.pid }) + "\n");
729
+ } catch (err) {
730
+ if (fd != null) {
731
+ const failedFd = fd;
732
+ fd = null;
733
+ try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve write failure. */ }
734
+ throw err;
735
+ }
736
+ if (errCode(err) !== "EEXIST") throw err;
737
+ if (isRefreshLockStale(path)) {
738
+ try { unlinkSync(path); } catch (unlinkErr) {
739
+ if (errCode(unlinkErr) !== "ENOENT") throw unlinkErr;
740
+ }
741
+ }
683
742
  }
684
- continue;
743
+ });
744
+ } catch (err) {
745
+ // A failed SQLite commit can follow successful file creation; it still owns an fd.
746
+ if (fd != null) {
747
+ const failedFd = fd;
748
+ fd = null;
749
+ try { releaseCodexRefreshFileLock(path, failedFd); } catch { /* Preserve admission failure. */ }
685
750
  }
686
- if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError();
687
- await sleep(REFRESH_LOCK_POLL_MS, signal);
751
+ if (!(err instanceof ConfigMutationLockError)) throw err;
688
752
  }
753
+ if (fd != null) break;
754
+ if (Date.now() >= deadline) throw new CodexCredentialRefreshLockTimeoutError();
755
+ await sleep(REFRESH_LOCK_POLL_MS, signal);
689
756
  }
690
757
 
691
758
  try {
692
759
  return await fn();
693
760
  } finally {
694
- if (fd != null) closeSync(fd);
695
- try {
696
- unlinkSync(path);
697
- } catch (err) {
698
- if (errCode(err) !== "ENOENT") throw err;
699
- }
761
+ releaseCodexRefreshFileLock(path, fd);
700
762
  }
701
763
  }
702
764
 
@@ -1137,9 +1199,20 @@ async function resolveCodexToken(
1137
1199
  let errDesc: string;
1138
1200
  let errCodeExact: string | undefined;
1139
1201
  try {
1140
- const parsed = JSON.parse(errText) as { error?: string; error_description?: string };
1141
- errCodeExact = typeof parsed.error === "string" ? parsed.error.trim() : undefined;
1142
- errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${res.status}`;
1202
+ const parsed = JSON.parse(errText) as {
1203
+ error?: string | { code?: string; message?: string };
1204
+ error_description?: string;
1205
+ };
1206
+ if (typeof parsed.error === "string") {
1207
+ errCodeExact = parsed.error.trim();
1208
+ errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ");
1209
+ } else if (parsed.error && typeof parsed.error === "object") {
1210
+ errCodeExact = typeof parsed.error.code === "string" ? parsed.error.code.trim() : undefined;
1211
+ errDesc = [parsed.error.code, parsed.error.message, parsed.error_description].filter(Boolean).join(": ");
1212
+ } else {
1213
+ errDesc = parsed.error_description || `HTTP ${res.status}`;
1214
+ }
1215
+ if (!errDesc) errDesc = `HTTP ${res.status}`;
1143
1216
  } catch { errDesc = `HTTP ${res.status}`; }
1144
1217
  // `invalid_grant` is the standard OAuth code for a refresh token that is no longer
1145
1218
  // usable, and upstream sends it bare with no description. Without it here the dead
@@ -1149,9 +1222,23 @@ async function resolveCodexToken(
1149
1222
  // Matched on the exact `error` CODE, not anywhere in the combined text: a transient
1150
1223
  // `server_error` whose description happens to mention invalid_grant would otherwise
1151
1224
  // retire a healthy account, which is the failure this whole change exists to remove.
1152
- const reason = errCodeExact === "invalid_grant"
1153
- || errDesc.includes("invalidated") || errDesc.includes("revoked") ? "revoked" as const
1154
- : errDesc.includes("expired") ? "expired" as const
1225
+ //
1226
+ // That rule binds the DESCRIPTION words too. "invalidated", "revoked" and "expired" read
1227
+ // as terminal prose, but upstream puts arbitrary text there: a `server_error` whose
1228
+ // description says "token was revoked" or "session expired" is still a 5xx blip, and
1229
+ // retiring the account on it is exactly the false quarantine #2887 exists to prevent.
1230
+ // So a body that carries a structured code is classified by that code ALONE. The
1231
+ // substring fallback survives only where there is no structured code to read at all --
1232
+ // a description-only body, or one this parser could not decode -- because there the
1233
+ // prose is the only signal upstream gave us.
1234
+ const structuredCode = errCodeExact ? errCodeExact : undefined;
1235
+ const proseIsOnlySignal = structuredCode === undefined;
1236
+ const reason = structuredCode === "invalid_grant"
1237
+ || structuredCode === "refresh_token_invalidated"
1238
+ || (proseIsOnlySignal
1239
+ && (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const
1240
+ : structuredCode === "refresh_token_expired"
1241
+ || (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const
1155
1242
  : "unknown" as const;
1156
1243
  throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
1157
1244
  }
@@ -18,6 +18,27 @@ export interface CodexAccountUsabilityOptions {
18
18
  isMainAccountTokenLive?: typeof isMainAccountTokenLive;
19
19
  /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
20
20
  modelEligibleAccountIds?: ReadonlySet<string>;
21
+ /**
22
+ * Accounts whose own confirmed roster definitively omits the requested model (#4768).
23
+ *
24
+ * Deliberately NOT read by this module. `modelEligibleAccountIds` is an eligibility boundary and
25
+ * produces `model_not_entitled`; this is an ORDERING preference applied once, in
26
+ * `getEligiblePoolAccounts`, and dropped whenever honouring it would leave no candidate. Reading
27
+ * it here would turn a preference into a refusal and re-create the fail-closed behaviour the
28
+ * flagships were deliberately taken out of.
29
+ */
30
+ deniedModelAccountIds?: ReadonlySet<string>;
31
+ /**
32
+ * This request's conversation carries live uploaded-file references (#4778).
33
+ *
34
+ * Also not read by this module, and for the same reason: it is a retention preference, never an
35
+ * eligibility boundary. Uploaded files are scoped to the account that issued them, so moving
36
+ * such a conversation orphans the reference and every later turn is refused with
37
+ * `409 account_change_file_scope` -- the reference stays in history, so the conversation is
38
+ * effectively dead. Retention makes that refusal rarer; it can never replace it, because an
39
+ * account can always become unable to serve.
40
+ */
41
+ retainAccountForUploadedFiles?: boolean;
21
42
  }
22
43
 
23
44
  /**
@@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
10
10
  import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
11
11
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
12
12
  import { emailMaskingEnabled, projectEmail } from "../../lib/privacy";
13
- import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup";
13
+ import { CodexWarmupError, codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup";
14
14
  import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../../types";
15
15
  import type { CatalogDisposition } from "../convergence-types";
16
16
  import { isValidCodexAccountId } from "../account-id";
@@ -51,6 +51,17 @@ export async function verifyCodexAccountWarmup(
51
51
  return { ok: true, validatedAt: Date.now() };
52
52
  } catch (err) {
53
53
  const reason = codexWarmupFailureReason(err);
54
+ if (err instanceof CodexWarmupError && err.code === "http_status" && err.status === 429) {
55
+ return {
56
+ ok: false,
57
+ response: jsonResponse({
58
+ error: "Codex account warmup was rate limited. Retry later or after the account's usage limit resets.",
59
+ code: "codex_warmup_rate_limited",
60
+ reason,
61
+ accountId,
62
+ }, 429),
63
+ };
64
+ }
54
65
  return {
55
66
  ok: false,
56
67
  response: jsonResponse({
@@ -322,10 +333,11 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
322
333
  ? { ok: true as const, validatedAt: undefined }
323
334
  : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
324
335
  if (!warmup.ok) {
325
- const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
336
+ const body = await warmup.response.json().catch(() => ({})) as { error?: string; code?: string; reason?: string };
326
337
  setCodexLoginState(flowId, {
327
338
  status: "error",
328
339
  error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
340
+ code: body.code,
329
341
  doneAt: Date.now(),
330
342
  });
331
343
  completed = true;
@@ -153,7 +153,9 @@ export function createResetCreditWhamClient(config: OcxConfig, accountId: string
153
153
  signal: AbortSignal.timeout(10_000),
154
154
  });
155
155
  if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
156
- return safeResetCreditConsumeDto(await resp.json());
156
+ const parsed = await readResetCreditJson(resp, AbortSignal.timeout(10_000));
157
+ if (!parsed.ok) throw new Error("invalid upstream reset-credit consume response");
158
+ return safeResetCreditConsumeDto(parsed.value);
157
159
  }),
158
160
  };
159
161
  }
@@ -383,7 +385,14 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string,
383
385
  if (identity) markManualResetCreditOperationAmbiguous(identity);
384
386
  return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
385
387
  }
386
- const result = safeResetCreditConsumeDto(await resp.json());
388
+ const consumed = await readResetCreditJson(resp, AbortSignal.timeout(10_000));
389
+ if (!consumed.ok) {
390
+ // The spend may already have landed upstream and its outcome code is unreadable,
391
+ // so this id must never come back as a new operation.
392
+ if (identity) markManualResetCreditOperationAmbiguous(identity);
393
+ return jsonResponse({ error: "Invalid upstream reset-credit consume response" }, 502);
394
+ }
395
+ const result = safeResetCreditConsumeDto(consumed.value);
387
396
  if (identity) {
388
397
  // Narrow explicitly rather than casting: `safeResetCreditConsumeDto`
389
398
  // normalizes anything unrecognized to "unknown", and settling that