@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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 (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -66,6 +66,7 @@ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex
66
66
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
67
67
  import {
68
68
  availableAccountGatedNativeModels,
69
+ codexModelEntitlementStateForAccount,
69
70
  resolveCodexModelEntitlements,
70
71
  } from "../codex/model-entitlements";
71
72
  export {
@@ -1211,10 +1212,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1211
1212
  ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => {
1212
1213
  const target = accountTargets.get(selector);
1213
1214
  const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
1214
- const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
1215
- const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
1216
1215
  return [selector, slugs.filter(slug => (
1217
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
1216
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
1217
+ || (accountId !== undefined
1218
+ && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
1218
1219
  ))] as const;
1219
1220
  }))
1220
1221
  : new Map<string, readonly string[]>();
@@ -458,8 +458,9 @@ export function trackStreamLifetime(
458
458
  export async function drainAndShutdown(
459
459
  server: ReturnType<typeof Bun.serve> | undefined,
460
460
  timeoutMs: number,
461
- ): Promise<void> {
461
+ ): Promise<boolean> {
462
462
  const s = server ?? _serverRef;
463
+ let shutdownSucceeded = true;
463
464
  // One absolute budget covers both a pre-existing scoped profile drain and
464
465
  // ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever.
465
466
  const deadline = Date.now() + Math.max(0, timeoutMs);
@@ -491,9 +492,11 @@ export async function drainAndShutdown(
491
492
  // shutdown is usually part of.
492
493
  const stateFlush = await Promise.allSettled([flushResponseState(), flushAntigravityReplay()]);
493
494
  if (stateFlush[0]?.status === "rejected") {
495
+ shutdownSucceeded = false;
494
496
  console.warn("[responses] state flush during shutdown failed");
495
497
  }
496
498
  if (stateFlush[1]?.status === "rejected") {
499
+ shutdownSucceeded = false;
497
500
  console.warn("[antigravity] replay flush during shutdown failed");
498
501
  }
499
502
 
@@ -546,4 +549,5 @@ export async function drainAndShutdown(
546
549
  // never resume admission merely because shutdown cleanup returned.
547
550
  }
548
551
  }
552
+ return shutdownSucceeded;
549
553
  }
@@ -21,6 +21,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../i
21
21
  import {
22
22
  applyIntegrationCoordinated,
23
23
  disableIntegrationCoordinated,
24
+ overwriteIntegrationCoordinated,
24
25
  restoreIntegrationCoordinated,
25
26
  type IntegrationRestoreInput,
26
27
  type IntegrationWriteInput,
@@ -70,7 +71,7 @@ export interface IntegrationJournalEnvelope {
70
71
  export interface IntegrationJournalRow {
71
72
  opId: string;
72
73
  clientId: IntegrationClientId;
73
- kind: "apply" | "disable" | "refresh" | "restore";
74
+ kind: "apply" | "disable" | "refresh" | "restore" | "overwrite";
74
75
  at: string;
75
76
  configPath: string;
76
77
  snapshot: "none" | "stored" | "expired";
@@ -79,6 +80,14 @@ export interface IntegrationJournalRow {
79
80
 
80
81
  export interface IntegrationToggleBody {
81
82
  enabled: boolean;
83
+ /**
84
+ * Opt in to replacing a conflicted block with the one opencodex would write.
85
+ *
86
+ * Absent and `false` behave identically and are the only states a caller
87
+ * reaches by accident, which is the point: the conflict refusal protects work
88
+ * we did not author, so it can only be waived by asking for it by name.
89
+ */
90
+ overwriteConflict?: boolean;
82
91
  }
83
92
 
84
93
  export interface IntegrationRestoreBody {
@@ -472,16 +481,38 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
472
481
  code: "invalid_enabled",
473
482
  }, 400, req, ctx.config);
474
483
  }
484
+ if (parsed.overwriteConflict !== undefined && typeof parsed.overwriteConflict !== "boolean") {
485
+ return jsonResponse({
486
+ error: "overwriteConflict must be a boolean",
487
+ code: "invalid_overwrite_conflict",
488
+ }, 400, req, ctx.config);
489
+ }
490
+ /*
491
+ * Rejected rather than ignored. Disabling a block we do not own is precisely
492
+ * the deletion this subsystem exists to prevent, so a caller sending this
493
+ * combination has misunderstood the field, and silently dropping it would
494
+ * answer 200 for a request whose intent we refused.
495
+ */
496
+ if (parsed.overwriteConflict === true && parsed.enabled === false) {
497
+ return jsonResponse({
498
+ error: "overwriteConflict applies only to enabling an integration",
499
+ code: "invalid_overwrite_conflict",
500
+ }, 400, req, ctx.config);
501
+ }
475
502
 
476
503
  try {
477
504
  const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
478
505
  const result = await runIntegrationMutationFlight(
479
506
  requestedClient,
480
- parsed.enabled ? "apply" : "disable",
507
+ parsed.enabled ? (parsed.overwriteConflict === true ? "overwrite" : "apply") : "disable",
481
508
  input.io?.now ?? Date.now,
482
- () => parsed.enabled
483
- ? applyIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams })
484
- : disableIntegrationCoordinated(input, { lockSeams: integrationMutationTestHooks?.lockSeams }),
509
+ () => {
510
+ const options = { lockSeams: integrationMutationTestHooks?.lockSeams };
511
+ if (!parsed.enabled) return disableIntegrationCoordinated(input, options);
512
+ return parsed.overwriteConflict === true
513
+ ? overwriteIntegrationCoordinated(input, options)
514
+ : applyIntegrationCoordinated(input, options);
515
+ },
485
516
  );
486
517
  if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
487
518
  return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
@@ -25,6 +25,7 @@ import { providerContextCap } from "../../providers/context-cap";
25
25
  import { isVisionReasoningEffort } from "../../reasoning-effort";
26
26
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
27
27
  import type { OcxConfig } from "../../types";
28
+ import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
28
29
  import { fetchAllModels } from "./shared";
29
30
 
30
31
  /**
@@ -47,8 +48,16 @@ export type ManagementModelRow = Partial<CatalogModel> & {
47
48
  * models the GUI's Models tab shows — including this function's `disabled` computation,
48
49
  * which the export core (src/clients/config-export.ts) deliberately does not perform.
49
50
  */
50
- export async function listManagementModelRows(config: OcxConfig): Promise<ManagementModelRow[]> {
51
- const models = await fetchAllModels(config);
51
+ export async function listManagementModelRows(
52
+ config: OcxConfig,
53
+ options: { entitlementWaitMs?: number } = {},
54
+ ): Promise<ManagementModelRow[]> {
55
+ const [models] = await Promise.all([
56
+ fetchAllModels(config),
57
+ ensureCodexEntitlementFreshness(config, {
58
+ waitMs: options.entitlementWaitMs ?? 3_000,
59
+ }),
60
+ ]);
52
61
  const disabled = new Set(config.disabledModels ?? []);
53
62
  // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
54
63
  // from the static supported set so a disabled model stays listed and re-enableable.
@@ -53,6 +53,7 @@ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account
53
53
  import { clearThreadAccountMap } from "../../codex/routing";
54
54
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
55
55
  import { clearModelCache, getProviderDiscoveryStatus } from "../../codex/model-cache";
56
+ import { getCodexModelEntitlementStatus } from "../../codex/model-entitlements";
56
57
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
57
58
  import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget";
58
59
  import { resolveCodexHomeDir } from "../../codex/home";
@@ -474,6 +475,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
474
475
  codexAccountMode: providerCodexAccountMode(name, p),
475
476
  ...(name === "xai" ? { xaiResponsesOptInState: xaiResponsesOptInState(p) } : {}),
476
477
  discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name),
478
+ ...(name === "openai" && isCanonicalOpenAiForwardProvider(p)
479
+ ? { entitlement: getCodexModelEntitlementStatus(config) }
480
+ : {}),
477
481
  })));
478
482
  }
479
483
 
@@ -78,11 +78,11 @@ let restartIo: SystemRestartIo = {};
78
78
  /** Prevents double-scheduling in the 200ms window before drainAndShutdown sets draining. */
79
79
  let restartAccepted = false;
80
80
 
81
- type RestartDrainOutcome = "completed" | "rejected" | "deadline";
81
+ type RestartDrainOutcome = "completed" | "failed" | "rejected" | "deadline";
82
82
  type BoundedSettlementOutcome = "completed" | "rejected" | "deadline";
83
83
 
84
84
  function waitForRestartDrain(
85
- drainPromise: Promise<void>,
85
+ drainPromise: Promise<boolean | void>,
86
86
  deadlineMs: number,
87
87
  now: () => number,
88
88
  scheduleDeadline: NonNullable<SystemRestartIo["scheduleDeadline"]>,
@@ -105,7 +105,7 @@ function waitForRestartDrain(
105
105
  cancelDeadline = scheduleDeadline(() => finish("deadline"), remainingMs);
106
106
  if (settled) cancelDeadline();
107
107
  void drainPromise.then(
108
- () => finish("completed"),
108
+ succeeded => finish(succeeded === false ? "failed" : "completed"),
109
109
  () => finish("rejected"),
110
110
  );
111
111
  });
@@ -382,7 +382,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
382
382
  await completeDeadlineRestartHandoff(io, exitProcess, restartPort, scheduleDeadline);
383
383
  return;
384
384
  }
385
- if (drainOutcome === "rejected") {
385
+ if (drainOutcome === "failed" || drainOutcome === "rejected") {
386
386
  // drainAndShutdown stops the listener in finally. Even if ancillary cleanup
387
387
  // rejects, an accepted restart must still reach replacement or terminal exit.
388
388
  console.warn("Drain-and-restart cleanup failed; continuing terminal restart handoff");
@@ -422,7 +422,7 @@ export function acceptSystemRestart(io: SystemRestartIo = restartIo): {
422
422
  return;
423
423
  }
424
424
  (io.markRecycling ?? markRecyclingForExit)();
425
- exitProcess(0);
425
+ exitProcess(drainOutcome === "failed" || drainOutcome === "rejected" ? 1 : 0);
426
426
  }, 200);
427
427
  }
428
428
 
@@ -256,10 +256,46 @@ export async function handleManagementAPI(
256
256
  if (routed) return routed;
257
257
 
258
258
  if (url.pathname === "/api/stop" && req.method === "POST") {
259
- const { restoreNativeCodexAsync } = await import("../codex/inject");
260
- const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service");
259
+ const { installedServiceRespawnRisk, stopServiceIfInstalledDetailed, isServiceOwnershipError } = await import("../service");
260
+ // `ocx stop` performs its own shared teardown AFTER verifying the scheduler did not
261
+ // respawn the proxy (#3008). Without this the child restores native Codex and strips
262
+ // the Grok fence here, so a survivor found moments later has already had the shared
263
+ // config pulled out from under it — and the parent's `ownershipBlocked` guard can
264
+ // only prevent a second, redundant teardown. A direct caller sends nothing and keeps
265
+ // the self-contained behaviour.
266
+ //
267
+ // The query flag alone is not enough to hand over the obligation: any authenticated
268
+ // caller could set it and simply exit, leaving client config pointed at a proxy that
269
+ // no longer exists. Honour the deferral only when the caller left a pending-teardown
270
+ // receipt on disk, which a later stop/update can find and finish.
271
+ // Decide BEFORE touching the manager. Stopping the Task Scheduler task and then
272
+ // refusing left the proxy running with its manager stopped — worse than either
273
+ // outcome. This process cannot verify its own post-exit respawn window; only the
274
+ // receipt-backed parent `ocx stop` can, which is what the deferral exists for.
275
+ const { deferralMatchesReceipt } = await import("../config/pending-teardown");
276
+ const { deferralHonored, performStopTeardown } = await import("./stop-teardown");
277
+ const holdsReceipt = deferralHonored(url, deferralMatchesReceipt);
278
+ const respawnRisk = holdsReceipt ? "none" : installedServiceRespawnRisk();
279
+ if (respawnRisk === "respawnable") {
280
+ return jsonResponse({
281
+ success: false,
282
+ code: "respawnable_service",
283
+ message: "This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed.",
284
+ }, 409, req, config);
285
+ }
286
+ if (respawnRisk === "unknown") {
287
+ // Do NOT send them to `ocx stop`: it maps the same unanswerable probe to a stop
288
+ // failure, so that advice would be a loop. The scheduler query itself is what needs
289
+ // fixing (#3008).
290
+ return jsonResponse({
291
+ success: false,
292
+ code: "service_state_unknown",
293
+ message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Nothing was changed. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
294
+ }, 409, req, config);
295
+ }
296
+ let serviceStop: import("../service").ServiceStopOutcome;
261
297
  try {
262
- stopServiceIfInstalled();
298
+ serviceStop = stopServiceIfInstalledDetailed();
263
299
  } catch (err) {
264
300
  if (isServiceOwnershipError(err)) {
265
301
  // The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn
@@ -269,20 +305,43 @@ export async function handleManagementAPI(
269
305
  }
270
306
  throw err;
271
307
  }
272
- const restore = await restoreNativeCodexAsync();
308
+ // The boolean helper collapses "failed" into the same false as "no service installed",
309
+ // so this route used to tear down shared config and exit while a manager that refused
310
+ // to stop was still there to respawn the proxy (#3008).
311
+ if (serviceStop === "failed") {
312
+ return jsonResponse({
313
+ success: false,
314
+ message: "The installed service manager did not stop; it may respawn the proxy. Shared client config was left alone. Run `ocx stop` from the home that owns the service.",
315
+ }, 409, req, config);
316
+ }
317
+ if (serviceStop === "state-unknown") {
318
+ // Same case, same remedy as the pre-check: the query is what needs fixing.
319
+ return jsonResponse({
320
+ success: false,
321
+ code: "service_state_unknown",
322
+ message: "The Windows Task Scheduler state could not be read, so this proxy cannot tell whether a wrapper would respawn it. Shared client config was left alone. Run `ocx service status` to see the query error, repair Task Scheduler access, then retry.",
323
+ }, 409, req, config);
324
+ }
325
+ // The pre-check above already refused the respawnable case without a receipt, so
326
+ // reaching here with one means the parent owns the verification.
273
327
  // Both managed configs come down together on an explicit teardown. The daemon's own
274
328
  // syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
275
- // which is exactly why an intentional stop has to do it here.
276
- const { stripGrokConfig } = await import("../grok/inject");
277
- const grok = stripGrokConfig();
329
+ // which is exactly why an intentional stop has to do it here — unless the caller is
330
+ // `ocx stop`, which does it itself once the proxy is proven down.
331
+ const teardown = await performStopTeardown(url, { ownsReceipt: deferralMatchesReceipt });
278
332
  setTimeout(async () => {
279
- await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000);
280
- process.exit(0);
333
+ let shutdownSucceeded = false;
334
+ try {
335
+ shutdownSucceeded = await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000);
336
+ } catch {
337
+ console.warn("[opencodex] shutdown drain failed");
338
+ }
339
+ // A drained proxy whose shared teardown failed did not finish the job. Exiting 0
340
+ // told a supervisor the stop was clean while native Codex or the Grok fence was
341
+ // still pointed at this process (#3008).
342
+ process.exit(shutdownSucceeded && teardown.success ? 0 : 1);
281
343
  }, 200);
282
- const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
283
- return jsonResponse(restore.success
284
- ? { success: true, message: `Proxy stopping, native Codex restored.${grokNote}` }
285
- : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` });
344
+ return jsonResponse(teardown);
286
345
  }
287
346
 
288
347
  if (url.pathname.startsWith("/api/native-main-profiles")) {
@@ -175,7 +175,24 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[])
175
175
  return `${text}${payload.slice(last)}`;
176
176
  }
177
177
 
178
- export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
178
+ /**
179
+ * The routing header codex-rs writes above a delegated agent payload.
180
+ *
181
+ * `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK --
182
+ * recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the
183
+ * parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector
184
+ * decides "unreadable" by stripping the envelope and asking whether any plaintext
185
+ * survives, so an envelope shape it does not recognise counts as surviving text: a
186
+ * `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded
187
+ * verbatim.
188
+ *
189
+ * Widening the strip is not the same as widening recovery. Recovery decrypts, and
190
+ * decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a
191
+ * payload the parent's session may have no right to read. This only lets the proxy
192
+ * NOTICE that what it is about to forward is unreadable ciphertext, which is what the
193
+ * report asks for: fail closed with a structured error rather than paste the token.
194
+ */
195
+ export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
179
196
 
180
197
  // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
181
198
  // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
@@ -233,8 +233,9 @@ function applyReconciliationOutcome(
233
233
  /**
234
234
  * Execute the existing fixed CLI installer outside the proxy event loop.
235
235
  *
236
- * Repair mode (`options.repair`) runs `ocx service repair` asset rewrite + restart
237
- * without Task Scheduler re-registration, so it must not enter the UAC elevation path.
236
+ * Repair mode (`options.repair`) runs `ocx service repair`. A stale definition may be
237
+ * re-registered and elevate inside repair; this wrapper must not retry it through the separate
238
+ * fresh-install UAC path.
238
239
  *
239
240
  * After an elevation request timeout the lock becomes `indeterminate` until the
240
241
  * original elevated transaction completes and is reconciled. A process restart
@@ -0,0 +1,84 @@
1
+ import type { CodexNativeRestoreResult } from "../codex/inject";
2
+ import { deferralMatchesReceipt } from "../config/pending-teardown";
3
+
4
+ /**
5
+ * Shared-teardown decision and execution for `POST /api/stop` (#3008).
6
+ *
7
+ * Lives outside the route handler because the handler schedules `process.exit` 200ms
8
+ * after it answers, which makes it uncallable from a test. The part worth testing is
9
+ * exactly this: whether the deferral is honoured, whether the restores actually run, and
10
+ * whether the response says what happened.
11
+ */
12
+
13
+ export type GrokStripResult = { ok: boolean; changed: boolean; message: string };
14
+
15
+ export type StopTeardownIo = {
16
+ /** Does the nonce this request carries name a readable obligation on disk? */
17
+ ownsReceipt?: (nonce: string | null) => boolean;
18
+ restoreNativeCodex?: () => Promise<CodexNativeRestoreResult>;
19
+ stripGrok?: () => GrokStripResult;
20
+ };
21
+
22
+ export type StopTeardownBody = {
23
+ success: boolean;
24
+ message: string;
25
+ sharedTeardown: "deferred" | "performed";
26
+ };
27
+
28
+ /**
29
+ * A deferral is honoured only when the caller proves it owns the obligation.
30
+ *
31
+ * The query flag names an intention; the receipt is the obligation. Without the second
32
+ * half any authenticated caller could ask the proxy to skip teardown and then exit,
33
+ * leaving native Codex and the Grok fence pointed at a proxy that no longer exists.
34
+ *
35
+ * "A receipt exists" is not that proof either: it would let any caller ride on another
36
+ * stop's outstanding obligation and get a deferral it never owns. The request has to name
37
+ * the receipt's nonce, which only the process that wrote it (and anything that can read
38
+ * the 0700 config directory, which is already the trust boundary for the admin token)
39
+ * can know.
40
+ */
41
+ export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) => boolean): boolean {
42
+ if (url.searchParams.get("deferSharedTeardown") !== "1") return false;
43
+ return ownsReceipt(url.searchParams.get("teardownNonce"));
44
+ }
45
+
46
+ /** Run (or skip) the shared teardown and describe the outcome truthfully. */
47
+ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise<StopTeardownBody> {
48
+ const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt;
49
+ if (deferralHonored(url, ownsReceipt)) {
50
+ // Not "native Codex restored": nothing was restored here, and claiming otherwise
51
+ // would be a success message the operator cannot verify.
52
+ return {
53
+ success: true,
54
+ message: "Proxy stopping; shared teardown deferred to the stopping client.",
55
+ sharedTeardown: "deferred",
56
+ };
57
+ }
58
+ const restore = io.restoreNativeCodex
59
+ ? await io.restoreNativeCodex()
60
+ : await (await import("../codex/inject")).restoreNativeCodexAsync();
61
+ const grok = io.stripGrok
62
+ ? io.stripGrok()
63
+ : (await import("../grok/inject")).stripGrokConfig();
64
+ // Success means BOTH halves came down. Deciding it from the native restore alone and
65
+ // appending the Grok text let a caller read `success: true` while the fence still
66
+ // pointed at a proxy that was exiting — the teardown reported done with half of it
67
+ // undone (#3008).
68
+ const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
69
+ if (restore.success && grok.ok) {
70
+ return { success: true, message: "Proxy stopping, native Codex restored.", sharedTeardown: "performed" };
71
+ }
72
+ if (restore.success) {
73
+ return {
74
+ success: false,
75
+ message: `Proxy stopping, native Codex restored, but the Grok fence was not removed:${grokNote} Run \`ocx restore\`.`,
76
+ sharedTeardown: "performed",
77
+ };
78
+ }
79
+ return {
80
+ success: false,
81
+ message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}`,
82
+ sharedTeardown: "performed",
83
+ };
84
+ }