@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
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/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { findLiveProxy, SERVICE_STOP_LIVENESS } from "../server/proxy-liveness";
|
|
2
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { getConfigDir } from "../config";
|
|
4
|
+
import { readPid, removePid, removeRuntimePort, verifyPidIdentity } from "../config/process-state";
|
|
5
|
+
import { isWslRuntime } from "../codex/home";
|
|
6
|
+
import { isProcessAlive, stopProxy } from "../lib/process-control";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, type WinswStatus } from "../lib/winsw";
|
|
9
|
+
import { diagnoseService } from "./diagnostics";
|
|
10
|
+
import type { ServiceDiagnostic } from "./diagnostics";
|
|
11
|
+
import { assertServiceEnvironmentMatchesInstall } from "./guards";
|
|
12
|
+
import { runLaunchctl, launchdEvictionTargets, launchctlBootoutBenign, probeLaunchdLoadState, installLaunchd, startLaunchd, stopLaunchd, statusLaunchd, uninstallLaunchd } from "./launchd";
|
|
13
|
+
import { assertSchedulerRegistrationBeforeStart } from "./repair";
|
|
14
|
+
import { SERVICE_MANAGED_ENV, TASK, plistPath, serviceStatePaths, writeServiceInstallState } from "./state";
|
|
15
|
+
import type { ServiceBackend } from "./state";
|
|
16
|
+
import { unitPath, isSystemd, installSystemd, startSystemd, stopSystemd, statusSystemd, uninstallSystemd, systemdServiceInstallCleanupOps } from "./systemd";
|
|
17
|
+
import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, installWindows, installWindowsNative, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops";
|
|
18
|
+
import { schtasks, probeWindowsSchedulerTask, rollbackWindowsSchedulerTaskOwnedByAttempt, settleDelay } from "./windows-scheduler";
|
|
19
|
+
import type { WindowsSchedulerTaskProbe } from "./windows-scheduler";
|
|
20
|
+
import { windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } from "./windows-taskxml";
|
|
21
|
+
import { win32 } from "node:path";
|
|
22
|
+
import { launchdGuiDomain } from "./launchd";
|
|
23
|
+
import { LABEL } from "./state";
|
|
24
|
+
|
|
25
|
+
type ServiceOps = {
|
|
26
|
+
install: () => void | Promise<void>; start: () => void; stop: () => void;
|
|
27
|
+
status: () => string; uninstall: () => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ServiceInstallCleanupOps = {
|
|
31
|
+
status: () => string | null;
|
|
32
|
+
stop: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
36
|
+
if (process.platform === "darwin")
|
|
37
|
+
// Wrapped, not passed: `installLaunchd` reports whether it reloaded launchd, and only
|
|
38
|
+
// `repairService` (for the `restart` verb) has any use for that. `ServiceOps.install` is
|
|
39
|
+
// the generic install seam and deliberately promises nothing about a return value.
|
|
40
|
+
return { install: () => { installLaunchd(); }, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
|
|
41
|
+
if (process.platform === "win32") {
|
|
42
|
+
if (backend === "native")
|
|
43
|
+
return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
|
|
44
|
+
return { install: installWindows, start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows };
|
|
45
|
+
}
|
|
46
|
+
if (process.platform === "linux") {
|
|
47
|
+
if (existsSync("/.dockerenv")) {
|
|
48
|
+
console.error("Docker detected. Run 'ocx start' directly instead of using the service manager.");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
if (!isSystemd() && !existsSync(unitPath())) {
|
|
52
|
+
console.error("systemd not found. Run 'ocx start' under your process supervisor.");
|
|
53
|
+
if (isWslRuntime()) {
|
|
54
|
+
console.error("WSL detected: enable systemd by adding [boot] systemd=true to /etc/wsl.conf, then run 'wsl --shutdown' from Windows and reopen the distro (WSL 0.67.6+).");
|
|
55
|
+
}
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
return { install: installSystemd, start: startSystemd, stop: stopSystemd, status: statusSystemd, uninstall: uninstallSystemd };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Install-only manager operations. Unlike the ordinary status/stop helpers, these
|
|
65
|
+
* distinguish confirmed absence from a failed manager query and propagate every
|
|
66
|
+
* non-benign stop failure. Installing new assets is unsafe while either answer is
|
|
67
|
+
* unknown because an old manager may still respawn a listener on the target port.
|
|
68
|
+
*/
|
|
69
|
+
function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
|
|
70
|
+
if (process.platform === "darwin") {
|
|
71
|
+
return {
|
|
72
|
+
// Same probe as `diagnoseService`, so the two answers to one question can no longer
|
|
73
|
+
// have opposite failure semantics: this one used to throw on a launchctl error while
|
|
74
|
+
// `statusLaunchd` swallowed it into "absent". Installing over an unverifiable
|
|
75
|
+
// manager is the unsafe case, so `unknown` fails closed here.
|
|
76
|
+
status: () => {
|
|
77
|
+
const probe = probeLaunchdLoadState();
|
|
78
|
+
if (probe.state === "unknown") {
|
|
79
|
+
throw new Error(`launchd job status could not be verified: ${probe.detail ?? "launchctl could not be asked"}`);
|
|
80
|
+
}
|
|
81
|
+
return probe.state === "not-loaded" ? null : `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()}`;
|
|
82
|
+
},
|
|
83
|
+
// `unload` is legacy and CANNOT evict a gui-domain job, which is what this cleanup
|
|
84
|
+
// exists to do before new assets are installed. Both user domains, because the probe
|
|
85
|
+
// above reports `user/<uid>` too: a gui-only bootout exits 3 against one of those,
|
|
86
|
+
// which this function would have read as "already stopped" and installed over a live
|
|
87
|
+
// job (see `launchdEvictionTargets`).
|
|
88
|
+
stop: () => {
|
|
89
|
+
for (const target of launchdEvictionTargets()) {
|
|
90
|
+
const booted = runLaunchctl(["bootout", target]);
|
|
91
|
+
if (launchctlBootoutBenign(booted.status)) continue;
|
|
92
|
+
throw new Error(`launchctl bootout ${target} failed: ${booted.stderr || `exit ${String(booted.status)}`}`);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (process.platform === "win32") {
|
|
98
|
+
if (backend === "native") {
|
|
99
|
+
return {
|
|
100
|
+
status: () => {
|
|
101
|
+
const status = statusWinswRaw();
|
|
102
|
+
if (status === "unknown") throw new Error("Native service status could not be verified.");
|
|
103
|
+
return status === "nonexistent" ? null : status;
|
|
104
|
+
},
|
|
105
|
+
stop: stopWinswService,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
status: () => {
|
|
110
|
+
const probe = probeWindowsSchedulerTask(TASK);
|
|
111
|
+
if (probe.status === "unknown") throw new Error(`Task Scheduler status could not be verified: ${probe.detail}`);
|
|
112
|
+
return probe.status === "present" ? "present" : null;
|
|
113
|
+
},
|
|
114
|
+
stop: () => {
|
|
115
|
+
try {
|
|
116
|
+
schtasks(["/end", "/tn", TASK]);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!isWindowsSchedulerEndBenign(error)) throw error;
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (process.platform === "linux") {
|
|
124
|
+
// `list-unit-files <name>` exits non-zero when the unit has never been
|
|
125
|
+
// installed, which made a clean first install look like an unknown manager
|
|
126
|
+
// failure. `show LoadState` gives us the tri-state we actually need: a
|
|
127
|
+
// healthy user manager returns `not-found` for a missing unit, while an
|
|
128
|
+
// unreachable/permission-denied manager still makes `sh()` throw and the
|
|
129
|
+
// caller therefore fails closed.
|
|
130
|
+
return systemdServiceInstallCleanupOps();
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
|
|
136
|
+
|
|
137
|
+
function verifiedKillTarget(pid: number | null | undefined): number | null {
|
|
138
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
139
|
+
const verified = verifyPidIdentity(pid);
|
|
140
|
+
return verified === pid ? verified : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether a proxy is still answering after the service manager claimed to stop it.
|
|
145
|
+
*
|
|
146
|
+
* `ops.stop()` reports the outcome of the STOP COMMAND, not of the process. A Windows scheduler
|
|
147
|
+
* task whose wrapper survives `schtasks /end` respawns its child a few seconds later, so a stop
|
|
148
|
+
* that returned success can still leave a live proxy — and `ocx service stop` then restored
|
|
149
|
+
* native Codex on top of a running one (#764). The tracked-pid cleanup does not catch it either:
|
|
150
|
+
* the respawned child writes a different pid, or none this process knows about.
|
|
151
|
+
*
|
|
152
|
+
* Probed rather than assumed, and bounded. The respawn risk is specific to a supervisor that can
|
|
153
|
+
* restart its child — the Windows scheduler wrapper — so only that case pays the restart window.
|
|
154
|
+
* Everywhere else a single probe answers the question, because nothing is going to bring the
|
|
155
|
+
* proxy back after `launchctl unload` or `systemctl stop`. Making every platform wait 7s on a
|
|
156
|
+
* stop that already succeeded would trade one bug for a worse everyday one.
|
|
157
|
+
*/
|
|
158
|
+
export async function proxyStillLiveAfterStop(deps: {
|
|
159
|
+
findProxy?: () => Promise<{ port: number } | null>;
|
|
160
|
+
sleep?: (ms: number) => Promise<void>;
|
|
161
|
+
now?: () => number;
|
|
162
|
+
/** Whether the stopped supervisor can respawn its child; only then is polling worth the wait. */
|
|
163
|
+
canRespawn?: boolean;
|
|
164
|
+
} = {}): Promise<{ port: number } | null> {
|
|
165
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
|
166
|
+
const now = deps.now ?? Date.now;
|
|
167
|
+
const canRespawn = deps.canRespawn ?? process.platform === "win32";
|
|
168
|
+
const deadline = now() + (canRespawn ? 7000 : 0);
|
|
169
|
+
// Single-shot (non-respawn) still needs one full SERVICE_STOP_LIVENESS budget; respawn
|
|
170
|
+
// polling shares the outer deadline so multi-candidate discovery cannot overrun it.
|
|
171
|
+
const findProxy = deps.findProxy ?? (() => {
|
|
172
|
+
const probeDeadline = canRespawn
|
|
173
|
+
? deadline
|
|
174
|
+
: now() + (SERVICE_STOP_LIVENESS.timeoutMs! * SERVICE_STOP_LIVENESS.attempts! + 250);
|
|
175
|
+
return findLiveProxy({ ...SERVICE_STOP_LIVENESS, deadlineAt: probeDeadline, nowFn: now });
|
|
176
|
+
});
|
|
177
|
+
for (;;) {
|
|
178
|
+
try {
|
|
179
|
+
const live = await findProxy();
|
|
180
|
+
if (live) return live;
|
|
181
|
+
} catch {
|
|
182
|
+
// A probe failure is not proof the proxy is gone; keep polling until the deadline.
|
|
183
|
+
}
|
|
184
|
+
if (now() >= deadline) return null;
|
|
185
|
+
await sleep(1000);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
|
|
190
|
+
let stopped = false;
|
|
191
|
+
const pid = readPid();
|
|
192
|
+
const trackedKillPid = verifiedKillTarget(pid);
|
|
193
|
+
if (trackedKillPid !== null && isProcessAlive(trackedKillPid)) {
|
|
194
|
+
await stopProxy(trackedKillPid);
|
|
195
|
+
removePid(trackedKillPid);
|
|
196
|
+
removeRuntimePort(trackedKillPid);
|
|
197
|
+
stopped = true;
|
|
198
|
+
} else if (pid) {
|
|
199
|
+
removePid(pid);
|
|
200
|
+
removeRuntimePort(pid);
|
|
201
|
+
}
|
|
202
|
+
// Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
|
|
203
|
+
// a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
|
|
204
|
+
// Cap multi-candidate discovery so stop cleanup cannot hang for three full retry budgets.
|
|
205
|
+
const live = await findLiveProxy({
|
|
206
|
+
...SERVICE_STOP_LIVENESS,
|
|
207
|
+
deadlineAt: Date.now() + 7000,
|
|
208
|
+
});
|
|
209
|
+
const liveKillPid = verifiedKillTarget(live?.pid);
|
|
210
|
+
if (liveKillPid !== null) {
|
|
211
|
+
await stopProxy(liveKillPid);
|
|
212
|
+
removePid(liveKillPid);
|
|
213
|
+
removeRuntimePort(liveKillPid);
|
|
214
|
+
stopped = true;
|
|
215
|
+
}
|
|
216
|
+
if (stopped) return "stopped";
|
|
217
|
+
if (pid) return "stale";
|
|
218
|
+
return "none";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupResult> {
|
|
222
|
+
try {
|
|
223
|
+
return await stopTrackedProxyIfRunning();
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(`⚠️ Failed to stop proxy: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
return "none";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface ServiceInstallPreparationDeps {
|
|
231
|
+
diagnose?: () => ServiceDiagnostic;
|
|
232
|
+
managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null;
|
|
233
|
+
stopTrackedProxy?: () => Promise<unknown>;
|
|
234
|
+
platform?: NodeJS.Platform;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Stop every manager that could own the install port, then stop the tracked
|
|
239
|
+
* standalone listener. Any unknown status or cleanup failure rejects, so callers
|
|
240
|
+
* cannot write assets or report success over a surviving old listener.
|
|
241
|
+
*/
|
|
242
|
+
export async function prepareServiceInstall(
|
|
243
|
+
requestedBackend: ServiceBackend,
|
|
244
|
+
deps: ServiceInstallPreparationDeps = {},
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
const diagnostic = (deps.diagnose ?? diagnoseService)();
|
|
247
|
+
const platform = deps.platform ?? process.platform;
|
|
248
|
+
const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps;
|
|
249
|
+
const backends: ServiceBackend[] = [];
|
|
250
|
+
const addBackend = (backend: ServiceBackend) => {
|
|
251
|
+
if (!backends.includes(backend)) backends.push(backend);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (platform === "win32") {
|
|
255
|
+
// The recorded backend owns the old installation and must be stopped first.
|
|
256
|
+
// A conflicting diagnostic means both managers exist, so stop both even when
|
|
257
|
+
// the requested backend happens to match the recorded one.
|
|
258
|
+
if (diagnostic.backend === "scheduler" || diagnostic.backend === "native") {
|
|
259
|
+
addBackend(diagnostic.backend);
|
|
260
|
+
if (diagnostic.conflict) addBackend(diagnostic.backend === "scheduler" ? "native" : "scheduler");
|
|
261
|
+
}
|
|
262
|
+
addBackend(requestedBackend);
|
|
263
|
+
} else {
|
|
264
|
+
addBackend(requestedBackend);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const backend of backends) {
|
|
268
|
+
const manager = resolveOps(backend);
|
|
269
|
+
if (!manager) throw new Error(`Background service manager is unavailable for ${backend}.`);
|
|
270
|
+
if (manager.status() !== null) manager.stop();
|
|
271
|
+
}
|
|
272
|
+
await (deps.stopTrackedProxy ?? stopTrackedProxyIfRunning)();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function installServiceSafely(
|
|
276
|
+
requestedBackend: ServiceBackend,
|
|
277
|
+
install: () => void | Promise<void>,
|
|
278
|
+
deps: ServiceInstallPreparationDeps = {},
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
await prepareServiceInstall(requestedBackend, deps);
|
|
281
|
+
await install();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface FreshWindowsSchedulerInstallDeps {
|
|
285
|
+
stageRegistrationXml?: (attemptNonce: string) => string;
|
|
286
|
+
register?: (xmlPath: string, attemptNonce: string) => Promise<void>;
|
|
287
|
+
recordOwnership?: () => boolean;
|
|
288
|
+
prepare?: () => Promise<void>;
|
|
289
|
+
removeNativeService?: () => void;
|
|
290
|
+
publishAssets?: () => void;
|
|
291
|
+
verifyBeforeRun?: (attemptNonce: string) => void | Promise<void>;
|
|
292
|
+
/** Reads the newly registered task; empty or throwing reads are retried before rollback. */
|
|
293
|
+
readSchedulerXml?: () => string;
|
|
294
|
+
/** Bounded wait before retrying an unreadable fresh-install registration. */
|
|
295
|
+
settleSchedulerRead?: (delayMs: number) => void | Promise<void>;
|
|
296
|
+
runTask?: () => void;
|
|
297
|
+
writeState?: () => void;
|
|
298
|
+
rollbackTask?: (attemptNonce: string) => Promise<string | null>;
|
|
299
|
+
removeStagedXml?: (xmlPath: string) => void;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Fresh Windows scheduler install with UAC before the destructive commit.
|
|
304
|
+
*
|
|
305
|
+
* The registration is created but never run before `prepare`: UAC cancellation and
|
|
306
|
+
* create failure therefore cannot stop the existing proxy or trigger its native-routing
|
|
307
|
+
* cleanup. Rollback proves ownership from the live registration's attempt nonce before
|
|
308
|
+
* deleting, because the fixed task name can be replaced by another process at any time.
|
|
309
|
+
*/
|
|
310
|
+
export async function installFreshWindowsSchedulerSafely(
|
|
311
|
+
deps: FreshWindowsSchedulerInstallDeps = {},
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml;
|
|
314
|
+
const register = deps.register ?? registerFreshWindowsSchedulerTask;
|
|
315
|
+
const recordOwnership = deps.recordOwnership ?? recordWindowsSchedulerOwnership;
|
|
316
|
+
const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler"));
|
|
317
|
+
const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler;
|
|
318
|
+
const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets;
|
|
319
|
+
const verifyBeforeRun = deps.verifyBeforeRun ?? ((nonce: string) => (
|
|
320
|
+
assertSchedulerRegistrationBeforeStart(
|
|
321
|
+
deps.readSchedulerXml ?? statusWindowsXml,
|
|
322
|
+
deps.settleSchedulerRead ?? settleDelay,
|
|
323
|
+
liveXml => (
|
|
324
|
+
windowsTaskRegistrationHealthy(liveXml)
|
|
325
|
+
&& windowsTaskRegistrationOwnedByAttempt(liveXml, nonce)
|
|
326
|
+
),
|
|
327
|
+
"The fresh Task Scheduler registration changed before start; it was preserved and not run.",
|
|
328
|
+
"The fresh Task Scheduler registration remained unreadable before start; it was preserved and not run.",
|
|
329
|
+
)
|
|
330
|
+
));
|
|
331
|
+
const runTask = deps.runTask ?? startWindows;
|
|
332
|
+
const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler"));
|
|
333
|
+
const rollbackTask = deps.rollbackTask ?? ((attemptNonce: string) => (
|
|
334
|
+
rollbackWindowsSchedulerTaskOwnedByAttempt(attemptNonce, TASK)
|
|
335
|
+
));
|
|
336
|
+
const removeStagedXml = deps.removeStagedXml ?? ((path: string) => {
|
|
337
|
+
removeWindowsSchedulerRegistrationStage(path);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
let stagedXml: string | null = null;
|
|
341
|
+
const attemptNonce = randomUUID();
|
|
342
|
+
const configRootWasAbsent = !existsSync(getConfigDir());
|
|
343
|
+
let registered = false;
|
|
344
|
+
let started = false;
|
|
345
|
+
try {
|
|
346
|
+
stagedXml = stage(attemptNonce);
|
|
347
|
+
await register(stagedXml, attemptNonce);
|
|
348
|
+
registered = true;
|
|
349
|
+
|
|
350
|
+
// The destructive boundary begins only after Task Scheduler accepted the definition.
|
|
351
|
+
// The registration has consumed its temporary XML. Remove it before claiming a newly
|
|
352
|
+
// created config root, because ownership initialization intentionally requires emptiness.
|
|
353
|
+
removeStagedXml(stagedXml);
|
|
354
|
+
stagedXml = null;
|
|
355
|
+
const ownershipRecorded = recordOwnership();
|
|
356
|
+
if (!ownershipRecorded && configRootWasAbsent) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
"The fresh OpenCodex config root could not be claimed for safe uninstall; "
|
|
359
|
+
+ "aborting before service-manager cleanup or asset publication.",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
await prepare();
|
|
363
|
+
removeNativeService();
|
|
364
|
+
publishAssets();
|
|
365
|
+
await verifyBeforeRun(attemptNonce);
|
|
366
|
+
runTask();
|
|
367
|
+
started = true;
|
|
368
|
+
writeState();
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
371
|
+
if (registered && !started) {
|
|
372
|
+
const rollback = await rollbackTask(attemptNonce);
|
|
373
|
+
throw new Error(
|
|
374
|
+
`${detail}\n`
|
|
375
|
+
+ (rollback
|
|
376
|
+
? `The new Task Scheduler registration may remain: ${rollback}`
|
|
377
|
+
: "The new Task Scheduler registration was rolled back. The previous proxy/routing state was not assumed restored."),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (started) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`${detail}\nThe scheduler task started, but install state was not published. `
|
|
383
|
+
+ "The task was left in place; inspect `ocx service status` before retrying.",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
} finally {
|
|
388
|
+
if (stagedXml) {
|
|
389
|
+
try { removeStagedXml(stagedXml); } catch (error) {
|
|
390
|
+
const code = error && typeof error === "object" && "code" in error
|
|
391
|
+
? String((error as NodeJS.ErrnoException).code)
|
|
392
|
+
: "";
|
|
393
|
+
console.error(
|
|
394
|
+
`⚠️ Failed to remove the private Task Scheduler staging directory${code ? ` (${code})` : ""}.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// `stopServiceIfInstalled` (boolean) is deliberately gone. It collapsed "not installed",
|
|
402
|
+
// "refused to stop" and "state could not be read" into the same `false`, and every caller
|
|
403
|
+
// that trusted it eventually read a live manager as absence — the route, then uninstall
|
|
404
|
+
// (#3008). Callers take `stopServiceIfInstalledDetailed` and handle the outcomes.
|
|
405
|
+
/**
|
|
406
|
+
* Would stopping the installed manager leave something that can respawn the proxy?
|
|
407
|
+
*
|
|
408
|
+
* Answered WITHOUT stopping anything, because a caller that must refuse the stop has to
|
|
409
|
+
* refuse before it acts: `POST /api/stop` briefly ended the Task Scheduler task and then
|
|
410
|
+
* returned 409, which left the proxy running with its manager stopped — worse than either
|
|
411
|
+
* outcome it was choosing between.
|
|
412
|
+
*
|
|
413
|
+
* Task Scheduler only. `schtasks /end` ends the task instance while the `cmd :loop`
|
|
414
|
+
* wrapper survives and respawns its child (#764); launchd, systemd and WinSW are down when
|
|
415
|
+
* they report stopped.
|
|
416
|
+
*/
|
|
417
|
+
export function installedServiceRespawnRisk(
|
|
418
|
+
probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
|
|
419
|
+
platform: NodeJS.Platform = process.platform,
|
|
420
|
+
io: { env?: NodeJS.ProcessEnv; exists?: (path: string) => boolean } = {},
|
|
421
|
+
): "none" | "respawnable" | "unknown" | "self-unload" {
|
|
422
|
+
// launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
|
|
423
|
+
// wrapper survives its task ending (#764).
|
|
424
|
+
//
|
|
425
|
+
// "Down when they report stopped" answers the RESPAWN question but not the SELF-UNLOAD
|
|
426
|
+
// one (#4023). When the proxy is itself the managed job, `launchctl unload` /
|
|
427
|
+
// `systemctl stop` terminate this very process, so the manager stop can kill the request
|
|
428
|
+
// handler before the shared teardown restores the native Codex config keys — leaving
|
|
429
|
+
// `openai_base_url`, `experimental_realtime_ws_base_url` and `model_catalog_json`
|
|
430
|
+
// pointed at a proxy that is gone. Reordering teardown ahead of the manager stop is not
|
|
431
|
+
// available here: the #3008 contract requires the manager to be proven stopped first.
|
|
432
|
+
// So refuse, exactly as Windows does, and send the operator to `ocx stop`, which stops
|
|
433
|
+
// the proxy from the outside and owns the teardown through its receipt.
|
|
434
|
+
if (platform !== "win32") {
|
|
435
|
+
const env = io.env ?? process.env;
|
|
436
|
+
// Discriminate on the wrapper-only marker, not on OCX_SERVICE: `ocx claude` and
|
|
437
|
+
// `ocx opencode` set OCX_SERVICE=1 on the proxies they spawn (for preserveRouting),
|
|
438
|
+
// and refusing their dashboard stop would break a proxy that no manager supervises.
|
|
439
|
+
if (env[SERVICE_MANAGED_ENV] !== "1") return "none";
|
|
440
|
+
const exists = io.exists ?? existsSync;
|
|
441
|
+
if (platform === "darwin") return exists(plistPath()) ? "self-unload" : "none";
|
|
442
|
+
if (platform === "linux") return exists(unitPath()) ? "self-unload" : "none";
|
|
443
|
+
return "none";
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
// `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
|
|
447
|
+
// fail — it does not throw — so testing for "present" let an unanswerable probe
|
|
448
|
+
// through, and the route then killed scheduler wrappers before refusing.
|
|
449
|
+
//
|
|
450
|
+
// "unknown" is kept SEPARATE from "respawnable" because the remedies differ. Telling
|
|
451
|
+
// an operator whose schtasks query is broken to run `ocx stop` is circular: that
|
|
452
|
+
// command maps the same unknown to a stop failure, so it cannot finish either.
|
|
453
|
+
const status = probe().status;
|
|
454
|
+
if (status === "absent") return "none";
|
|
455
|
+
return status === "present" ? "respawnable" : "unknown";
|
|
456
|
+
} catch {
|
|
457
|
+
// A probe that cannot answer is not evidence of absence either.
|
|
458
|
+
return "unknown";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Outcome of stopping an installed process manager.
|
|
464
|
+
*
|
|
465
|
+
* `stopServiceIfInstalled` collapses "no service was installed" and "a service was
|
|
466
|
+
* installed and would not stop" into the same `false`, which is fine for a caller that
|
|
467
|
+
* only wants to log. It is not fine for one deciding whether an update may replace package
|
|
468
|
+
* files: a manager that refused to stop can respawn the proxy on top of a half-written
|
|
469
|
+
* install (#3008).
|
|
470
|
+
*/
|
|
471
|
+
/**
|
|
472
|
+
* `stopped-respawnable` is Task Scheduler specifically: `schtasks /end` ends the task
|
|
473
|
+
* instance while the `cmd :loop` wrapper survives and respawns its child seconds later
|
|
474
|
+
* (#764). Only that backend needs the restart-window wait — launchd, systemd and WinSW
|
|
475
|
+
* are down when they report stopped, and making them pay a seven-second poll would be a
|
|
476
|
+
* regression in every ordinary `ocx stop`.
|
|
477
|
+
*/
|
|
478
|
+
/**
|
|
479
|
+
* `state-unknown` is kept apart from `failed` because the remedies differ. A manager that
|
|
480
|
+
* refused to stop is a stop failure the operator can retry; a scheduler whose state cannot
|
|
481
|
+
* be READ is a broken query, and telling that operator "the manager did not stop" sends
|
|
482
|
+
* them looking for the wrong thing (#3008).
|
|
483
|
+
*/
|
|
484
|
+
export type ServiceStopOutcome = "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown";
|
|
485
|
+
|
|
486
|
+
export function stopServiceIfInstalledDetailed(): ServiceStopOutcome {
|
|
487
|
+
assertServiceEnvironmentMatchesInstall();
|
|
488
|
+
if (process.platform === "darwin") {
|
|
489
|
+
if (existsSync(plistPath())) {
|
|
490
|
+
try { stopLaunchd(); return "stopped"; } catch { return "failed"; }
|
|
491
|
+
}
|
|
492
|
+
} else if (process.platform === "win32") {
|
|
493
|
+
// Query BOTH backends regardless of state: a failed switch or stale state can leave
|
|
494
|
+
// two managers installed, and either one would respawn the proxy after `ocx stop`.
|
|
495
|
+
let stopped = false;
|
|
496
|
+
let failed = false;
|
|
497
|
+
let schedulerStopped = false;
|
|
498
|
+
let stateUnknown = false;
|
|
499
|
+
// `probeWindowsSchedulerTask` is tri-state on purpose: a query that THROWS is not the
|
|
500
|
+
// same as a task that is absent, and treating it as absent lets a live scheduler
|
|
501
|
+
// survive a "successful" stop.
|
|
502
|
+
const probe = probeWindowsSchedulerTask();
|
|
503
|
+
if (probe.status === "present") {
|
|
504
|
+
if (stopWindowsChecked()) { stopped = true; schedulerStopped = true; }
|
|
505
|
+
else failed = true;
|
|
506
|
+
} else if (probe.status === "unknown") {
|
|
507
|
+
// Not "failed": nothing refused to stop. The query itself could not answer, which is
|
|
508
|
+
// a different problem with a different fix.
|
|
509
|
+
stateUnknown = true;
|
|
510
|
+
}
|
|
511
|
+
if (statusWinswRaw() !== "nonexistent") {
|
|
512
|
+
try { stopWinswService(); stopped = true; } catch { failed = true; }
|
|
513
|
+
}
|
|
514
|
+
// `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
|
|
515
|
+
// respawns its child seconds later (issue #764), resurrecting the proxy during a
|
|
516
|
+
// stop or a tray restart. Kill the launcher/wrapper processes outright.
|
|
517
|
+
killWindowsServiceWrapperProcesses();
|
|
518
|
+
// A failure on either backend wins: the other one stopping does not make the live one
|
|
519
|
+
// safe to update over.
|
|
520
|
+
const outcome = classifyWindowsServiceStop({ stopped, failed, schedulerStopped, stateUnknown });
|
|
521
|
+
if (outcome !== "absent") return outcome;
|
|
522
|
+
} else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
|
|
523
|
+
try { stopSystemd(); return "stopped"; } catch { return "failed"; }
|
|
524
|
+
}
|
|
525
|
+
return "absent";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
|
|
529
|
+
export function removeServiceInstallState(): void {
|
|
530
|
+
for (const path of serviceStatePaths()) {
|
|
531
|
+
try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
type UninstallServiceHooksForTests = {
|
|
536
|
+
platform: typeof process.platform;
|
|
537
|
+
assertEnvironment: () => void;
|
|
538
|
+
probeWindowsTask: () => WindowsSchedulerTaskProbe;
|
|
539
|
+
uninstallWindowsTask: () => void;
|
|
540
|
+
nativeStatus: () => WinswStatus;
|
|
541
|
+
uninstallNative: () => void;
|
|
542
|
+
removeInstallState: () => void;
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
let uninstallServiceHooksForTests: UninstallServiceHooksForTests | null = null;
|
|
546
|
+
|
|
547
|
+
/** Test-only hooks for full-uninstall service removal. */
|
|
548
|
+
export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksForTests | null): void {
|
|
549
|
+
uninstallServiceHooksForTests = hooks;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
|
|
554
|
+
* when no service exists or the platform has no service manager. An installed native Windows
|
|
555
|
+
* service or scheduler task that cannot be removed throws so the caller cannot erase state and
|
|
556
|
+
* report success.
|
|
557
|
+
*/
|
|
558
|
+
/**
|
|
559
|
+
* Outcome of removing an installed manager.
|
|
560
|
+
*
|
|
561
|
+
* `false` used to mean both "nothing was installed" and "removal failed" on darwin and
|
|
562
|
+
* linux, so a failed removal was reported as absence and authorized the shared teardown
|
|
563
|
+
* while the service assets were still there (#3008).
|
|
564
|
+
*/
|
|
565
|
+
export type ServiceUninstallOutcome = "absent" | "removed" | "failed";
|
|
566
|
+
|
|
567
|
+
export function uninstallServiceDetailed(): ServiceUninstallOutcome {
|
|
568
|
+
const hooks = uninstallServiceHooksForTests;
|
|
569
|
+
(hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
|
|
570
|
+
const platform = hooks?.platform ?? process.platform;
|
|
571
|
+
if (platform === "darwin") {
|
|
572
|
+
if (existsSync(plistPath())) {
|
|
573
|
+
try { uninstallLaunchd(); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
|
|
574
|
+
}
|
|
575
|
+
} else if (platform === "win32") {
|
|
576
|
+
let removed = false;
|
|
577
|
+
const scheduler = (hooks?.probeWindowsTask ?? probeWindowsSchedulerTask)();
|
|
578
|
+
if (scheduler.status === "unknown") {
|
|
579
|
+
throw new Error(`Could not determine Task Scheduler state: ${scheduler.detail}`);
|
|
580
|
+
}
|
|
581
|
+
if (scheduler.status === "present") {
|
|
582
|
+
(hooks?.uninstallWindowsTask ?? uninstallWindows)();
|
|
583
|
+
removed = true;
|
|
584
|
+
}
|
|
585
|
+
if ((hooks?.nativeStatus ?? statusWinswRaw)() !== "nonexistent") {
|
|
586
|
+
(hooks?.uninstallNative ?? uninstallWinswService)();
|
|
587
|
+
removed = true;
|
|
588
|
+
}
|
|
589
|
+
if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return "removed"; }
|
|
590
|
+
} else if (platform === "linux" && existsSync(unitPath())) {
|
|
591
|
+
try { uninstallSystemd(); removeServiceInstallState(); return "removed"; } catch {
|
|
592
|
+
try { unlinkSync(unitPath()); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return "absent";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Boolean form for callers that only distinguish "something was removed". */
|
|
599
|
+
export function uninstallServiceIfInstalled(): boolean {
|
|
600
|
+
const outcome = uninstallServiceDetailed();
|
|
601
|
+
if (outcome === "failed") throw new Error("the installed service could not be removed");
|
|
602
|
+
return outcome === "removed";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** True if a background service (launchd/systemd/Task Scheduler) is installed. */
|
|
606
|
+
export function isServiceInstalled(): boolean {
|
|
607
|
+
return diagnoseService().installed;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* True when an installed background service can actually supervise the proxy.
|
|
612
|
+
* Presence alone is not enough: stale/missing assets, conflicts, and disabled
|
|
613
|
+
* registrations report `installed` but will not bring the proxy back after exit.
|
|
614
|
+
*/
|
|
615
|
+
export function isServiceViable(): boolean {
|
|
616
|
+
return diagnoseService().viable;
|
|
617
|
+
}
|