@yansigit/opencodex 2.31.3 → 2.32.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 (107) hide show
  1. package/bin/ocx.mjs +99 -70
  2. package/gui/dist/assets/index-BG43zwVe.js +102 -0
  3. package/gui/dist/assets/index-CiSI-jrP.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +6 -8
  7. package/src/adapters/base.ts +2 -0
  8. package/src/adapters/command-code.ts +2 -3
  9. package/src/adapters/cursor/live-models.ts +8 -0
  10. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  11. package/src/adapters/cursor/tool-definitions.ts +1 -1
  12. package/src/adapters/google.ts +6 -7
  13. package/src/adapters/kiro.ts +0 -3
  14. package/src/adapters/openai-responses.ts +3 -0
  15. package/src/adapters/tool-catalog-nudge.ts +1 -1
  16. package/src/adapters/xai-web-search.ts +7 -2
  17. package/src/bridge.ts +21 -15
  18. package/src/cli/dispatch.ts +50 -2
  19. package/src/cli/doctor.ts +24 -11
  20. package/src/cli/help.ts +4 -3
  21. package/src/cli/index.ts +11 -4
  22. package/src/cli/models.ts +13 -3
  23. package/src/cli/observe.ts +20 -5
  24. package/src/cli/provider.ts +2 -1
  25. package/src/cli/registry.ts +7 -5
  26. package/src/cli/status.ts +2 -1
  27. package/src/cli/system-restart-client.ts +1 -1
  28. package/src/cli/usage-report.ts +134 -0
  29. package/src/codex/app-server-processes.ts +3 -1
  30. package/src/codex/catalog/aggregation.ts +13 -1
  31. package/src/codex/catalog/effort.ts +24 -9
  32. package/src/codex/catalog/model-metadata.ts +566 -0
  33. package/src/codex/catalog/parsing.ts +33 -0
  34. package/src/codex/catalog/provider-fetch.ts +123 -32
  35. package/src/codex/catalog/sync.ts +5 -4
  36. package/src/codex/desktop-app-restart.ts +342 -0
  37. package/src/codex/history-job.ts +32 -3
  38. package/src/codex/history-manifest.ts +112 -0
  39. package/src/codex/history-migration-guardian.ts +5 -5
  40. package/src/codex/history-provider.ts +825 -247
  41. package/src/codex/history-worker.ts +8 -5
  42. package/src/codex/inject.ts +49 -21
  43. package/src/codex/injected-marker.ts +1 -1
  44. package/src/codex/internal/history-writer.ts +4 -3
  45. package/src/codex/native-profile-startup.ts +157 -27
  46. package/src/codex/native-residue.ts +26 -33
  47. package/src/combos/failover.ts +27 -0
  48. package/src/compatibility/index.ts +26 -0
  49. package/src/compatibility/manifest.ts +253 -0
  50. package/src/compatibility/openai-responses.ts +81 -0
  51. package/src/config/atomic-write.ts +219 -0
  52. package/src/config/paths.ts +40 -0
  53. package/src/config/process-state.ts +308 -0
  54. package/src/config/provider-validation.ts +177 -0
  55. package/src/config.ts +75 -812
  56. package/src/generated/compatibility-version.json +145 -85
  57. package/src/images/plan.ts +5 -4
  58. package/src/integrations/ownership-policy.ts +141 -0
  59. package/src/integrations/ownership.ts +10 -0
  60. package/src/integrations/state.ts +44 -5
  61. package/src/integrations/writer.ts +6 -0
  62. package/src/lib/bounded-body.ts +14 -2
  63. package/src/lib/process-control.ts +2 -1
  64. package/src/lib/state-store-registrations.ts +2 -0
  65. package/src/lib/tool-argument-integers.ts +56 -5
  66. package/src/oauth/health.ts +1 -1
  67. package/src/providers/registry.ts +1 -1
  68. package/src/reasoning-effort.ts +19 -2
  69. package/src/responses/apply-patch-envelope.ts +63 -0
  70. package/src/responses/custom-tool-compat.ts +132 -38
  71. package/src/responses/parser.ts +3 -2
  72. package/src/responses/reasoning-replay-cache.ts +81 -3
  73. package/src/server/auth-cors.ts +9 -7
  74. package/src/server/index.ts +102 -21
  75. package/src/server/local-management-read-client.ts +1 -1
  76. package/src/server/local-provider-reload-client.ts +1 -1
  77. package/src/server/management/agent-settings-routes.ts +1 -1
  78. package/src/server/management/config-routes.ts +4 -1
  79. package/src/server/management/context.ts +1 -1
  80. package/src/server/management/logs-usage-routes.ts +27 -6
  81. package/src/server/management/model-routes.ts +8 -4
  82. package/src/server/management/native-integration-routes.ts +2 -1
  83. package/src/server/management/provider-capability-config.ts +1 -1
  84. package/src/server/management/system-restart.ts +1 -1
  85. package/src/server/port-reclaim.ts +1 -1
  86. package/src/server/proxy-liveness.ts +2 -1
  87. package/src/server/request-log-conversation.ts +30 -0
  88. package/src/server/responses/codex-auth-error.ts +55 -0
  89. package/src/server/responses/combo-stream-preflight.ts +171 -0
  90. package/src/server/responses/compact.ts +6 -21
  91. package/src/server/responses/core.ts +225 -94
  92. package/src/server/responses/fetch-helpers.ts +2 -97
  93. package/src/server/responses-custom-tool-repair.ts +41 -5
  94. package/src/server/responses-undeclared-tool-guard.ts +156 -15
  95. package/src/service.ts +8 -4
  96. package/src/types/request.ts +6 -1
  97. package/src/types/tools.ts +87 -11
  98. package/src/types.ts +1 -1
  99. package/src/update/index.ts +5 -4
  100. package/src/update/job.ts +3 -1
  101. package/src/update/transactional-install.mjs +8 -1
  102. package/src/usage/log.ts +16 -8
  103. package/src/usage/summary.ts +201 -8
  104. package/src/vision/describe.ts +18 -13
  105. package/src/web-search/executor.ts +10 -3
  106. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
  107. package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
@@ -1,4 +1,9 @@
1
1
  import { namespacedToolName } from "../types";
2
+ import {
3
+ normalizeApplyPatchDelimiters,
4
+ repairFreeformToolInput,
5
+ unwrapFreeformToolInput,
6
+ } from "./apply-patch-envelope";
2
7
  import { collectResponsesToolGroups } from "./tool-groups";
3
8
 
4
9
  const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]);
@@ -15,19 +20,42 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
20
  return !!value && typeof value === "object" && !Array.isArray(value);
16
21
  }
17
22
 
18
- function customToolInput(argumentsText: unknown): string {
19
- if (typeof argumentsText !== "string") return "";
20
- try {
21
- const parsed = JSON.parse(argumentsText) as unknown;
22
- if (isPlainObject(parsed) && typeof parsed.input === "string") return parsed.input;
23
- } catch { /* malformed arguments stay visible to the client */ }
24
- return argumentsText;
25
- }
26
-
27
23
  function customToolWireName(namespace: string | undefined, name: string): string {
28
24
  return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name);
29
25
  }
30
26
 
27
+ function toolChoiceAllowsRoutedCustomTool(
28
+ body: unknown,
29
+ wireName: string,
30
+ candidateNames: ReadonlySet<string>,
31
+ ): boolean {
32
+ if (!isPlainObject(body)) return true;
33
+ const choice = body.tool_choice;
34
+ if (choice === undefined || choice === null || choice === "auto" || choice === "required") {
35
+ return true;
36
+ }
37
+ if (choice === "none") return false;
38
+ if (!isPlainObject(choice)) return true;
39
+
40
+ const selectorAllows = (selector: unknown): boolean => {
41
+ if (!isPlainObject(selector) || typeof selector.name !== "string") return false;
42
+ if (selector.type !== "custom") return false;
43
+ if (typeof selector.namespace === "string") {
44
+ return customToolWireName(selector.namespace, selector.name) === wireName;
45
+ }
46
+ if (selector.name === wireName) return true;
47
+ const suffix = `__${selector.name}`;
48
+ const candidates = [...candidateNames].filter(name => name.endsWith(suffix));
49
+ return candidates.length === 1 && candidates[0] === wireName;
50
+ };
51
+
52
+ if (choice.type === "function" || choice.type === "custom") return selectorAllows(choice);
53
+ if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
54
+ return choice.tools.some(selectorAllows);
55
+ }
56
+ return false;
57
+ }
58
+
31
59
  /** Final upstream identity of a call, including a namespace restored by an earlier rewrite. */
32
60
  export function routedCustomToolWireName(value: unknown): string | undefined {
33
61
  if (!isPlainObject(value) || typeof value.name !== "string") return undefined;
@@ -38,12 +66,13 @@ export function routedCustomToolWireName(value: unknown): string | undefined {
38
66
  }
39
67
 
40
68
  /**
41
- * Names of converted custom declarations after namespace lowering. Restoration uses these exact
42
- * wire identities so same-named function and custom children in different namespaces stay distinct.
69
+ * Names of custom declarations after namespace lowering. The selection flag separates converted
70
+ * names from native passthrough names while keeping same-named function and custom children distinct.
43
71
  */
44
72
  function collectRoutedCustomToolWireNames(
45
73
  body: unknown,
46
74
  supportsResponsesCustomTools?: boolean,
75
+ passthrough = false,
47
76
  ): Set<string> {
48
77
  const names = new Set<string>();
49
78
  const groups = collectResponsesToolGroups(body);
@@ -64,7 +93,7 @@ function collectRoutedCustomToolWireNames(
64
93
  if (
65
94
  tool.type === "custom"
66
95
  && typeof tool.name === "string"
67
- && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools)
96
+ && routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) === passthrough
68
97
  ) {
69
98
  names.add(tool.name);
70
99
  continue;
@@ -77,7 +106,8 @@ function collectRoutedCustomToolWireNames(
77
106
  isPlainObject(child)
78
107
  && child.type === "custom"
79
108
  && typeof child.name === "string"
80
- && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools)
109
+ && routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) === passthrough
110
+ && (!passthrough || tool.name === BUILTIN_FUNCTIONS_NAMESPACE)
81
111
  && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name))
82
112
  ) names.add(customToolWireName(tool.name, child.name));
83
113
  }
@@ -85,7 +115,6 @@ function collectRoutedCustomToolWireNames(
85
115
  }
86
116
  return names;
87
117
  }
88
-
89
118
  export function customToolItemId(id: unknown): unknown {
90
119
  if (typeof id !== "string") return id;
91
120
  return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id;
@@ -203,64 +232,129 @@ export function rewriteRoutedCustomToolsForUpstream(
203
232
  ): {
204
233
  body: unknown;
205
234
  names: Set<string>;
235
+ repairNames: Set<string>;
206
236
  } {
207
237
  const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools);
208
238
  const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools);
209
- if (conversionNames.size === 0) return { body, names };
239
+ const repairNames = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools, true);
240
+ for (const name of repairNames) {
241
+ if (!toolChoiceAllowsRoutedCustomTool(body, name, repairNames)) repairNames.delete(name);
242
+ }
243
+ if (conversionNames.size === 0) return { body, names, repairNames };
210
244
  const callIds = new Set<string>();
211
245
  collectConvertedCallIds(body, conversionNames, callIds);
212
- return { body: rewriteForUpstream(body, conversionNames, callIds), names };
246
+ return { body: rewriteForUpstream(body, conversionNames, callIds), names, repairNames };
213
247
  }
214
248
 
215
249
  export function restoreRoutedCustomCalls(
216
250
  value: unknown,
217
251
  names: ReadonlySet<string>,
252
+ repairNames: ReadonlySet<string> = new Set(),
218
253
  ): { value: unknown; changed: boolean } {
219
- if (Array.isArray(value)) {
254
+ if (!isPlainObject(value)) return { value, changed: false };
255
+
256
+ const restoreItem = (item: unknown): { value: unknown; changed: boolean } => {
257
+ if (!isPlainObject(item)) return { value: item, changed: false };
258
+ const wireName = routedCustomToolWireName(item);
259
+ if (
260
+ item.type === "function_call"
261
+ && typeof item.name === "string"
262
+ && wireName !== undefined
263
+ && names.has(wireName)
264
+ ) {
265
+ const restored: Record<string, unknown> = {
266
+ ...item,
267
+ type: "custom_tool_call",
268
+ id: customToolItemId(item.id),
269
+ input: repairFreeformToolInput(
270
+ item.arguments,
271
+ item.name,
272
+ typeof item.namespace === "string" ? item.namespace : undefined,
273
+ ),
274
+ };
275
+ delete restored.arguments;
276
+ return { value: restored, changed: true };
277
+ }
278
+ if (
279
+ item.type === "custom_tool_call"
280
+ && typeof item.name === "string"
281
+ && wireName !== undefined
282
+ && repairNames.has(wireName)
283
+ && typeof item.input === "string"
284
+ ) {
285
+ const input = normalizeApplyPatchDelimiters(item.input);
286
+ if (input !== item.input) return { value: { ...item, input }, changed: true };
287
+ }
288
+ return { value: item, changed: false };
289
+ };
290
+
291
+ const restoreOutput = (output: unknown): { value: unknown; changed: boolean } => {
292
+ if (!Array.isArray(output)) return { value: output, changed: false };
220
293
  let changed = false;
221
- const restored = value.map(entry => {
222
- const result = restoreRoutedCustomCalls(entry, names);
294
+ const restored = output.map(item => {
295
+ const result = restoreItem(item);
223
296
  changed ||= result.changed;
224
297
  return result.value;
225
298
  });
226
- return changed ? { value: restored, changed: true } : { value, changed: false };
227
- }
228
- if (!isPlainObject(value)) return { value, changed: false };
299
+ return changed ? { value: restored, changed: true } : { value: output, changed: false };
300
+ };
229
301
 
230
302
  let changed = false;
231
- const restored: Record<string, unknown> = {};
232
- for (const [key, entry] of Object.entries(value)) {
233
- const result = restoreRoutedCustomCalls(entry, names);
234
- restored[key] = result.value;
235
- changed ||= result.changed;
303
+ const restored: Record<string, unknown> = { ...value };
304
+ const output = restoreOutput(value.output);
305
+ if (output.changed) {
306
+ restored.output = output.value;
307
+ changed = true;
236
308
  }
237
309
 
238
- const wireName = routedCustomToolWireName(value);
239
- if (value.type === "function_call" && wireName !== undefined && names.has(wireName)) {
240
- restored.type = "custom_tool_call";
241
- restored.id = customToolItemId(value.id);
242
- restored.input = customToolInput(value.arguments);
243
- delete restored.arguments;
244
- changed = true;
310
+ if (
311
+ (value.type === "response.output_item.added" || value.type === "response.output_item.done")
312
+ && isPlainObject(value.item)
313
+ ) {
314
+ const item = restoreItem(value.item);
315
+ if (item.changed) {
316
+ restored.item = item.value;
317
+ changed = true;
318
+ }
319
+ }
320
+
321
+ if (
322
+ typeof value.type === "string"
323
+ && value.type.startsWith("response.")
324
+ && isPlainObject(value.response)
325
+ ) {
326
+ const response = restoreRoutedCustomCalls(value.response, names, repairNames);
327
+ if (response.changed) {
328
+ restored.response = response.value;
329
+ changed = true;
330
+ }
245
331
  }
332
+
246
333
  return changed ? { value: restored, changed: true } : { value, changed: false };
247
334
  }
248
335
 
249
336
  export function restoreRoutedCustomCallsInJson(
250
337
  text: string,
251
338
  names: ReadonlySet<string>,
339
+ repairNames: ReadonlySet<string> = new Set(),
252
340
  ): string {
253
- if (names.size === 0) return text;
341
+ if (names.size === 0 && repairNames.size === 0) return text;
254
342
  let payload: unknown;
255
343
  try {
256
344
  payload = JSON.parse(text);
257
345
  } catch {
258
346
  return text;
259
347
  }
260
- const restored = restoreRoutedCustomCalls(payload, names);
348
+ const restored = restoreRoutedCustomCalls(payload, names, repairNames);
261
349
  return restored.changed ? JSON.stringify(restored.value) : text;
262
350
  }
263
351
 
264
- export function unwrapRoutedCustomToolArguments(argumentsText: unknown): string {
265
- return customToolInput(argumentsText);
352
+ export function unwrapRoutedCustomToolArguments(
353
+ argumentsText: unknown,
354
+ toolName = "",
355
+ namespace?: string,
356
+ ): string {
357
+ return toolName
358
+ ? repairFreeformToolInput(argumentsText, toolName, namespace)
359
+ : unwrapFreeformToolInput(argumentsText);
266
360
  }
@@ -11,7 +11,7 @@ import type {
11
11
  OcxToolCall,
12
12
  OcxReasoningReplayScopeRef,
13
13
  } from "../types";
14
- import { namespacedToolName, toolChoiceCandidates } from "../types";
14
+ import { createToolChoiceResolver, namespacedToolName } from "../types";
15
15
  import { responsesRequestSchema } from "./schema";
16
16
  import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
17
17
  import { lookupReplayThoughtSignature } from "./thought-signature-replay";
@@ -758,8 +758,9 @@ export function parseRequest(
758
758
  const tc = mapToolChoice(data.tool_choice);
759
759
  if (tc && typeof tc === "object") {
760
760
  const selectors = "allowedTools" in tc ? tc.allowedTools : [tc.name];
761
+ const resolver = createToolChoiceResolver(mergedTools);
761
762
  for (const selector of selectors) {
762
- if (toolChoiceCandidates(mergedTools, selector).length > 1) {
763
+ if (resolver.candidateCount(selector) > 1) {
763
764
  throw new Error(`ambiguous tool_choice name: ${selector}`);
764
765
  }
765
766
  }
@@ -29,6 +29,7 @@ import type {
29
29
  const MAX_ENTRIES = 64;
30
30
  const MAX_TOTAL_BYTES = 256 * 1024;
31
31
  const TTL_MS = 60 * 60 * 1000;
32
+ const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
32
33
  const replayIdentityKey = randomBytes(32);
33
34
  const CREDENTIAL_HEADER_NAMES = new Set([
34
35
  "authorization",
@@ -58,10 +59,17 @@ interface ServingIdentityEntry {
58
59
  at: number;
59
60
  }
60
61
 
62
+ interface OpaqueBlobRejectionEntry {
63
+ bytes: number;
64
+ at: number;
65
+ }
66
+
61
67
  const entries = new Map<string, CacheEntry>();
62
68
  const servingIdentities = new Map<string, ServingIdentityEntry>();
69
+ const opaqueBlobRejections = new Map<string, OpaqueBlobRejectionEntry>();
63
70
  let totalBytes = 0;
64
71
  let servingIdentityTotalBytes = 0;
72
+ let opaqueBlobRejectionTotalBytes = 0;
65
73
  let clockForTests: (() => number) | null = null;
66
74
 
67
75
  const now = (): number => clockForTests?.() ?? Date.now();
@@ -142,11 +150,31 @@ function servingIdentityFor(
142
150
  return { threadId, identity: JSON.stringify(identityTuple) };
143
151
  }
144
152
 
153
+ function opaqueBlobRejectionKeyFor(
154
+ scope: OcxReasoningReplayScopeRef | undefined,
155
+ ): string | undefined {
156
+ const current = servingIdentityFor(scope);
157
+ return current ? JSON.stringify([current.threadId, current.identity]) : undefined;
158
+ }
159
+
160
+ function deleteOpaqueBlobRejection(key: string): void {
161
+ const entry = opaqueBlobRejections.get(key);
162
+ if (!entry) return;
163
+ opaqueBlobRejections.delete(key);
164
+ opaqueBlobRejectionTotalBytes -= entry.bytes;
165
+ }
166
+
167
+ function sweepExpiredOpaqueBlobRejections(at: number): void {
168
+ for (const [key, entry] of opaqueBlobRejections) {
169
+ if (at - entry.at >= OPAQUE_BLOB_REJECTION_TTL_MS) deleteOpaqueBlobRejection(key);
170
+ }
171
+ }
172
+
145
173
  /**
146
- * Compare this request's route with the last successfully serving route for its client thread.
174
+ * Compare this request's route with the last successfully serving route for its conversation.
147
175
  * A live mismatch means replayed opaque reasoning was minted by another backend and must not be
148
176
  * forwarded to this one. Comparison deliberately does not refresh or replace the recorded route:
149
- * a failed candidate request did not serve the thread.
177
+ * a failed candidate request did not serve the conversation.
150
178
  *
151
179
  * Serving provenance uses restart-stable destination and credential dimensions so token
152
180
  * generations and other volatile credential material cannot create false route changes. Missing
@@ -164,7 +192,7 @@ export function reasoningReplayServingIdentityChanged(
164
192
  return previous !== undefined && previous.identity !== current.identity;
165
193
  }
166
194
 
167
- /** Record the route only after it has successfully served the client thread. */
195
+ /** Record the route only after it has successfully served the conversation. */
168
196
  export function commitReasoningReplayServingIdentity(
169
197
  scope: OcxReasoningReplayScopeRef | undefined,
170
198
  ): void {
@@ -200,6 +228,54 @@ export function commitReasoningReplayServingIdentity(
200
228
  }
201
229
  }
202
230
 
231
+ /**
232
+ * Whether this exact conversation and durable serving identity previously rejected opaque replay.
233
+ *
234
+ * The five serving dimensions deliberately match the serving record. Missing durable destination
235
+ * or credential identity is unknown and never falls back to process-local dimensions. The five
236
+ * minute TTL is shorter than the serving record's hour: a stale memo silently degrades reasoning,
237
+ * while expiry costs one visible recovery round trip and can safely re-establish the memo.
238
+ */
239
+ export function reasoningReplayOpaqueBlobRejectionMemoized(
240
+ scope: OcxReasoningReplayScopeRef | undefined,
241
+ ): boolean {
242
+ const key = opaqueBlobRejectionKeyFor(scope);
243
+ if (!key) return false;
244
+ const at = now();
245
+ sweepExpiredOpaqueBlobRejections(at);
246
+ return opaqueBlobRejections.has(key);
247
+ }
248
+
249
+ /** Record only after a blobless retry succeeded for this durable serving identity. */
250
+ export function rememberReasoningReplayOpaqueBlobRejection(
251
+ scope: OcxReasoningReplayScopeRef | undefined,
252
+ ): void {
253
+ const key = opaqueBlobRejectionKeyFor(scope);
254
+ if (!key) return;
255
+ const bytes = Buffer.byteLength(key, "utf8");
256
+ if (bytes > MAX_TOTAL_BYTES) return;
257
+ const at = now();
258
+ sweepExpiredOpaqueBlobRejections(at);
259
+ if (opaqueBlobRejections.has(key)) deleteOpaqueBlobRejection(key);
260
+ opaqueBlobRejections.set(key, { bytes, at });
261
+ opaqueBlobRejectionTotalBytes += bytes;
262
+ while (
263
+ (opaqueBlobRejectionTotalBytes > MAX_TOTAL_BYTES || opaqueBlobRejections.size > MAX_ENTRIES)
264
+ && opaqueBlobRejections.size > 1
265
+ ) {
266
+ let oldestKey: string | undefined;
267
+ let oldestAt = Infinity;
268
+ for (const [candidateKey, entry] of opaqueBlobRejections) {
269
+ if (entry.at < oldestAt) {
270
+ oldestAt = entry.at;
271
+ oldestKey = candidateKey;
272
+ }
273
+ }
274
+ if (oldestKey === undefined) break;
275
+ deleteOpaqueBlobRejection(oldestKey);
276
+ }
277
+ }
278
+
203
279
  function processLocalIdentity(domain: string, material: string): string {
204
280
  return createHmac("sha256", replayIdentityKey)
205
281
  .update(domain)
@@ -420,7 +496,9 @@ export function peekReasoningForCall(
420
496
  export function clearReasoningReplayCacheForTests(clock?: (() => number) | null): void {
421
497
  entries.clear();
422
498
  servingIdentities.clear();
499
+ opaqueBlobRejections.clear();
423
500
  totalBytes = 0;
424
501
  servingIdentityTotalBytes = 0;
502
+ opaqueBlobRejectionTotalBytes = 0;
425
503
  clockForTests = clock ?? null;
426
504
  }
@@ -1,23 +1,25 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { formatErrorResponse } from "../bridge";
3
+ import {
4
+ codexAutoStartEnabled,
5
+ modelPreferHostedToolsConfigError,
6
+ providerModelCostsConfigError,
7
+ requestPacingConfigError,
8
+ retryOn429PolicyConfigError,
9
+ sanitizeModelCostsForDisplay,
10
+ } from "../config";
3
11
  import {
4
12
  apiKeyTransportConfigError,
5
13
  booleanRecordConfigError,
6
14
  modelAdapterRecordConfigError,
7
- modelPreferHostedToolsConfigError,
8
- codexAutoStartEnabled,
9
15
  nonBlankStringArrayConfigError,
10
16
  positiveIntegerConfigError,
11
17
  positiveIntegerRecordConfigError,
12
18
  providerBaseUrlConfigError,
13
19
  providerHeadersConfigError,
14
- providerModelCostsConfigError,
15
20
  reasoningSummaryDeliveryRecordConfigError,
16
- retryOn429PolicyConfigError,
17
- requestPacingConfigError,
18
- sanitizeModelCostsForDisplay,
19
21
  upstreamHttpVersionConfigError,
20
- } from "../config";
22
+ } from "../config/provider-validation";
21
23
  import { providerDestinationConfigError } from "../lib/destination-policy";
22
24
  import { redactSecretString } from "../lib/redact";
23
25
  import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
@@ -22,10 +22,11 @@ import {
22
22
  import { reconcileOAuthProviders } from "../oauth";
23
23
  import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization";
24
24
  import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync";
25
- import { getCodexHome } from "../codex/paths";
25
+ import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../service";
26
26
  import { shouldSyncCodexOnStart } from "../codex/desired-state";
27
27
  import {
28
28
  inspectNativeCodexOwnership,
29
+ type NativeCodexOwnership,
29
30
  type OwnershipInspection,
30
31
  } from "../integrations/native/ownership-preflight";
31
32
  import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
@@ -175,8 +176,8 @@ import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration";
175
176
  import {
176
177
  bindNativeMainStartupLifecycle,
177
178
  blockNativeMainStartupForUnownedServiceHome,
179
+ prepareNativeMainStartupLifecycle,
178
180
  releaseNativeMainStartupLifecycle,
179
- startNativeMainStartupLifecycle,
180
181
  type NativeMainStartupGateDeps,
181
182
  type NativeMainStartupLifecycle,
182
183
  } from "../codex/native-profile-startup";
@@ -444,6 +445,8 @@ export interface StartServerDeps {
444
445
  nativeMainStartup?: NativeMainStartupGateDeps;
445
446
  /** Test-only ownership evidence; production inspects the installed service state. */
446
447
  inspectNativeCodexOwnership?: typeof inspectNativeCodexOwnership;
448
+ /** Test-only service-home resolver; production resolves the current homes directly. */
449
+ resolveServiceHomes?: typeof currentServiceHomes;
447
450
  /** Test-only seam for an upstream that cannot complete its WebSocket close handshake. */
448
451
  liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory;
449
452
  /** Test-only seam; production derives a fresh local-attestation secret per process. */
@@ -452,9 +455,22 @@ export interface StartServerDeps {
452
455
  readinessGate?: ReadinessGate;
453
456
  }
454
457
 
455
- function inspectStartupOwnership(deps: StartServerDeps): OwnershipInspection {
458
+ function inspectStartupOwnership(
459
+ deps: StartServerDeps,
460
+ currentHomes: ReturnType<typeof currentServiceHomes> | null,
461
+ statePaths: readonly string[] | null,
462
+ ): OwnershipInspection {
456
463
  try {
457
- return (deps.inspectNativeCodexOwnership ?? inspectNativeCodexOwnership)();
464
+ if (currentHomes === null || statePaths === null) {
465
+ return {
466
+ ownership: "unknown",
467
+ reason: "startup service-home resolution failed",
468
+ };
469
+ }
470
+ if (deps.inspectNativeCodexOwnership) {
471
+ return deps.inspectNativeCodexOwnership({ currentHomes, statePaths });
472
+ }
473
+ return inspectNativeCodexOwnership({ currentHomes, statePaths });
458
474
  } catch {
459
475
  return {
460
476
  ownership: "unknown",
@@ -541,15 +557,27 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
541
557
  // journal, or credential path. Both positive foreign evidence and an unprovable
542
558
  // ownership state are non-authority.
543
559
  startupCacheInvalidationWrote = false;
544
- const startupCacheOwnership = inspectStartupOwnership(deps);
560
+ const resolveServiceHomes = deps.resolveServiceHomes ?? currentServiceHomes;
561
+ let startupOwnershipHomes: ReturnType<typeof currentServiceHomes> | null = null;
562
+ let startupOwnershipStatePaths: readonly string[] | null = null;
563
+ try {
564
+ const homes = resolveServiceHomes();
565
+ const statePaths = serviceStatePathsForOpenCodexHome(homes.opencodexHome);
566
+ startupOwnershipHomes = homes;
567
+ startupOwnershipStatePaths = statePaths;
568
+ } catch { /* inspection below stays unknown */ }
569
+ const startupCacheOwnership = inspectStartupOwnership(
570
+ deps,
571
+ startupOwnershipHomes,
572
+ startupOwnershipStatePaths,
573
+ );
545
574
  // Startup cache invalidation is best-effort and must never block the server from
546
- // serving. It now takes K so it cannot race a convergence commit, but both the
547
- // home resolution and the acquisition can fail on a machine with no Codex home —
548
- // `getCodexHome()` THROWS when CODEX_HOME names a missing directory, which would
549
- // otherwise turn "no Codex installed" into "proxy will not start".
550
- if (startupCacheOwnership.ownership === "owned") {
575
+ // serving. It now takes K so it cannot race a convergence commit. Use the home
576
+ // paired with the ownership inspection; re-reading ambient CODEX_HOME here could
577
+ // invalidate a different installation after an environment or mount change.
578
+ if (startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) {
551
579
  try {
552
- const startupCodexHome = getCodexHome();
580
+ const startupCodexHome = startupOwnershipHomes.codexHome;
553
581
  // #1046: record whether this actually rewrote the cache. `handleStart` ORs this
554
582
  // with the later startup sync and warns ONCE about stale app-servers; warning
555
583
  // here instead would read a catalog mtime the sync is about to move.
@@ -706,18 +734,71 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
706
734
  // clients; no Codex request can use this lifecycle in that state.
707
735
  // Re-probe here instead of trusting the earlier cache decision: startup work
708
736
  // between the two sites must not widen the service-install race.
709
- const nativeOwnership = inspectStartupOwnership(deps);
737
+ const nativeOwnership = inspectStartupOwnership(deps, startupOwnershipHomes, startupOwnershipStatePaths);
738
+ const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign"
739
+ && startupOwnershipHomes !== null
740
+ ? prepareNativeMainStartupLifecycle(
741
+ deps.nativeMainStartup,
742
+ { codexHome: startupOwnershipHomes.codexHome, configDir: startupOwnershipHomes.opencodexHome },
743
+ )
744
+ : null;
745
+ let retryOwnershipHomes = startupOwnershipHomes;
746
+ let retryOwnershipStatePaths = startupOwnershipStatePaths;
747
+ let retryPreparedNativeMainLifecycle = preparedNativeMainLifecycle;
748
+ const reprobeNativeOwnership = (): NativeCodexOwnership => {
749
+ // If startup could not resolve the homes at all, preserve a bounded retry
750
+ // without guessing an authority. The first successful resolution is pinned
751
+ // together with its service-state paths before ownership is inspected.
752
+ if (retryOwnershipHomes === null || retryOwnershipStatePaths === null) {
753
+ try {
754
+ const homes = resolveServiceHomes();
755
+ const statePaths = serviceStatePathsForOpenCodexHome(homes.opencodexHome);
756
+ retryOwnershipHomes = homes;
757
+ retryOwnershipStatePaths = statePaths;
758
+ } catch {
759
+ return "unknown";
760
+ }
761
+ }
762
+ const homes = retryOwnershipHomes;
763
+ const statePaths = retryOwnershipStatePaths;
764
+ const answer = inspectStartupOwnership(deps, homes, statePaths).ownership;
765
+ if (answer !== "owned") return answer;
766
+ retryPreparedNativeMainLifecycle ??= prepareNativeMainStartupLifecycle(
767
+ deps.nativeMainStartup,
768
+ { codexHome: homes.codexHome, configDir: homes.opencodexHome },
769
+ );
770
+ // An ownership verdict without a lifecycle bound to that same home is not
771
+ // enough to reopen native-main admission.
772
+ return retryPreparedNativeMainLifecycle ? "owned" : "unknown";
773
+ };
774
+ const ownershipRetryOptions = {
775
+ reprobe: reprobeNativeOwnership,
776
+ expectedHomeId: () => retryPreparedNativeMainLifecycle?.homeId ?? null,
777
+ startOwnedLifecycle: () => {
778
+ if (!retryPreparedNativeMainLifecycle) {
779
+ throw new Error("Native-main ownership became known before its startup lifecycle was prepared.");
780
+ }
781
+ return retryPreparedNativeMainLifecycle.start();
782
+ },
783
+ };
710
784
  const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config)
711
785
  ? nativeOwnership.ownership === "owned"
712
- ? startNativeMainStartupLifecycle(deps.nativeMainStartup)
713
- : blockNativeMainStartupForUnownedServiceHome(
714
- nativeOwnership.ownership === "foreign" ? "foreign-ownership" : "ownership-unknown",
715
- // #2108: an `unknown` verdict means the probe could not answer, not that this host
716
- // is unownable. Hand the fence a way to re-ask so a host that becomes answerable
717
- // after boot reopens on its own instead of needing `ocx restart`. A `foreign`
718
- // verdict ignores this by design — that one is a fact, not a question.
719
- { reprobe: () => inspectStartupOwnership(deps).ownership },
720
- )
786
+ ? preparedNativeMainLifecycle
787
+ ? preparedNativeMainLifecycle.start()
788
+ : blockNativeMainStartupForUnownedServiceHome(
789
+ "ownership-unknown",
790
+ ownershipRetryOptions,
791
+ )
792
+ : nativeOwnership.ownership === "foreign"
793
+ ? blockNativeMainStartupForUnownedServiceHome("foreign-ownership")
794
+ : blockNativeMainStartupForUnownedServiceHome(
795
+ "ownership-unknown",
796
+ // #2108: an `unknown` verdict means the probe could not answer, not that this host
797
+ // is unownable. Hand the fence a way to re-ask so a host that becomes answerable
798
+ // after boot reopens on its own instead of needing `ocx restart`. A `foreign`
799
+ // verdict ignores this by design — that one is a fact, not a question.
800
+ ownershipRetryOptions,
801
+ )
721
802
  : {
722
803
  homeId: null,
723
804
  settled: Promise.resolve({ status: "ready", homeId: null }),
@@ -1,4 +1,4 @@
1
- import { readRuntimePort, type RuntimePortState } from "../config";
1
+ import { readRuntimePort, type RuntimePortState } from "../config/process-state";
2
2
  import { createLocalAttestationChallenge } from "../lib/local-management-attestation";
3
3
  import {
4
4
  LOCAL_MANAGEMENT_CAPABILITY_HEADER,
@@ -1,4 +1,4 @@
1
- import { readRuntimePort, type RuntimePortState } from "../config";
1
+ import { readRuntimePort, type RuntimePortState } from "../config/process-state";
2
2
  import {
3
3
  LOCAL_ATTESTATION_CHALLENGE_HEADER,
4
4
  LOCAL_ATTESTATION_PROOF_HEADER,
@@ -155,7 +155,7 @@ function runGrokApplyFlight(): Promise<unknown> {
155
155
  flight.promise = (grokApplyTestHooks?.run ?? (async () => {
156
156
  const [{ syncGrokConfig }, { readRuntimePort }] = await Promise.all([
157
157
  import("../../grok/sync"),
158
- import("../../config"),
158
+ import("../../config/process-state"),
159
159
  ]);
160
160
  const currentConfig = loadConfig();
161
161
  const runtime = readRuntimePort(process.pid);
@@ -490,7 +490,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
490
490
  if (url.pathname === "/api/sync" && req.method === "POST") {
491
491
  const { syncModelsToCodex } = await import("../../codex/sync");
492
492
  const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
493
- const { readRuntimePort, loadConfig } = await import("../../config");
493
+ const [{ readRuntimePort }, { loadConfig }] = await Promise.all([
494
+ import("../../config/process-state"),
495
+ import("../../config"),
496
+ ]);
494
497
  // Never use the server-captured startup object for a durable integration
495
498
  // decision. A toggle may have persisted while this process was gathering.
496
499
  const runtime = readRuntimePort(process.pid);
@@ -8,7 +8,7 @@ import type { ManagementPrincipal } from "../management-auth";
8
8
  import type { CatalogModel } from "../../codex/catalog";
9
9
  import type { injectGrokConfig } from "../../grok/inject";
10
10
  import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
11
- import type { RuntimePortState } from "../../config";
11
+ import type { RuntimePortState } from "../../config/process-state";
12
12
  import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types";
13
13
  import type {
14
14
  performCodexRestart,