@bitkyc08/opencodex 2.38.0 → 2.39.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 (81) hide show
  1. package/bin/ocx.mjs +58 -6
  2. package/gui/dist/assets/index-D-lchsPw.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/baseten.svg +13 -0
  6. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  7. package/gui/dist/provider-icons/cerebras.svg +26 -0
  8. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  9. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  10. package/gui/dist/provider-icons/featherless.svg +4 -0
  11. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  12. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  13. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  14. package/gui/dist/provider-icons/kilo.svg +13 -0
  15. package/gui/dist/provider-icons/litellm.svg +1 -0
  16. package/gui/dist/provider-icons/minimax.svg +1 -0
  17. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  18. package/gui/dist/provider-icons/nebius.svg +1 -0
  19. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  20. package/gui/dist/provider-icons/nous.svg +149 -0
  21. package/gui/dist/provider-icons/novita.svg +32 -0
  22. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  23. package/gui/dist/provider-icons/parallel.svg +13 -0
  24. package/gui/dist/provider-icons/sambanova.svg +276 -0
  25. package/gui/dist/provider-icons/scaleway.svg +11 -0
  26. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  27. package/gui/dist/provider-icons/synthetic.svg +12 -0
  28. package/gui/dist/provider-icons/together.svg +18 -0
  29. package/gui/dist/provider-icons/umans.svg +30 -0
  30. package/gui/dist/provider-icons/venice.svg +165 -0
  31. package/gui/dist/provider-icons/vultr.svg +15 -0
  32. package/gui/dist/provider-icons/zai.svg +218 -0
  33. package/gui/dist/provider-icons/zenmux.svg +1 -0
  34. package/package.json +1 -1
  35. package/src/adapters/cursor/live-models.ts +1 -0
  36. package/src/adapters/openai-responses.ts +72 -10
  37. package/src/bridge.ts +15 -15
  38. package/src/cli/dispatch.ts +54 -18
  39. package/src/cli/index.ts +420 -30
  40. package/src/cli/integrations.ts +24 -2
  41. package/src/cli/uninstall-plan.ts +86 -0
  42. package/src/codex/account-store.ts +121 -8
  43. package/src/codex/auth-api.ts +202 -33
  44. package/src/codex/catalog/provider-fetch.ts +76 -9
  45. package/src/codex/history-job.ts +10 -0
  46. package/src/codex/history-manifest.ts +35 -2
  47. package/src/codex/history-provider.ts +196 -19
  48. package/src/codex/history-worker.ts +3 -0
  49. package/src/codex/quota-401-recovery.ts +190 -0
  50. package/src/codex/quota-recovery-timing.ts +28 -0
  51. package/src/codex/quota.ts +6 -0
  52. package/src/codex/routing.ts +68 -16
  53. package/src/codex/subagent-model-fallback.ts +4 -1
  54. package/src/config/pending-teardown-names.d.mts +8 -0
  55. package/src/config/pending-teardown-names.mjs +69 -0
  56. package/src/config/pending-teardown.ts +286 -0
  57. package/src/generated/compatibility-version.json +84 -32
  58. package/src/integrations/journal.ts +12 -1
  59. package/src/integrations/writer.ts +82 -7
  60. package/src/lib/process-control.ts +30 -5
  61. package/src/lib/state-store-registrations.ts +8 -0
  62. package/src/oauth/index.ts +220 -13
  63. package/src/oauth/store.ts +220 -18
  64. package/src/responses/spill-store.ts +20 -0
  65. package/src/responses/state.ts +288 -2
  66. package/src/server/management/integration-routes.ts +36 -5
  67. package/src/server/management-api.ts +66 -12
  68. package/src/server/responses/encrypted-payload.ts +18 -1
  69. package/src/server/stop-teardown.ts +84 -0
  70. package/src/service.ts +155 -20
  71. package/src/update/index.ts +36 -4
  72. package/src/update/proxy-liveness-probe.d.mts +6 -0
  73. package/src/update/proxy-liveness-probe.mjs +84 -0
  74. package/src/update/stop-contract.d.mts +2 -0
  75. package/src/update/stop-contract.mjs +15 -0
  76. package/src/update/stop-decision.d.mts +10 -0
  77. package/src/update/stop-decision.mjs +34 -0
  78. package/src/vision/eligibility.ts +19 -1
  79. package/src/vision/index.ts +4 -18
  80. package/gui/dist/assets/index-C14iCj_Q.js +0 -112
  81. package/gui/dist/assets/index-D7PIz7_g.css +0 -1
package/src/cli/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject";
4
4
  import { stripGrokConfig } from "../grok/inject";
5
+ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs";
5
6
  import {
6
7
  describeHistoryJobFailure,
7
8
  resolveCodexHistoryJobTarget,
@@ -25,7 +26,16 @@ import {
25
26
  writePid,
26
27
  writeRuntimePort,
27
28
  } from "../config/process-state";
29
+ import {
30
+ claimPendingTeardown,
31
+ clearPendingTeardown,
32
+ isPendingTeardownAbandoned,
33
+ listPendingTeardowns,
34
+ pendingTeardownPathFor,
35
+ quarantinePendingTeardown,
36
+ } from "../config/pending-teardown";
28
37
  import { collectStatus, unusedProxyWarningLines } from "./status";
38
+ import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
29
39
  import { takeFlag } from "./runtime-api";
30
40
 
31
41
  import {
@@ -44,9 +54,9 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-li
44
54
  import { createReadinessGate } from "../server/readiness";
45
55
  import { runReady, type ReadyArgs } from "./ready";
46
56
  import { runCli } from "./root";
47
- import { ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control";
57
+ import { isProcessAlive, ProxyOwnershipRefusedError, stopProxy } from "../lib/process-control";
48
58
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
49
- import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
59
+ import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service";
50
60
  import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health";
51
61
  import { drainAndShutdown, isRecyclingForExit, startServer } from "../server";
52
62
  import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env";
@@ -230,7 +240,14 @@ async function handleStart(options: { block?: boolean } = {}) {
230
240
  const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
231
241
  if (present) assertNotAdminToken(present);
232
242
  const requestedPort = parsePortOption();
233
- const owner = await findProxyOwnerBeforeJournalRecovery();
243
+ // Always probe the configured port, even when both state files are absent. A
244
+ // fallback-port sibling overwrites the pid/runtime records when it starts and
245
+ // removes them on its own shutdown, so their absence proves nothing about the
246
+ // configured port. Without the probe, `start` shadowed a healthy proxy with an
247
+ // ephemeral-port copy and re-pointed client config at the copy; the next sibling
248
+ // shutdown then left no runtime record for discovery at all. `handleEnsure`
249
+ // already passes this; `handleStart` is the path that did not.
250
+ const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true });
234
251
  if (owner.live) {
235
252
  // Service-wrapper context (opencodex-service.cmd `:loop`): a healthy proxy from
236
253
  // ANY source means the requested port is already served. Exit 0 so the wrapper's
@@ -631,17 +648,35 @@ async function handleRestartStartWhenStopped(): Promise<boolean | "skipped"> {
631
648
  return handleEnsure({ existingIsSuccess: false });
632
649
  }
633
650
 
634
- async function restoreSharedClientStateAfterStop(): Promise<boolean> {
635
- let restored = true;
651
+ /**
652
+ * Restore shared client state after a stop.
653
+ *
654
+ * Returns the two failure kinds separately. `historyOnly` means teardown succeeded and
655
+ * only Codex history metadata could not be finalized: the proxy is down, the service is
656
+ * stopped, and a manifest is waiting for review. `other` means something that actually
657
+ * removes state a client depends on.
658
+ *
659
+ * The distinction exists because `ocx update` must proceed for the first and abort for the
660
+ * second, and it can only see an exit code (#3008).
661
+ */
662
+ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; other: boolean }> {
663
+ let historyOnly = false;
664
+ let other = false;
636
665
  try {
637
666
  const result = await restoreNativeCodexAsync();
638
667
  if (result.success) console.log(`↩️ ${result.message}`);
639
668
  else {
640
- restored = false;
669
+ // Codex history is the one restore whose failure leaves the runtime consistent: the
670
+ // manifest is retained and the routed metadata is untouched. Config and catalog are
671
+ // not — a client reads those, so their failure is a real teardown failure.
672
+ const artifacts = result.artifacts;
673
+ const configOrCatalogFailed = artifacts.config.state === "failed" || artifacts.catalog.state === "failed";
674
+ if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true;
675
+ else other = true;
641
676
  console.error(`⚠️ ${result.message}`);
642
677
  }
643
678
  } catch (error) {
644
- restored = false;
679
+ other = true;
645
680
  console.error(`⚠️ Native Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
646
681
  }
647
682
 
@@ -649,16 +684,58 @@ async function restoreSharedClientStateAfterStop(): Promise<boolean> {
649
684
  try {
650
685
  const grok = stripGrokConfig();
651
686
  if (grok.changed) console.log(`↩️ ${grok.message}`);
652
- else if (!grok.ok) { restored = false; console.error(`⚠️ ${grok.message}`); }
687
+ else if (!grok.ok) { other = true; console.error(`⚠️ ${grok.message}`); }
653
688
  } catch (error) {
654
- restored = false;
689
+ other = true;
655
690
  console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`);
656
691
  }
657
- return restored;
692
+ return { historyOnly, other };
658
693
  }
659
694
 
660
695
  async function handleStop() {
696
+ // The receipt must name the endpoint the owner was stopping — an obligation nobody can
697
+ // locate cannot be proven discharged. Only the runtime record knows it; a proxy started
698
+ // with an explicit --port is not on the configured one.
699
+ const endpointOf = (runtime: { port: number; hostname?: string } | null): { hostname: string; port: number } | null =>
700
+ runtime?.port ? { hostname: runtime.hostname ?? "127.0.0.1", port: runtime.port } : null;
701
+ // Last-resort endpoint for a receipt: the address this home is configured to serve on,
702
+ // which is what a later recovery probe would ask about anyway.
703
+ const configuredEndpoint = (): { hostname: string; port: number } => {
704
+ try {
705
+ const config = loadConfig();
706
+ return {
707
+ hostname: config.hostname ?? "127.0.0.1",
708
+ port: typeof config.port === "number" && config.port > 0 ? config.port : 10100,
709
+ };
710
+ } catch {
711
+ return { hostname: "127.0.0.1", port: 10100 };
712
+ }
713
+ };
714
+ // Only a definitive "nothing is answering" authorizes finishing somebody else's
715
+ // abandoned teardown. The tri-state probe distinguishes that from "we could not tell"
716
+ // (timeout, a listener that withholds /healthz), which `findLiveProxy` collapses into
717
+ // the same null (#3008).
718
+ const abandonedTeardownIsSafeToFinish = async (
719
+ endpoint: { hostname: string; port: number } | null,
720
+ ): Promise<boolean> => {
721
+ // The endpoint has to come from the receipt. A crashed owner usually leaves no
722
+ // runtime-port record, and the configured port is the wrong question for a proxy
723
+ // started with an explicit --port: it refuses while the live one keeps serving.
724
+ // An obligation that cannot name its endpoint cannot be proven discharged.
725
+ if (!endpoint) return false;
726
+ try {
727
+ const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs");
728
+ return probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead";
729
+ } catch {
730
+ // A probe that could not run is not evidence of absence.
731
+ return false;
732
+ }
733
+ };
661
734
  let stopFailed = false;
735
+ let historyOnlyFailure = false;
736
+ // Only Task Scheduler respawns after a successful stop (#764), so only it earns the
737
+ // restart-window wait; launchd, systemd and WinSW are down when they say so.
738
+ let schedulerCanRespawn = false;
662
739
  let stoppedService = false;
663
740
  // An ownership mismatch means the service manager was never even contacted: the installed
664
741
  // service is still live and will respawn the proxy. Tearing down SHARED state in that
@@ -666,9 +743,81 @@ async function handleStop() {
666
743
  // service — the exact failure this flag prevents. A plain stop failure is different: we
667
744
  // tried, so local teardown still proceeds.
668
745
  let ownershipBlocked = false;
746
+ // Deferring shared teardown to this process is an obligation, so record it on disk
747
+ // before asking for it (#3008). A parent that dies mid-stop would otherwise leave the
748
+ // client config routed at a proxy that is already gone, with nothing to find later.
749
+ //
750
+ // `inheritedTeardowns` is the inverse case: PREVIOUS stops that left obligations
751
+ // unfinished. Snapshot them BEFORE this run claims anything, so this run's own receipt
752
+ // is never mistaken for one it inherited.
753
+ const inheritedTeardowns = listPendingTeardowns()
754
+ .filter(read => isPendingTeardownAbandoned(read, isProcessAlive));
755
+ let teardownNonce: string | undefined;
756
+ const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => {
757
+ if (teardownNonce) return;
758
+ try {
759
+ teardownNonce = claimPendingTeardown(endpoint, endpointSource).nonce;
760
+ } catch (err) {
761
+ // Without a receipt the proxy performs its own teardown, which is the pre-#3008
762
+ // behaviour: correct for every backend that cannot respawn, and merely early for
763
+ // Task Scheduler. Losing the deferral is far better than losing the stop.
764
+ console.warn(`⚠️ Could not record the deferred-teardown receipt: ${err instanceof Error ? err.message : String(err)}`);
765
+ }
766
+ };
767
+ /**
768
+ * One stop target, used for BOTH the receipt and the request.
769
+ *
770
+ * Deriving them separately meant the receipt could name a different endpoint than the
771
+ * one actually contacted, and a proxy with no runtime record got no receipt at all —
772
+ * silently reopening the parent-crash window on the path where the stop is a hard kill
773
+ * and no child teardown runs at all.
774
+ *
775
+ * So the caller supplies whatever endpoint it already discovered: the orphan path knows
776
+ * one from `findLiveProxy` even when the runtime record is gone.
777
+ *
778
+ * When nothing resolves, the graceful request cannot be made at all — `stopProxy` goes
779
+ * straight to the kill ladder, no child teardown runs, and there is no receipt to leave
780
+ * behind. A warning does not make that durable, so the receipt is claimed FIRST against
781
+ * the endpoint this process would restore anyway. It is the configured listen address,
782
+ * which is the same address every recovery probe would ask about, and an obligation
783
+ * recorded against it is strictly better than none: at worst the probe cannot confirm
784
+ * A guessed endpoint is NOT evidence, so the receipt records which kind it holds: a
785
+ * guessed one fails closed into manual recovery rather than letting a later probe read
786
+ * "the configured port refuses" as proof that the right proxy is down.
787
+ */
788
+ const stopWithDeferral = async (pid: number, discovered?: { hostname: string; port: number } | null): Promise<void> => {
789
+ // Resolve ONCE. Reading the runtime record twice let the receipt name the configured
790
+ // guess while the request went to a runtime endpoint that appeared in between.
791
+ const exact = discovered ?? endpointOf(readRuntimePort(pid));
792
+ claimTeardown(exact ?? configuredEndpoint(), exact ? "exact" : "guessed");
793
+ await stopProxy(pid, {
794
+ deferSharedTeardownNonce: teardownNonce,
795
+ // Only an exact endpoint may direct the request; the configured fallback is a guess
796
+ // good enough to record an obligation against, not to POST a stop to.
797
+ runtimeEndpoint: exact ?? undefined,
798
+ });
799
+ };
669
800
  try {
670
- stoppedService = stopServiceIfInstalled();
671
- if (stoppedService) console.log("🛑 Service manager stopped (won't respawn).");
801
+ const serviceStop = stopServiceIfInstalledDetailed();
802
+ stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable";
803
+ schedulerCanRespawn = serviceStop === "stopped-respawnable";
804
+ // No "won't respawn" claim here: a stopped Task Scheduler can still respawn through
805
+ // its wrapper, which the verification below is what actually settles.
806
+ if (stoppedService) console.log("🛑 Service manager stopped.");
807
+ if (serviceStop === "failed") {
808
+ // A manager that would not stop can respawn the proxy. That is a real stop failure,
809
+ // not a history-only one, and an update must not replace files over it (#3008).
810
+ stopFailed = true;
811
+ console.error("❌ The installed service manager did not stop; it may respawn the proxy.");
812
+ }
813
+ if (serviceStop === "state-unknown") {
814
+ // Nothing refused to stop — the scheduler state could not be READ. Saying "did not
815
+ // stop" sends the operator looking for the wrong problem, and `/api/stop` answers
816
+ // the same case with service_state_unknown.
817
+ stopFailed = true;
818
+ console.error("❌ The Windows Task Scheduler state could not be read, so this stop cannot tell whether a wrapper would respawn the proxy.");
819
+ console.error(" Run 'ocx service status' to see the query error, repair Task Scheduler access, then retry.");
820
+ }
672
821
  } catch (err) {
673
822
  if (isServiceOwnershipError(err)) {
674
823
  ownershipBlocked = true;
@@ -685,7 +834,11 @@ async function handleStop() {
685
834
  try {
686
835
  // Graceful-first (management-API drain) — on Windows this is the only path where
687
836
  // the proxy's shutdown handlers actually run; taskkill /F is the fallback inside.
688
- await stopProxy(pid);
837
+ // Shared teardown is deferred to this process: it happens after the respawn
838
+ // verification below, so a survivor does not get its client config pulled first.
839
+ // The receipt goes down first — the proxy honours the deferral only when it can
840
+ // see one, so an unrecordable claim degrades to the child doing its own teardown.
841
+ await stopWithDeferral(pid);
689
842
  console.log(`✅ Proxy (PID ${pid}) stopped.`);
690
843
  removePid(pid);
691
844
  removeRuntimePort(pid);
@@ -713,7 +866,9 @@ async function handleStop() {
713
866
  const live = await findLiveProxy();
714
867
  if (live?.pid) {
715
868
  try {
716
- await stopProxy(live.pid);
869
+ // The probe already found where it answers, and on this path the runtime record is
870
+ // typically what went missing in the first place.
871
+ await stopWithDeferral(live.pid, { hostname: live.hostname ?? "127.0.0.1", port: live.port });
717
872
  console.log(`✅ Proxy (PID ${live.pid}) stopped.`);
718
873
  } catch (err) {
719
874
  stopFailed = true;
@@ -725,6 +880,17 @@ async function handleStop() {
725
880
  console.error(" Skipping shared teardown (native Codex restore, Grok config): the foreign proxy is still running.");
726
881
  }
727
882
  }
883
+ } else if (live) {
884
+ // Identity-confirmed live, but no PID this process can kill: a legacy /healthz that
885
+ // reports no pid, or a pid that failed verification. Treating that as "nothing is
886
+ // running" purges the state records and then restores shared client config out from
887
+ // under a proxy that is still serving — the exact failure the deferral exists to
888
+ // prevent, arrived at from the other direction.
889
+ stopFailed = true;
890
+ ownershipBlocked = true;
891
+ console.error(`❌ A proxy is answering on port ${live.port}, but no process id could be resolved for it, so it cannot be stopped from here.`);
892
+ console.error(" Skipping shared teardown: restoring client config while it serves would leave both pointing at each other.");
893
+ console.error(" Stop it from the home that started it, or end the process manually, then rerun 'ocx stop'.");
728
894
  } else if (!stoppedService) {
729
895
  console.log("No running proxy found.");
730
896
  }
@@ -739,16 +905,159 @@ async function handleStop() {
739
905
  // Environment ownership is independent from service ownership. Always roll back
740
906
  // current-home variables; the helper refuses foreign markers on its own.
741
907
  try { revertSystemEnv(); } catch { /* best-effort */ }
742
- if (!ownershipBlocked) {
743
- if (!await restoreSharedClientStateAfterStop()) stopFailed = true;
908
+ // A stopped Windows scheduler is not a proven-down proxy. `killWindowsSchedulerWrappers`
909
+ // is explicitly best-effort and the `:loop` wrapper respawns its child after ~5s, so an
910
+ // immediate probe can see a dead interval and an update can start replacing files right
911
+ // before the proxy comes back. Poll across the restart window before this stop is allowed
912
+ // to report anything but failure (#3008) — and ONLY for that backend, since making every
913
+ // launchd and systemd stop wait seven seconds would be a regression in ordinary use.
914
+ if (schedulerCanRespawn && !ownershipBlocked) {
915
+ const survivor = await proxyStillLiveAfterStop({ canRespawn: true });
916
+ if (survivor) {
917
+ stopFailed = true;
918
+ console.error(`❌ A proxy is still listening on port ${survivor.port} after the service stop; it is being respawned.`);
919
+ console.error(" Skipping shared teardown: restoring client config while the proxy runs leaves both pointing at each other.");
920
+ ownershipBlocked = true;
921
+ }
744
922
  }
745
- // Set the code rather than exiting inline: `restart` and the tray coordinator call this
746
- // function and need it to RETURN so they can decide what to do next.
923
+ // Recovering somebody else's abandoned obligation is not the same act as finishing this
924
+ // run's own. This run stopped a proxy and verified the result; the inherited case has no
925
+ // such evidence, and `findLiveProxy` returning null covers a timeout and a malformed
926
+ // answer as well as a genuinely dead port. Restoring client config under a proxy that is
927
+ // merely unresponsive is exactly the failure the deferral exists to prevent.
928
+ //
929
+ // So an inherited obligation this run did not claim GATES the restore itself, rather
930
+ // than only labelling it: without a definitive "dead" from the tri-state probe, the
931
+ // restore does not run, the receipt stays for the next stop, and the stop fails. A
932
+ // warning that lets the restore happen anyway is not a gate.
933
+ //
934
+ // An UNREADABLE obligation is a third case. It names no endpoint, so nothing can ever
935
+ // prove its proxy down. It is NOT waved through: it fails this stop and is set aside
936
+ // only afterwards, so the operator gets an explicit manual step instead of a silent
937
+ // restore backed by no evidence. Setting it aside is still necessary — left in place it
938
+ // makes both updater gates run a stop that fails on it every time, which is an update
939
+ // that can never proceed.
940
+ //
941
+ // Inherited obligations are evaluated whether or not this run claimed its own. A stop
942
+ // that finds a live proxy used to skip them entirely, so older abandoned receipts
943
+ // accumulated forever while each run cleared only its own nonce.
944
+ const recoveredNonces: string[] = [];
945
+ const unreadable: { nonce: string }[] = [];
946
+ let inheritedBlocks = false;
947
+ if (inheritedTeardowns.length > 0 && !ownershipBlocked) {
948
+ for (const read of inheritedTeardowns) {
949
+ if (read.state === "unscannable") {
950
+ // No file, no nonce: nothing to quarantine and nothing to remove. The home itself
951
+ // may be hiding an obligation, so block and ask for the directory to be fixed.
952
+ inheritedBlocks = true;
953
+ stopFailed = true;
954
+ console.error(`❌ ${read.detail}, so this stop cannot tell whether a shared teardown is still owed.`);
955
+ console.error(" Skipping shared teardown. Fix access to the opencodex home, then rerun 'ocx stop'.");
956
+ continue;
957
+ }
958
+ if (read.state === "invalid") {
959
+ unreadable.push(read);
960
+ inheritedBlocks = true;
961
+ stopFailed = true;
962
+ console.error(`❌ A pending-teardown receipt could not be read (${read.detail}).`);
963
+ console.error(" It names no endpoint, so this stop cannot prove the proxy it belonged to is down.");
964
+ console.error(" Confirm no proxy is running, then rerun 'ocx stop' to complete the teardown.");
965
+ continue;
966
+ }
967
+ if (read.receipt.endpointSource === "guessed") {
968
+ // The recorded address is the configured one, not the one that stop contacted. A
969
+ // proxy on an explicit --port can be respawned there while this address refuses,
970
+ // so "dead" here proves nothing and must not authorize a restore.
971
+ inheritedBlocks = true;
972
+ stopFailed = true;
973
+ console.error("❌ A shared teardown from an earlier stop is outstanding, but that stop could not record the address it was stopping.");
974
+ console.error(` Only the configured address (${read.receipt.endpoint.hostname}:${read.receipt.endpoint.port}) was recorded, which cannot prove the right proxy is down.`);
975
+ console.error(` Confirm no proxy is running, then run 'ocx restore' and remove ${pendingTeardownPathFor(read.receipt.nonce)}.`);
976
+ continue;
977
+ }
978
+ if (await abandonedTeardownIsSafeToFinish(read.receipt.endpoint)) {
979
+ recoveredNonces.push(read.receipt.nonce);
980
+ continue;
981
+ }
982
+ inheritedBlocks = true;
983
+ stopFailed = true;
984
+ console.error(`❌ A shared teardown from an earlier stop is still outstanding, and the proxy on ${read.receipt.endpoint.hostname}:${read.receipt.endpoint.port} could not be confirmed down.`);
985
+ console.error(" Skipping shared teardown: restoring client config under a proxy that may still be running is what the deferral exists to prevent.");
986
+ console.error(" The obligation is preserved; retry once the proxy is confirmed stopped.");
987
+ }
988
+ }
989
+ const restoreBlocked = ownershipBlocked || inheritedBlocks;
990
+ if (!restoreBlocked) {
991
+ if (recoveredNonces.length > 0) {
992
+ // A previous deferred stop died before restoring, and the probe says its endpoint is
993
+ // not answering. That is the whole point of leaving the receipt behind.
994
+ console.log("↩️ Finishing a shared teardown left unfinished by an earlier stop.");
995
+ }
996
+ const restore = await restoreSharedClientStateAfterStop();
997
+ if (restore.other) stopFailed = true;
998
+ else if (restore.historyOnly) historyOnlyFailure = true;
999
+ // The obligation is discharged whether or not history metadata finalized: config and
1000
+ // catalog are what a client reads, and `restore.other` already fails the stop.
1001
+ //
1002
+ // Each nonce names its own file, so a clear can only ever remove the obligation it
1003
+ // names — never one a concurrent stop wrote. Both this run's claim and every inherited
1004
+ // receipt it proved discharged are released together.
1005
+ if (!restore.other) {
1006
+ const discharged = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces;
1007
+ for (const nonce of discharged) {
1008
+ // A receipt that survives its discharge re-triggers recovery forever, so a failed
1009
+ // removal is surfaced rather than swallowed.
1010
+ if (!clearPendingTeardown(nonce)) {
1011
+ stopFailed = true;
1012
+ console.error(`❌ The shared teardown finished, but its receipt could not be removed: ${pendingTeardownPathFor(nonce)}`);
1013
+ console.error(" Remove it manually; otherwise every later stop and update will try to recover it again.");
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ // Set an unreadable receipt aside only AFTER the outcome is known. Renaming it earlier
1019
+ // would take it out of the recovery loop while the restore it stood for had not run.
1020
+ //
1021
+ // Setting aside is NOT discharging. The renamed file still counts as an outstanding
1022
+ // obligation (`isAnyTeardownObligationFileName`), so both updaters keep refusing to
1023
+ // install until an operator removes it — the rename only stops every later stop from
1024
+ // re-reading the same garbage. Skipped under `ownershipBlocked` because a foreign
1025
+ // service still owns this state and none of it is ours to move.
1026
+ if (unreadable.length > 0 && !ownershipBlocked) {
1027
+ for (const read of unreadable) {
1028
+ const moved = quarantinePendingTeardown(read.nonce);
1029
+ if (moved) {
1030
+ console.error(`⚠️ That unreadable receipt was set aside at ${moved}. It still blocks 'ocx update', and 'ocx stop' has NOT restored on its behalf.`);
1031
+ console.error(" To clear it: confirm no proxy is running, run 'ocx restore', then delete that file.");
1032
+ } else {
1033
+ console.error(`❌ It could not be set aside either: ${pendingTeardownPathFor(read.nonce)}. Remove it manually after running 'ocx restore'.`);
1034
+ }
1035
+ }
1036
+ }
1037
+ // Set the code rather than exiting inline: this function returns a value its dispatcher
1038
+ // reads, so exiting here would take that decision away from the caller.
1039
+ //
1040
+ // A history-only failure gets its own code so `ocx update` can tell "the proxy is down
1041
+ // and a manifest needs review" from "the proxy would not stop" (#3008). Ordinary failure
1042
+ // still wins: it is the stronger signal.
747
1043
  if (stopFailed) process.exitCode = 1;
1044
+ else if (historyOnlyFailure) process.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE;
748
1045
  return !stopFailed;
749
1046
  }
750
1047
 
751
1048
  async function handleUninstall() {
1049
+ /** Definitive "nothing is answering" on the endpoint this home would serve. */
1050
+ const proxyEndpointProvenDown = async (): Promise<boolean> => {
1051
+ try {
1052
+ const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs");
1053
+ // Every candidate, not just the preferred one: a stale runtime record pointing at a
1054
+ // closed port would otherwise "prove" a live proxy on the configured port is gone.
1055
+ const endpoints = endpointsToProve(readRuntimePort(), loadConfig());
1056
+ return everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname));
1057
+ } catch {
1058
+ return false;
1059
+ }
1060
+ };
752
1061
  const failures: string[] = [];
753
1062
 
754
1063
  const runStep = async (label: string, step: () => void | boolean | Promise<void | boolean>) => {
@@ -762,18 +1071,89 @@ async function handleUninstall() {
762
1071
  }
763
1072
  };
764
1073
 
765
- await runStep("service stopped", () => stopServiceIfInstalled());
1074
+ // Consume the DETAILED outcome. The boolean helper returns false for "not installed",
1075
+ // "refused to stop" and "state could not be read" alike, so this step used to print
1076
+ // "not installed" for a manager that might still be running and then tear down shared
1077
+ // config underneath it (#3008).
1078
+ // The authorization rule lives in `uninstall-plan` so it can be exercised for every
1079
+ // failure permutation by calling it, rather than by reading this function's source.
1080
+ const observed: UninstallObservation = {
1081
+ serviceStop: null,
1082
+ proxyProvenDown: false,
1083
+ serviceRemoval: null,
1084
+ respawnWindowVerified: false,
1085
+ };
1086
+ await runStep("service stopped", () => {
1087
+ const outcome = stopServiceIfInstalledDetailed();
1088
+ observed.serviceStop = outcome;
1089
+ if (outcome === "absent") return false;
1090
+ if (outcome === "failed") {
1091
+ throw new Error("the installed service manager did not stop; it may respawn the proxy");
1092
+ }
1093
+ if (outcome === "state-unknown") {
1094
+ throw new Error("the Windows Task Scheduler state could not be read, so this uninstall cannot tell whether a manager is still running. Run 'ocx service status' to see the query error");
1095
+ }
1096
+ return true;
1097
+ });
766
1098
 
767
1099
  await runStep("proxy stopped", async () => {
768
1100
  const pid = readPid();
769
- if (!pid) return false;
1101
+ if (!pid) {
1102
+ // A missing pid file is not proof that nothing is serving: a proxy can outlive its
1103
+ // record (crash, manual delete, corrupt file), which is exactly why `ocx stop` falls
1104
+ // back to identity-checked discovery. Without this, uninstall restored shared config
1105
+ // and reported success while that proxy kept running (#3008).
1106
+ const live = await findLiveProxy();
1107
+ if (!live) {
1108
+ // A miss is not proof: `findLiveProxy` collapses a timeout and a transport failure
1109
+ // into the same null as a dead endpoint. Ask the tri-state probe, which only says
1110
+ // "dead" for a refused connection or a definitive non-OpenCodex answer (#3008).
1111
+ observed.proxyProvenDown = await proxyEndpointProvenDown();
1112
+ if (!observed.proxyProvenDown) {
1113
+ throw new Error("no proxy could be found, but its endpoint could not be confirmed down either; confirm nothing is serving, then rerun");
1114
+ }
1115
+ return false;
1116
+ }
1117
+ if (!live.pid) {
1118
+ throw new Error(`a proxy is answering on port ${live.port} but no process id could be resolved for it; stop it from the home that started it, then rerun`);
1119
+ }
1120
+ await stopProxy(live.pid);
1121
+ observed.proxyProvenDown = true;
1122
+ return true;
1123
+ }
770
1124
  await stopProxy(pid);
771
1125
  removePid(pid);
772
1126
  removeRuntimePort(pid);
1127
+ observed.proxyProvenDown = true;
773
1128
  return true;
774
1129
  });
775
1130
 
776
- await runStep("service removed", () => uninstallServiceIfInstalled());
1131
+ await runStep("service removed", () => {
1132
+ const outcome = uninstallServiceDetailed();
1133
+ observed.serviceRemoval = outcome;
1134
+ // "absent" and "removed" are both fine; a failure is not, and it used to look like
1135
+ // absence on darwin and linux.
1136
+ if (outcome === "failed") throw new Error("the installed service could not be removed");
1137
+ return outcome === "removed";
1138
+ });
1139
+
1140
+ // Only Task Scheduler can respawn through a surviving wrapper, and removing the
1141
+ // registration does not prove the running one died. Poll the same window `ocx stop` does
1142
+ // before shared config is allowed down (#764, #3008).
1143
+ if (observed.serviceStop === "stopped-respawnable") {
1144
+ await runStep("respawn window verified", async () => {
1145
+ const survivor = await proxyStillLiveAfterStop({ canRespawn: true });
1146
+ if (survivor) throw new Error(`a proxy is still listening on port ${survivor.port} after the service was removed; it is being respawned`);
1147
+ // A null from that poll is not proof either: its identity probe returns null on a
1148
+ // timeout, so a respawned-but-unresponsive proxy looks the same as none. Require the
1149
+ // tri-state probe to say dead on every candidate before calling the window verified.
1150
+ if (!await proxyEndpointProvenDown()) {
1151
+ throw new Error("no survivor answered after the service was removed, but the endpoint could not be confirmed down either; confirm nothing is serving, then rerun");
1152
+ }
1153
+ observed.respawnWindowVerified = true;
1154
+ return true;
1155
+ });
1156
+ }
777
1157
 
778
1158
  if (process.platform === "win32") {
779
1159
  await runStep("Windows tray removed", async () => {
@@ -784,16 +1164,26 @@ async function handleUninstall() {
784
1164
  });
785
1165
  }
786
1166
 
787
- await runStep("native Codex restored", async () => {
788
- const r = await restoreNativeCodexAsync();
789
- if (!r.success) throw new Error(r.message);
790
- });
1167
+ // Shared client config comes down only once nothing that could still be serving is
1168
+ // unaccounted for. Restoring it under a live, still-managed proxy leaves both pointing
1169
+ // at each other — the same failure `ocx stop` refuses (#3008).
1170
+ if (sharedTeardownAuthorized(observed)) {
1171
+ await runStep("native Codex restored", async () => {
1172
+ const r = await restoreNativeCodexAsync();
1173
+ if (!r.success) throw new Error(r.message);
1174
+ });
791
1175
 
792
- await runStep("Grok Build config restored", () => {
793
- const r = stripGrokConfig();
794
- if (!r.ok) throw new Error(r.message);
795
- return r.changed;
796
- });
1176
+ await runStep("Grok Build config restored", () => {
1177
+ const r = stripGrokConfig();
1178
+ if (!r.ok) throw new Error(r.message);
1179
+ return r.changed;
1180
+ });
1181
+ } else {
1182
+ failures.push("native Codex restored", "Grok Build config restored");
1183
+ console.error("⚠️ Skipping shared teardown (native Codex restore, Grok config): a service or proxy could not be proven stopped.");
1184
+ console.error(" Resolve the failures above and rerun 'ocx uninstall' — service removal and local state cleanup are also unfinished.");
1185
+ console.error(" 'ocx restore' is an interim step if you need native routing back before then.");
1186
+ }
797
1187
 
798
1188
  await runStep("system env vars reverted", () => {
799
1189
  const r = revertSystemEnv();
@@ -29,7 +29,7 @@ const GROK_USAGE = `Usage:
29
29
 
30
30
  const CLIENT_USAGE = `Usage:
31
31
  ocx integration client [status] [--client <id>] [--json]
32
- ocx integration client <enable|disable> --client <id> [--json]
32
+ ocx integration client <enable|disable> --client <id> [--overwrite-conflict] [--json]
33
33
  ocx integration client history [--client <id>] [--json]
34
34
  ocx integration client restore --op <opId> [--confirm-drift] [--json]`;
35
35
 
@@ -212,11 +212,33 @@ export async function handleClientIntegrationCommand(
212
212
  throw new CliUsageError(`unknown client integration command ${action}`, CLIENT_USAGE);
213
213
  }
214
214
  const client = takeOption(args, "--client");
215
+ /*
216
+ * The conflict escape hatch, spelled the way `restore --confirm-drift` is: the
217
+ * refusal is the default and the waiver has to be typed.
218
+ *
219
+ * Without it the dashboard could resolve a conflict and the CLI could not,
220
+ * which strands exactly the user who cannot open a browser -- an SSH session,
221
+ * or an agent driving the proxy. That dead end is the reason the overwrite
222
+ * path exists at all.
223
+ */
224
+ const overwriteConflict = takeFlag(args, "--overwrite-conflict");
215
225
  rejectArgs(args, CLIENT_USAGE);
216
226
  if (!client) throw new CliUsageError("--client <id> is required", CLIENT_USAGE);
227
+ /*
228
+ * Refused here rather than forwarded. The route answers 400 for this pair, but
229
+ * a local usage error names the flag that is wrong, where the route's reply
230
+ * arrives as a generic failed request.
231
+ */
232
+ if (overwriteConflict && action === "disable") {
233
+ throw new CliUsageError("--overwrite-conflict applies only to enable", CLIENT_USAGE);
234
+ }
217
235
  const result = await runtimeRequest(`/api/client-integrations/${encodeURIComponent(client)}`, {
218
236
  method: "PUT",
219
- body: JSON.stringify({ enabled: action === "enable" }),
237
+ // Sent only when asked for, so a proxy on an older build sees the request it
238
+ // has always seen rather than an unknown field.
239
+ body: JSON.stringify(overwriteConflict
240
+ ? { enabled: true, overwriteConflict: true }
241
+ : { enabled: action === "enable" }),
220
242
  }, deps);
221
243
  printData(result, wantsJson, [String((result as Record<string, unknown>).message ?? `${client} ${action}d.`)]);
222
244
  });