@bitkyc08/opencodex 2.57.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 (86) hide show
  1. package/gui/dist/assets/{index-Cz7CLdif.js → index-BbrHOIY0.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  5. package/src/adapters/command-code.ts +13 -4
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +65 -1
  8. package/src/adapters/cursor/live-transport.ts +5 -1
  9. package/src/adapters/cursor/protobuf-events.ts +110 -11
  10. package/src/adapters/cursor/protobuf-request.ts +19 -1
  11. package/src/adapters/cursor/text-toolcall.ts +230 -0
  12. package/src/adapters/cursor/thread-continuity.ts +67 -0
  13. package/src/adapters/cursor/types.ts +5 -0
  14. package/src/adapters/cursor.ts +55 -5
  15. package/src/adapters/google-http.ts +38 -13
  16. package/src/adapters/mimo-free.ts +32 -17
  17. package/src/adapters/ollama-native.ts +42 -8
  18. package/src/adapters/openai-responses/passthrough.ts +30 -4
  19. package/src/adapters/openai-responses/request-strips.ts +43 -0
  20. package/src/adapters/physical-send.ts +50 -0
  21. package/src/bridge/response-json.ts +1 -1
  22. package/src/bridge/sse.ts +1 -1
  23. package/src/claude/outbound.ts +14 -4
  24. package/src/cli/config-command.ts +35 -18
  25. package/src/cli/dispatch.ts +17 -4
  26. package/src/cli/index.ts +44 -2
  27. package/src/cli/system-command.ts +70 -1
  28. package/src/cli/uninstall-client-state.ts +12 -0
  29. package/src/codex/auth-context.ts +42 -8
  30. package/src/codex/desktop-switches.ts +145 -0
  31. package/src/codex/history-job.ts +5 -1
  32. package/src/codex/history-provider.ts +33 -4
  33. package/src/codex/history-worker.ts +14 -1
  34. package/src/codex/inject/remove.ts +145 -7
  35. package/src/codex/inject/restore.ts +204 -32
  36. package/src/codex/inject.ts +3 -7
  37. package/src/codex/loopback-target.ts +9 -0
  38. package/src/codex/native-profile-startup.ts +64 -20
  39. package/src/config/atomic-write.ts +83 -8
  40. package/src/config/schema/config-schema.ts +2 -0
  41. package/src/config/schema/leaf-validators.ts +1 -0
  42. package/src/generated/compatibility-version.json +132 -68
  43. package/src/lib/bounded-subprocess.ts +62 -10
  44. package/src/lib/windows-secret-acl.ts +151 -15
  45. package/src/lib/windows-user-principal.ts +5 -1
  46. package/src/providers/derive.ts +6 -0
  47. package/src/providers/model-discovery.ts +19 -7
  48. package/src/providers/registry/entries-core.ts +11 -0
  49. package/src/providers/registry/entries-extended.ts +50 -28
  50. package/src/providers/registry/model-seeds.ts +67 -17
  51. package/src/providers/registry/types.ts +9 -0
  52. package/src/responses/spill-store.ts +17 -0
  53. package/src/responses/state/body-policy.ts +25 -0
  54. package/src/responses/state/spill-queue.ts +8 -6
  55. package/src/responses/state.ts +3 -22
  56. package/src/router.ts +4 -0
  57. package/src/server/auth-cors.ts +1 -0
  58. package/src/server/index/websocket-handler.ts +48 -1
  59. package/src/server/management/config-routes.ts +27 -5
  60. package/src/server/models-capabilities.ts +24 -3
  61. package/src/server/responses/codex-ws-exchange.ts +65 -4
  62. package/src/server/responses/combo-stream-preflight.ts +68 -5
  63. package/src/server/responses/core-combo.ts +26 -0
  64. package/src/server/responses/core-options.ts +3 -0
  65. package/src/server/responses/fetch-helpers.ts +4 -1
  66. package/src/server/responses/native-injection-protocol.ts +42 -0
  67. package/src/server/responses/native-injection-replay.ts +105 -0
  68. package/src/server/responses/native-injection.ts +242 -0
  69. package/src/server/responses/native-response-control.ts +56 -0
  70. package/src/server/responses/native-response-json.ts +14 -0
  71. package/src/server/responses/native-response-output.ts +37 -0
  72. package/src/server/responses/native-steering-log.ts +44 -0
  73. package/src/server/responses/native-steering-policy.ts +49 -0
  74. package/src/server/responses/native-steering-replay.ts +126 -0
  75. package/src/server/responses/native-steering-settings.ts +76 -0
  76. package/src/server/responses/native-steering.ts +400 -0
  77. package/src/server/responses/native-tool-results.ts +130 -0
  78. package/src/server/responses/passthrough-delivery.ts +11 -0
  79. package/src/server/responses/passthrough-dispatch.ts +33 -1
  80. package/src/server/responses/request-prepare.ts +41 -0
  81. package/src/server/responses/ws-upstream.ts +21 -1
  82. package/src/server/stop-teardown.ts +8 -1
  83. package/src/server/ws-bridge.ts +16 -1
  84. package/src/service/cli.ts +13 -1
  85. package/src/types/config.ts +4 -0
  86. package/src/types/provider.ts +13 -0
@@ -306,17 +306,37 @@ function buildNativeMessages(
306
306
  // owned by this adapter/request lifecycle rather than process-global state.
307
307
  reservedToolCallIds.clear();
308
308
  let pending: PendingToolBatch | undefined;
309
+ // Codex records mid-turn injections (a PostToolUse hook verdict, a context notice) between an
310
+ // assistant tool call and that call's own tool result. Native Ollama needs the call and its
311
+ // results adjacent, so those conversational messages wait here instead of closing the batch
312
+ // early. The openai-chat adapter defers them the same way; refusing the replay killed the turn.
313
+ let deferred: OllamaNativeMessage[] = [];
314
+
315
+ const releaseDeferred = (): void => {
316
+ if (deferred.length === 0) return;
317
+ messages.push(...deferred);
318
+ deferred = [];
319
+ };
309
320
 
310
321
  const flushPending = (): void => {
311
322
  if (!pending) return;
312
323
  for (const call of pending.calls) {
313
324
  if (!call.result) {
314
- throw new Error(`ollama-native tool call ${call.id} is missing its tool result; refusing interrupted replay`);
325
+ // No result exists anywhere in the replayed history: the turn was interrupted, or the
326
+ // result never reached it. State exactly that instead of inventing an outcome, and keep
327
+ // the conversation replayable.
328
+ messages.push({
329
+ role: "tool",
330
+ tool_call_id: call.id,
331
+ tool_name: call.wireName,
332
+ // Same marker text as the chat adapter (openai-chat/messages.ts), so both adapters read
333
+ // the same in an operator's log. The name is this wire's flattened tool name, which is
334
+ // what the assistant turn above it carries.
335
+ content: `[ocx] no tool result was recorded for "${call.wireName}"; execution status unknown — do not treat this as success, failure, or user-provided input.`,
336
+ });
337
+ continue;
315
338
  }
316
- }
317
- for (const call of pending.calls) {
318
- const result = call.result!;
319
- const translated = contentToNative(result.content, "tool result");
339
+ const translated = contentToNative(call.result.content, "tool result");
320
340
  messages.push({
321
341
  role: "tool",
322
342
  tool_call_id: call.id,
@@ -326,6 +346,7 @@ function buildNativeMessages(
326
346
  });
327
347
  }
328
348
  pending = undefined;
349
+ releaseDeferred();
329
350
  };
330
351
 
331
352
  for (const message of parsed.context.messages) {
@@ -347,9 +368,22 @@ function buildNativeMessages(
347
368
  continue;
348
369
  }
349
370
 
350
- // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A
351
- // new conversational message is a hard boundary; unresolved calls are never fabricated.
352
- if (pending) flushPending();
371
+ // Native Ollama requires the whole assistant tool-call turn followed by its tool results. A
372
+ // conversational message that arrives while the batch is still open is held aside instead of
373
+ // closing it, so the call keeps its results adjacent; it is released right after the batch
374
+ // flushes. Anything else (a new assistant turn) settles the batch first.
375
+ if (pending) {
376
+ if (message.role === "user" || message.role === "developer") {
377
+ const translated = message.role === "user"
378
+ ? contentToNative(message.content, "user")
379
+ : contentToNative(message.content, "developer", false);
380
+ deferred.push(message.role === "user"
381
+ ? { role: "user", content: translated.content, ...(translated.images ? { images: translated.images } : {}) }
382
+ : { role: "system", content: translated.content });
383
+ continue;
384
+ }
385
+ flushPending();
386
+ }
353
387
 
354
388
  switch (message.role) {
355
389
  case "user": {
@@ -32,7 +32,7 @@ import {
32
32
  createAdapterTierMetadata,
33
33
  } from "../../providers/fastwire";
34
34
  import { mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripUnsupportedReasoningSummaryDelivery } from "./reasoning";
35
- import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored } from "./request-strips";
35
+ import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored } from "./request-strips";
36
36
  import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache";
37
37
  import { isPlainObject } from "./internal";
38
38
  import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema";
@@ -270,19 +270,31 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
270
270
  // tier write so a force-fast/default decision can never mutate parsed._rawBody.
271
271
  outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision);
272
272
  const stateless = provider.statelessResponses === true;
273
+ const adjacentToolResults = provider.requiresAdjacentResponsesToolResults === true;
274
+ // Adjacency reorders items the upstream would accept in some order. Pairing synthesizes an
275
+ // item the client never sent, which is a larger claim about the conversation, so it is its
276
+ // own capability: Kimi carries the adjacency flag but accepts a dangling call (#4726) and
277
+ // must not start receiving placeholders it never needed.
278
+ const pairedToolResults = provider.requiresPairedResponsesToolResults === true;
273
279
  if (stateless) outBody = stripStatefulResponsesParams(outBody);
274
280
  // A replay miss can leave a function_call_output whose paired function_call sat
275
281
  // in the prefix that was never expanded. A stateless upstream cannot resolve the
276
282
  // pair from its own storage either, so it needs the same repair the forward
277
283
  // backend gets — dropping previous_response_id is not much use if the body that
278
284
  // reaches the wire is unparseable.
285
+ // A parser can also 400 on a function_call with no matching output at all. DeepSeek gets
286
+ // that repair through statelessResponses. xAI cannot be marked stateless: its Responses API
287
+ // stores conversations for 30 days and documents previous_response_id. So it carries the
288
+ // pairing capability instead, which reuses the orphan-call placeholder without touching
289
+ // store or previous_response_id.
279
290
  if (provider.annotateEmptyToolOutputs === true) {
280
291
  outBody = annotateEmptyResponsesToolOutputs(outBody, true);
281
292
  }
282
- if (forward || stateless) {
283
- outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
293
+ const synthesizeMissingCallOutputs = !forward && (stateless || pairedToolResults);
294
+ if (forward || stateless || pairedToolResults) {
295
+ outBody = repairOrphanedInputItems(outBody, unexpandedMiss, synthesizeMissingCallOutputs);
284
296
  }
285
- if (provider.requiresAdjacentResponsesToolResults === true) {
297
+ if (adjacentToolResults) {
286
298
  outBody = normalizeResponsesToolResultAdjacency(outBody);
287
299
  }
288
300
  if (forward) {
@@ -316,6 +328,20 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
316
328
  outBody = backfillWebSearchQueries(outBody);
317
329
  if (!isCanonicalOpenAiForwardProvider(provider)) {
318
330
  outBody = stripInternalChatMessageMetadataPassthrough(outBody);
331
+ // The same class of private field, one level up, but keyed on the DESTINATION rather than
332
+ // on the canonical surface alone. `src/server/responses/compact.ts` spreads the caller's
333
+ // raw body into the native `/responses/compact` request without passing through this
334
+ // adapter, and that endpoint is offered only to OpenAI-operated destinations
335
+ // (supportsNativeResponsesCompactEndpoint). Stripping on the canonical predicate here
336
+ // would make the two paths disagree for `openai-apikey`; stripping on the destination
337
+ // keeps every OpenAI-operated route byte-identical and removes the field exactly where it
338
+ // is known to break, which is a gateway this proxy does not operate.
339
+ //
340
+ // Placed before the routed compaction body is built and before serialization, so the HTTP,
341
+ // routed-compaction and WebSocket outbounds are all covered by this one call.
342
+ if (!isOpenAiOperatedResponsesDestination(provider)) {
343
+ outBody = stripCanonicalOnlyTopLevelFields(outBody);
344
+ }
319
345
  outBody = promoteClientLoadedTools(outBody);
320
346
  }
321
347
  if (!isCanonicalOpenAiForwardProvider(provider)) {
@@ -120,6 +120,49 @@ export function stripInternalChatMessageMetadataPassthrough(body: unknown): unkn
120
120
  return changed ? { ...body, input } : body;
121
121
  }
122
122
 
123
+ /**
124
+ * OpenAI-private TOP-LEVEL request keys, the sibling of `CANONICAL_ONLY_TOOL_FIELDS` one level up.
125
+ *
126
+ * Codex attaches these on the request body itself rather than on a tool or an input item, and gates
127
+ * them on its own auth rather than on the destination URL. Loopback injection keeps Codex pointed at
128
+ * its built-in `openai` provider, so the client still believes it is addressing the canonical
129
+ * ChatGPT backend and keeps the key no matter where this proxy routes the turn. A Responses gateway
130
+ * that validates its top-level schema then rejects the whole request before inference.
131
+ *
132
+ * Keep this a table, and keep it to keys a client is OBSERVED to send. It is not an unknown-field
133
+ * sanitizer: a top-level key nobody has traced to a client is forwarded untouched, because deleting
134
+ * it would silently drop a parameter some other caller means.
135
+ */
136
+ const CANONICAL_ONLY_TOP_LEVEL_FIELDS: readonly string[] = [
137
+ // Cyber access program selector, new in Codex 0.155. codex-rs mints it from
138
+ // `cyber_access_program::for_auth`, which filters on ChatGPT auth alone and never on the
139
+ // destination base URL, and serializes it on the Responses request, the compaction input and the
140
+ // WebSocket `response.create` envelope. No public specification defines it, so a strict
141
+ // third-party gateway answers with an unknown-parameter error naming it, and every turn of that
142
+ // thread fails (#4853).
143
+ //
144
+ // `codex_output_schema` is deliberately NOT here. In codex-rs it is the `name` of the JSON-schema
145
+ // `text.format` object, not a top-level key, so listing it would delete a field this client never
146
+ // sends and discard it for any client that does send it meaningfully.
147
+ "access_programs",
148
+ ];
149
+
150
+ /**
151
+ * Remove the OpenAI-private top-level keys.
152
+ *
153
+ * The caller decides the boundary; see the call site in `passthrough.ts`, which applies this only
154
+ * to a destination OpenCodex does not operate. Returns the input unchanged when no listed key is
155
+ * present, so the common path allocates nothing and the caller-owned raw body is never mutated.
156
+ */
157
+ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown {
158
+ if (!isPlainObject(body)) return body;
159
+ if (!CANONICAL_ONLY_TOP_LEVEL_FIELDS.some(field => Object.hasOwn(body, field))) return body;
160
+
161
+ const next = { ...body };
162
+ for (const field of CANONICAL_ONLY_TOP_LEVEL_FIELDS) delete next[field];
163
+ return next;
164
+ }
165
+
123
166
  /**
124
167
  * When `store` is false, the upstream API does not persist response items. Any item ID
125
168
  * forwarded in `input` is then interpreted as a reference to a stored item that does not
@@ -0,0 +1,50 @@
1
+ import type { AdapterFetchContext } from "./base";
2
+ import type { SendClass } from "../lib/request-execution-budget";
3
+ import type { AttemptRecoveryKind } from "../usage/log";
4
+ import { abortError, SendBudgetExhaustedError } from "../lib/upstream-retry";
5
+
6
+ type PacedFetch = typeof globalThis.fetch & {
7
+ waitForPacing?: (signal?: AbortSignal) => Promise<void>;
8
+ unpacedFetch?: typeof globalThis.fetch;
9
+ };
10
+
11
+ /** One ordinal sequence per adapter fetchResponse call, across all of its inference retries.
12
+ * Consumption starts at underlying executor invocation; its own later preflight may still fail. */
13
+ export function createAdapterPhysicalSend(ctx: AdapterFetchContext = {}, fallback = globalThis.fetch) {
14
+ const executor = (ctx.executor ?? fallback) as PacedFetch;
15
+ let ordinal = 0;
16
+ return async (options: {
17
+ url: string;
18
+ sendClass?: SendClass;
19
+ recovery?: AttemptRecoveryKind;
20
+ /** Runs only after admission, e.g. backoff and cancellation of a superseded response. */
21
+ beforeDispatch?: () => void | Promise<void>;
22
+ dispatch: (executor: typeof globalThis.fetch) => Promise<Response>;
23
+ }): Promise<Response> => {
24
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
25
+ const decision = ctx.sendBudget?.reserveDispatch({
26
+ sendClass: options.sendClass ?? "transient", targetKey: options.url,
27
+ });
28
+ if (decision && !decision.allowed) throw new SendBudgetExhaustedError(options.url);
29
+ const permit = decision?.allowed ? decision.permit : undefined;
30
+ let dispatched = false;
31
+ const physicalExecutor = (async (input, init) => {
32
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
33
+ if (init?.signal?.aborted) throw abortError(init.signal);
34
+ if (dispatched || (permit && !permit.use())) throw new SendBudgetExhaustedError(options.url);
35
+ dispatched = true;
36
+ ordinal += 1;
37
+ ctx.onPhysicalSend?.({ ordinal, ...(options.recovery ? { recovery: options.recovery } : {}) });
38
+ return (executor.unpacedFetch ?? executor)(input, init);
39
+ }) as typeof globalThis.fetch;
40
+ try {
41
+ await executor.waitForPacing?.(ctx.abortSignal);
42
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
43
+ await options.beforeDispatch?.();
44
+ if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
45
+ return await options.dispatch(physicalExecutor);
46
+ } finally {
47
+ permit?.release();
48
+ }
49
+ };
50
+ }
@@ -527,7 +527,7 @@ function buildResponseJSONWithBudget(
527
527
  compactionEncryptedContent = e.compactionEncryptedContent;
528
528
  sawTerminal = true;
529
529
  endTurn = e.endTurn;
530
- cleanDone = e.stopReason === undefined;
530
+ cleanDone = !isTruncatedStopReason(e.stopReason);
531
531
  rawStopReason = e.stopReason;
532
532
  if (e.providerState) options?.onProviderState?.(e.providerState);
533
533
  // Match streaming: max_tokens and content_filter both terminate as incomplete.
package/src/bridge/sse.ts CHANGED
@@ -1171,7 +1171,7 @@ export function bridgeToResponsesSSE(
1171
1171
  break;
1172
1172
  }
1173
1173
  case "done": {
1174
- if (currentMsg) closeCurrentMessage(event.stopReason ? undefined : "final_answer");
1174
+ if (currentMsg) closeCurrentMessage(isTruncatedStopReason(event.stopReason) ? undefined : "final_answer");
1175
1175
  if (currentReasoning) closeCurrentReasoning();
1176
1176
  if (currentRawReasoning) closeCurrentRawReasoning();
1177
1177
  flushHiddenRawReasoning();
@@ -189,7 +189,7 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte
189
189
  return { id, input, resultContent, completed };
190
190
  }
191
191
 
192
- function messageSnapshot(model: string): Rec {
192
+ function messageSnapshot(model: string, confirmedUsage?: Rec): Rec {
193
193
  return {
194
194
  id: `msg_${uuid()}`,
195
195
  type: "message",
@@ -198,7 +198,7 @@ function messageSnapshot(model: string): Rec {
198
198
  model,
199
199
  stop_reason: null,
200
200
  stop_sequence: null,
201
- usage: { input_tokens: 0, output_tokens: 0 },
201
+ usage: confirmedUsage ?? { input_tokens: 0, output_tokens: 0 },
202
202
  };
203
203
  }
204
204
 
@@ -246,6 +246,7 @@ export function responsesSseToAnthropicSse(
246
246
  let open: OpenBlock | null = null;
247
247
  let sawToolUse = false;
248
248
  let webSearchRequests = 0;
249
+ let earlyAnthropicUsage: Rec | undefined;
249
250
  let pingTimer: ReturnType<typeof setInterval> | undefined;
250
251
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
251
252
  const utf8SliceBytes = (value: string, start: number, end: number): number => {
@@ -282,7 +283,7 @@ export function responsesSseToAnthropicSse(
282
283
  const ensureStarted = () => {
283
284
  if (started) return;
284
285
  started = true;
285
- emit("message_start", { type: "message_start", message: messageSnapshot(model) });
286
+ emit("message_start", { type: "message_start", message: messageSnapshot(model, earlyAnthropicUsage) });
286
287
  emit("ping", { type: "ping" });
287
288
  };
288
289
  // Keepalive pings protect remote deployments behind LB/NAT idle timeouts even
@@ -405,8 +406,17 @@ export function responsesSseToAnthropicSse(
405
406
  const handleFrame = (eventName: string, data: Rec) => {
406
407
  switch (eventName) {
407
408
  case "response.created":
408
- // Transport prelude only. Start Anthropic framing on semantic output or completion.
409
+ case "response.in_progress": {
410
+ // Lifecycle preludes do not start Anthropic framing, but some upstreams attach
411
+ // confirmed input usage before semantic output. Retain only its bounded Anthropic
412
+ // projection so message_start can report measurements that already arrived.
413
+ const response = isRec(data.response) ? data.response : {};
414
+ const usage = isRec(response.usage) ? response.usage : undefined;
415
+ if (!started && usage && typeof usage.input_tokens === "number") {
416
+ earlyAnthropicUsage = anthropicUsage(usage);
417
+ }
409
418
  break;
419
+ }
410
420
  case "response.heartbeat":
411
421
  if ((controller.desiredSize ?? 0) > 0) emit("ping", { type: "ping" });
412
422
  break;
@@ -4,7 +4,7 @@ import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeMo
4
4
  import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { normalizeVisionReasoningForModel } from "../vision/reasoning";
7
- import type { ClientConnectionStatus } from "./connect";
7
+ import type { ServiceApiTokenState } from "../lib/service-secrets";
8
8
  import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
9
9
 
10
10
  const USAGE = `Usage:
@@ -38,9 +38,8 @@ const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
38
38
  * `client` block, which is the same defect in miniature: the presence of configuration is not
39
39
  * evidence that the connection works, and a machine whose data-plane token was revoked, rotated
40
40
  * away or deleted would have been labelled `connected: true` while it could not reach the hub at
41
- * all. `collectClientConnectionStatus` is the one reader that knows — it compares the token file's
42
- * fingerprint against the connection record — so the caller passes its answer in and this stays
43
- * pure and testable.
41
+ * all. The read-only projection compares the bounded token file's fingerprint against the
42
+ * connection record, and passes that answer in so this formatter stays pure and testable.
44
43
  *
45
44
  * Synthetic and NOT persisted, for two reasons. `clientConnectionSchema` is `.strict()`, so a
46
45
  * `client.note` field would not validate; and persisted prose drifts from the behaviour it
@@ -49,7 +48,7 @@ const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
49
48
  */
50
49
  export function remoteHubConfigNote(
51
50
  config: OcxConfig,
52
- readConnection: () => Pick<ClientConnectionStatus, "state" | "reason" | "token">,
51
+ readConnection: () => RemoteHubConnectionObservation,
53
52
  ): { connected: boolean; origin: string; note: string } | null {
54
53
  if (config.runtimeRole !== "client" || !config.client) return null;
55
54
  // A thunk, so a standalone or hub install pays nothing: the guard above returns first and the
@@ -67,6 +66,36 @@ export function remoteHubConfigNote(
67
66
  return { connected, origin: config.client.serverUrl, note };
68
67
  }
69
68
 
69
+ export type RemoteHubConnectionObservation = {
70
+ state: "disconnected" | "connected" | "invalid" | "mismatched";
71
+ reason?: string;
72
+ token: "owned" | "missing" | "changed" | "unsafe";
73
+ };
74
+
75
+ export function remoteHubConnectionFromTokenState(
76
+ config: Pick<OcxConfig, "client">,
77
+ tokenState: ServiceApiTokenState,
78
+ ): RemoteHubConnectionObservation {
79
+ const token = tokenState.kind === "absent"
80
+ ? "missing"
81
+ : tokenState.kind === "unsafe"
82
+ ? "unsafe"
83
+ : tokenState.fingerprint === config.client?.tokenFingerprint ? "owned" : "changed";
84
+ return { state: "connected", token };
85
+ }
86
+
87
+ async function readRemoteHubConfigNote(config: OcxConfig): Promise<ReturnType<typeof remoteHubConfigNote>> {
88
+ if (config.runtimeRole !== "client" || !config.client) return null;
89
+ // This display command needs only connection ownership, not lifecycle recovery, catalog
90
+ // readiness, or any write-capable connect machinery. Keep the read on the bounded token
91
+ // observer so a cold `config show` never imports the full connect command graph.
92
+ const { readServiceApiTokenState } = await import("../lib/service-secrets");
93
+ return remoteHubConfigNote(
94
+ config,
95
+ () => remoteHubConnectionFromTokenState(config, readServiceApiTokenState()),
96
+ );
97
+ }
98
+
70
99
  function redact(value: unknown, key = ""): unknown {
71
100
  if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
72
101
  // `client.priorCatalog` is the base64 catalog snapshot connect took before overwriting the
@@ -168,19 +197,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
168
197
  rejectArgs(args, USAGE);
169
198
  const diagnostics = readConfigDiagnostics();
170
199
  const redacted = redact(diagnostics.config);
171
- // Imported here rather than at module scope: `./connect` pulls the whole client lifecycle
172
- // in, and `ocx config get/set` has no use for it.
173
- const { collectClientConnectionStatus } = await import("./connect");
174
- // The readiness probe is declined explicitly. `collectClientConnectionStatus` observes the
175
- // local Codex ladder for a connected client, and observing it spawns `codex debug models`
176
- // under a 45s budget. `ocx config show` reads only `state`, `reason` and `token` from the
177
- // result, so paying for a subprocess here would buy nothing and would quietly turn a
178
- // read-only config dump into a runtime probe. Returning no ladder resolves readiness to
179
- // `unverified`, which is the honest answer for a caller that never asked.
180
- const note = remoteHubConfigNote(
181
- diagnostics.config,
182
- () => collectClientConnectionStatus(undefined, undefined, { supportedEfforts: () => null }),
183
- );
200
+ const note = await readRemoteHubConfigNote(diagnostics.config);
184
201
  // First key, not last: it has to be read before the empty `providers` map that misled a
185
202
  // reader into concluding nothing was configured anywhere.
186
203
  const config = note && redacted && typeof redacted === "object" && !Array.isArray(redacted)
@@ -24,7 +24,7 @@ import {
24
24
  } from "../codex/desired-state";
25
25
  import { syncModelsToCodex } from "../codex/sync";
26
26
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
27
- import { restoreNativeCodexAsync } from "../codex/inject";
27
+ import { restoreNativeCodexAsync, type CodexNativeRestoreResult } from "../codex/inject";
28
28
  import { stripGrokConfig } from "../grok/inject";
29
29
  import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope";
30
30
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
@@ -106,6 +106,7 @@ const commandRunners: Record<string, CommandRunner> = {
106
106
  restore: async deps => {
107
107
  const restoreArgs = deps.args.slice(1);
108
108
  const restoreJson = takeFlag(restoreArgs, "--json");
109
+ const removeProviderTable = takeFlag(restoreArgs, "--remove-codex-provider-table");
109
110
  if (restoreArgs[0] === "back") {
110
111
  // Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its
111
112
  // lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged;
@@ -146,6 +147,9 @@ const commandRunners: Record<string, CommandRunner> = {
146
147
  const target = collectOrcaCodexHomeDiagnostic();
147
148
  return emitBack(true, `Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`, 0);
148
149
  }
150
+ if (removeProviderTable && !restoreJson) {
151
+ console.log("⚠️ Removing [model_providers.opencodex] means conversations already tagged opencodex will stop opening.");
152
+ }
149
153
  const desired = setIntegrationEnabled("codex", false);
150
154
  if (!desired.ok) {
151
155
  if (restoreJson) {
@@ -191,9 +195,9 @@ const commandRunners: Record<string, CommandRunner> = {
191
195
  return grokCode;
192
196
  }
193
197
  }
194
- let r: { success: boolean; message: string };
198
+ let r: CodexNativeRestoreResult | Pick<CodexNativeRestoreResult, "success" | "message">;
195
199
  try {
196
- r = await restoreNativeCodexAsync({ revalidateDesiredState: true });
200
+ r = await restoreNativeCodexAsync({ revalidateDesiredState: true, removeProviderTable });
197
201
  } catch (err) {
198
202
  r = { success: false, message: err instanceof Error ? err.message : String(err) };
199
203
  }
@@ -232,7 +236,16 @@ const commandRunners: Record<string, CommandRunner> = {
232
236
  code = 1;
233
237
  }
234
238
  if (r.success) {
235
- console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back");
239
+ const retained = "retainedCodexProviderTable" in r ? r.retainedCodexProviderTable : undefined;
240
+ if (retained) {
241
+ console.log("Codex integration is OFF and plain `codex` now runs natively.");
242
+ console.log("The following lines remain in $CODEX_HOME/config.toml because conversations already tagged opencodex resolve their provider only through this table:");
243
+ console.log(retained.lines.join("\n"));
244
+ console.log(`Follow-up: ${retained.followUp}`);
245
+ console.log("Switch back with: ocx restore back");
246
+ } else {
247
+ console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back");
248
+ }
236
249
  console.log(`Note: ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}`);
237
250
  } else {
238
251
  console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
package/src/cli/index.ts CHANGED
@@ -13,7 +13,20 @@ try {
13
13
  /* best-effort */
14
14
  }
15
15
  }
16
- import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject";
16
+ import {
17
+ currentExternalCodexModelProvider,
18
+ restoreNativeCodex,
19
+ restoreNativeCodexAsync,
20
+ shouldInjectApiAuthHeader,
21
+ } from "../codex/inject";
22
+ // Straight from the owning modules rather than the facade: these are teardown-reporting
23
+ // helpers, not part of the injection surface, and `inject.ts` sits under a size cap that
24
+ // exists to stop it collecting exactly this kind of passthrough.
25
+ import { readOcxProviderTableBlock } from "../codex/inject/remove";
26
+ import {
27
+ describeRetainedCodexProviderTable,
28
+ type RetainedCodexProviderTable,
29
+ } from "../codex/inject/restore";
17
30
  import { stripGrokConfig } from "../grok/inject";
18
31
  import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs";
19
32
  import {
@@ -119,6 +132,12 @@ function reportShellHookFailure(result: { state: "installed" | "absent" | "faile
119
132
  console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block.");
120
133
  }
121
134
 
135
+ function reportRetainedCodexProviderTable(retained: RetainedCodexProviderTable): void {
136
+ console.log(` ${describeRetainedCodexProviderTable(retained)}`);
137
+ console.log(" Retained config lines:");
138
+ for (const line of retained.lines) console.log(` ${line}`);
139
+ }
140
+
122
141
  async function refreshOwnedRaycastCatalog(
123
142
  config: ReturnType<typeof loadConfig>,
124
143
  port: number,
@@ -799,7 +818,12 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
799
818
  let other = false;
800
819
  try {
801
820
  const result = await restoreNativeCodexAsync();
802
- if (result.success) console.log(`↩️ ${result.message}`);
821
+ if (result.success) {
822
+ console.log(`↩️ ${result.message}`);
823
+ if (result.retainedCodexProviderTable) {
824
+ reportRetainedCodexProviderTable(result.retainedCodexProviderTable);
825
+ }
826
+ }
803
827
  else {
804
828
  // Codex history is the one restore whose failure leaves the runtime consistent: the
805
829
  // manifest is retained and the routed metadata is untouched. Config and catalog are
@@ -810,6 +834,9 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
810
834
  // attempted. Reading the states alone cannot tell that apart from an ownership
811
835
  // refusal, so the structured reason carries it and the states are still required to
812
836
  // agree — a refusal that somehow reports a failed artifact is not this case.
837
+ // A degraded restore has no refusal reason and reports config as partial, so it cannot
838
+ // enter this branch: its config obligation was discharged and the stop receipt must be
839
+ // released rather than preserved.
813
840
  const preflightRefused = result.historyPreflightRefusal !== undefined
814
841
  && artifacts.config.state === "skipped"
815
842
  && artifacts.catalog.state === "skipped"
@@ -1357,6 +1384,9 @@ async function handleUninstall() {
1357
1384
  await runStep("native Codex restored", async () => {
1358
1385
  const r = await restoreNativeCodexAsync();
1359
1386
  if (!r.success) throw new Error(r.message);
1387
+ if (r.retainedCodexProviderTable) {
1388
+ reportRetainedCodexProviderTable(r.retainedCodexProviderTable);
1389
+ }
1360
1390
  });
1361
1391
 
1362
1392
  await runStep("Grok Build config restored", () => {
@@ -1503,6 +1533,18 @@ async function handleStatus() {
1503
1533
  console.log(` Codex autostart: ${status.json.codexAutostart ? "enabled" : "disabled"}${local}`);
1504
1534
  console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}${local}`);
1505
1535
  console.log(` ${formatStartupRoutingDetail(status.json.startup)}${local}`);
1536
+ if (status.json.startup.routingKind === "native") {
1537
+ let retainedProviderTable = false;
1538
+ try {
1539
+ retainedProviderTable = readOcxProviderTableBlock() !== null;
1540
+ } catch {
1541
+ // The routing snapshot owns unreadable-config reporting. A later read race must not
1542
+ // turn this diagnostic command into a teardown failure.
1543
+ }
1544
+ if (retainedProviderTable) {
1545
+ console.log(` ⚠️ Codex provider table retained${local}: [model_providers.opencodex] remains while root routing is native. Remove with 'ocx restore --remove-codex-provider-table'; tagged conversations will stop opening.`);
1546
+ }
1547
+ }
1506
1548
  console.log(` Service: ${status.json.service.summary}${local}`);
1507
1549
  console.log(` ${status.json.codexShim.summary}${local}`);
1508
1550
  console.log(` Codex runtime: ${status.json.codexRuntime.path}${local}`);
@@ -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> {