@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.
- package/bin/ocx.mjs +58 -6
- package/gui/dist/assets/index-D-lchsPw.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/index.ts +420 -30
- package/src/cli/integrations.ts +24 -2
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/codex/account-store.ts +121 -8
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/catalog/provider-fetch.ts +76 -9
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/routing.ts +68 -16
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/generated/compatibility-version.json +84 -32
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/writer.ts +82 -7
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +20 -0
- package/src/responses/state.ts +288 -2
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management-api.ts +66 -12
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +155 -20
- package/src/update/index.ts +36 -4
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-C14iCj_Q.js +0 -112
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
687
|
+
else if (!grok.ok) { other = true; console.error(`⚠️ ${grok.message}`); }
|
|
653
688
|
} catch (error) {
|
|
654
|
-
|
|
689
|
+
other = true;
|
|
655
690
|
console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
656
691
|
}
|
|
657
|
-
return
|
|
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
|
-
|
|
671
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
//
|
|
746
|
-
//
|
|
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
|
-
|
|
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)
|
|
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", () =>
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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();
|
package/src/cli/integrations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|