Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 105 additions & 24 deletions bin/ocx.mjs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ and, afterwards, a folder next to the package that cannot be deleted because `bu
The updater stops the proxy, installs the new version into a separate staging folder, checks it,
and only then swaps it in. When the install step fails, the swap never happens: the previous version
stays installed and runnable, and the updater restarts the previous background service (or the
previous proxy) before it exits. You do not need to reinstall to get back to a working proxy.
previous proxy) before it exits, when ownership and service-home checks authorize recovery.

If the initial service-home check is inconclusive, for example because Bun is missing or a service
query fails, the updater can replace an idle package without stopping or restarting the proxy.
A successful package-only update prints a manual restart hint and explains that service ownership
could not be verified. Check `ocx service status` and the registered installation before restarting.
Changes in authority, unknown durable runtime ownership, Desktop supervision, a live package
runtime or an unresolved teardown receipt still block package replacement; an inconclusive check is not permission to control them.

Check that with:

Expand Down
5 changes: 5 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1763,6 +1763,11 @@
"update-refresh.test.ts": "update",
"update-restart-lease.test.ts": "update",
"update-stop-classification.test.ts": "update",
"update-service-home.test.ts": "update",
"update-service-home-policy.test.ts": "update",
"update-home-recovery.test.ts": "update",
"update-home-launcher.test.ts": "update",
"update-home-worker.test.ts": "update",
"update-stop-first.test.ts": "update",
"update-transactional-leftovers.test.ts": "update",
"update-transactional.test.ts": "update",
Expand Down
63 changes: 53 additions & 10 deletions src/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type { ServiceOwnership } from "../service/state";
import { planUpdateRuntimeHandling, planStoppedRuntimeRecovery, inspectPackageRuntimeLiveness } from "./runtime-ownership.mjs";
import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs";
import { withUpdateOwnershipLease, readUpdateRuntimeTarget } from "./ownership-transaction";
import { createUpdateServiceHomePolicy, inspectUpdateServiceHome } from "./service-home-authority";
import { UPDATE_PACKAGE_ONLY_EXIT_CODE } from "./service-home-policy.mjs";
import { npmInvocation } from "./npm-invocation.mjs";
import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs";
import { detectInstallOwnershipFromPath } from "./install-detection.mjs";
Expand Down Expand Up @@ -489,12 +491,14 @@ export async function runUpdate(): Promise<void> {

const { serviceStatePaths } = await import("../service");
const updateExitCode = await withUpdateOwnershipLease(serviceStatePaths(), async mutation => {
const homePolicy = createUpdateServiceHomePolicy(inspectUpdateServiceHome, message => console.warn(message), process.argv.includes("--update-package-only"));
if (!homePolicy.checkPackage()) return 1;
// Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
// unloads it, so a successful update must repair/restart it afterwards.
let serviceWasInstalled = false;
try {
const { isServiceInstalled } = await import("../service");
serviceWasInstalled = isServiceInstalled();
serviceWasInstalled = homePolicy.serviceAllowed && isServiceInstalled();
} catch { /* best-effort */ }
// What this update may do to the runtime. A desktop takeover vetoes both the stop and the
// service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong.
Expand All @@ -513,7 +517,7 @@ export async function runUpdate(): Promise<void> {
}
let trayWasInstalled = false;
let trayWasRunning = false;
if (process.platform === "win32") {
if (!homePolicy.packageOnly && process.platform === "win32") {
try {
const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows");
const tray = getWindowsTrayStatus();
Expand Down Expand Up @@ -566,10 +570,13 @@ export async function runUpdate(): Promise<void> {
};
/** `"vetoed"`: another owner holds the runtime, so not starting is the correct outcome. */
const startProxyDirectly = async (): Promise<boolean | "vetoed"> => {
// Every caller, including error recovery, owns the lock and fresh home proof here.
mutation.reacquire();
if (!homePolicy.check()) return "vetoed";
const ownership = await resolvedRuntimeOwnership();
const supervision = observeSupervision();
const plan = planUpdateRuntimeHandling({ ...ownership, serviceInstalled: false, supervision });
if (!plan.mayStopRuntime) {
if (!sameOwner(ownership) || !plan.mayStopRuntime || !homePolicy.check()) {
console.warn(supervision
? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored."
: plan.notice);
Expand Down Expand Up @@ -600,8 +607,14 @@ export async function runUpdate(): Promise<void> {
// and count it only when the serving proxy is that job. Runs unleased: the manager's child
// cannot join this lease (#5760). `"unsettled"` means the manager may still finish the restart,
// so no competing proxy may be started.
const restartThroughServiceManager = async (): Promise<"supervised" | "failed" | "unsettled"> => {
const restartThroughServiceManager = async (): Promise<"supervised" | "failed" | "unsettled" | "refused"> => {
if (!hasRestartableServiceManager()) return "failed";
mutation.release();
const owner = await resolvedRuntimeOwnership();
if (!homePolicy.check()) return "refused";
if (!homePolicy.serviceAllowed) return "failed";
if (!sameOwner(owner) || !planUpdateRuntimeHandling({ ...owner, serviceInstalled: true,
supervision: observeSupervision() }).mayRestoreService) return "refused";
console.log("🔁 Asking the service manager to restart the background service...");
const before = readUpdateRuntimeTarget(getRuntimePortPath(), capturedListen.hostname);
const staleRecordPid = before.kind === "target" ? before.target.pid : null;
Expand Down Expand Up @@ -652,12 +665,14 @@ export async function runUpdate(): Promise<void> {
});
};
let recovery = await planRecovery();
if (!homePolicy.check()) return;
if (recovery.action === "service") {
// The service manager starts the proxy outside this process tree, so it
// cannot join this lease, and holding it through the repair's health wait
// keeps that proxy from starting (#5760). Release it, then decide again —
// a claim could have landed while the repair ran unleased.
mutation.release();
if (!homePolicy.check()) return;
recovery = await planRecovery();
}
if (recovery.reason === "desktop-supervised") {
Expand All @@ -666,6 +681,8 @@ export async function runUpdate(): Promise<void> {
console.warn(`⚠️ ${reason}; runtime recovery requires manual review (${recovery.reason}).`);
} else if (recovery.action === "service") {
const { serviceReinstallArgs, isServiceViable } = await import("../service");
if (!homePolicy.check()) return;
if (!homePolicy.serviceAllowed) { await startProxyDirectly(); return; }
recovery = await planRecovery();
if (recovery.action !== "service") {
console.warn("Runtime authority changed before service recovery; no CLI runtime was restored.");
Expand All @@ -675,6 +692,8 @@ export async function runUpdate(): Promise<void> {
stdio: updateChildStdio(), windowsHide: true,
env: mutation.controlEnvironment({ ...process.env, OCX_BAKE_PORT: String(capturedListen.port) }),
});
if (!homePolicy.check()) return;
if (!homePolicy.serviceAllowed) { await startProxyDirectly(); return; }
if (service.status !== 0 || !isServiceViable()) {
// Ownership may have moved while the refresh ran unleased; only a recovery plan that
// still names the service may restart the manager's job.
Expand Down Expand Up @@ -716,7 +735,12 @@ export async function runUpdate(): Promise<void> {
// silently skips the recovery the receipt was written to trigger (#3008).
// Full `ocx stop` semantics (drain, service stop, restore).

if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) {
if (homePolicy.packageOnly && pendingTeardownOutstanding()) {
console.error("Pending teardown requires manual recovery before package replacement; run 'ocx stop' from the owning installation.");
return 1;
}
if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) {
if (!homePolicy.check()) return 1;
const preStopPlan = planUpdateRuntimeHandling({
...(await resolvedRuntimeOwnership()),
serviceInstalled: serviceWasInstalled,
Expand Down Expand Up @@ -805,7 +829,7 @@ export async function runUpdate(): Promise<void> {
supervision: observeSupervision(),
});
const replacementLiveness = currentPackageRuntimeLiveness();
if (replacementOwnership.subjectToken !== initialOwnership.subjectToken
if (!homePolicy.checkPackage() || replacementOwnership.subjectToken !== initialOwnership.subjectToken
|| !replacementPlan.mayReplacePackage
|| replacementLiveness !== "dead") {
replacementRefusal = replacementPlan.notice
Expand Down Expand Up @@ -885,6 +909,11 @@ export async function runUpdate(): Promise<void> {
});
if (r.status === 0) {
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
if (homePolicy.packageOnly) {
homePolicy.check(homePolicy.initial);
console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`);
return process.argv.includes("--update-worker-result") ? UPDATE_PACKAGE_ONLY_EXIT_CODE : undefined;
}
if (!postInstallPlan.mayStopRuntime) console.warn("Runtime authority does not permit CLI recovery; no service was refreshed or proxy started.");
// Re-enter through the verified active package launcher. This keeps the Codex
// shim, tray, service and proxy recovery paths on the same package/group that
Expand Down Expand Up @@ -915,20 +944,29 @@ export async function runUpdate(): Promise<void> {
// The stop above unloaded any managed service; repair it with the NEW files
// (spawn the fresh cli.ts so updated code writes the baked paths) so a
// launchd/schtasks/systemd user isn't left with the background proxy down.
if (!homePolicy.check()) return 1;
if (!homePolicy.serviceAllowed && postInstallPlan.mayStopRuntime) {
if (stopAttempted) return await startProxyDirectly() === true ? undefined : 1;
console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`);
return;
}
if (postInstallPlan.mayRestoreService) {
let homeRefused = false;
const mayRefresh = async () => {
if (!homePolicy.check()) { homeRefused = true; return false; }
const ownership = await resolvedRuntimeOwnership();
const supervision = observeSupervision();
const plan = planUpdateRuntimeHandling({ ...ownership, serviceInstalled: true, supervision });
if (!plan.mayRestoreService) console.warn(supervision
? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)."
: plan.notice);
return plan.mayRestoreService;
return plan.mayRestoreService && homePolicy.serviceAllowed;
};
if (!await mayRefresh()) return;
if (!await mayRefresh()) return homeRefused ? 1 : undefined;
console.log("🔁 Refreshing the background service with the updated files...");
const { serviceReinstallArgs } = await import("../service");
const { reclaimListenPort } = await import("../server/port-reclaim");
if (!homePolicy.check()) return 1;
const freed = await reclaimListenPort(capturedListen.port, capturedListen.hostname, {
timeoutMs: 30_000,
intervalMs: 100,
Expand All @@ -944,17 +982,21 @@ export async function runUpdate(): Promise<void> {
// wait, the lease makes that child die at the acquire deadline (#5760).
// The kill-authorizing reclaim above already ran under the lease.
mutation.release();
if (!homePolicy.check()) return 1;
if (!homePolicy.serviceAllowed) return await startProxyDirectly() === true ? undefined : 1;
const prevBake = process.env.OCX_BAKE_PORT;
process.env.OCX_BAKE_PORT = String(capturedListen.port);
try {
if (!await mayRefresh()) return;
if (!await mayRefresh()) return homeRefused ? 1 : undefined;
const svcStdio = updateChildStdio();
const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], {
env: mutation.controlEnvironment(),
stdio: svcStdio,
encoding: svcStdio === "pipe" ? "utf8" : undefined,
windowsHide: true,
});
if (!homePolicy.check()) return 1;
if (!homePolicy.serviceAllowed) return await startProxyDirectly() === true ? undefined : 1;
if (svcStdio === "pipe") logSpawnOutput("", svc);
const serviceRefreshed = svc.status === 0;
let serviceViable = serviceRefreshed;
Expand All @@ -969,10 +1011,11 @@ export async function runUpdate(): Promise<void> {
if (!serviceRefreshed || !serviceViable) {
// #7043: the manager first. Only a proxy it owns counts as recovered; everything
// below this line leaves an unsupervised runtime and exits non-zero.
if (!await mayRefresh()) return;
if (!await mayRefresh()) return homeRefused ? 1 : undefined;
const managed = await restartThroughServiceManager();
if (managed === "supervised") return;
if (managed === "unsettled") return 1;
if (managed === "refused") return 1;
// Repair normally avoids elevation for a healthy scheduler task, but a stale
// definition may require guarded create/elevation. It can also fail — or exit 0
// while leaving stale/missing assets that never start
Expand Down
8 changes: 7 additions & 1 deletion src/update/job-supervised-restart.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readRuntimePort } from "../config/process-state";
import { proxyIdentityAt } from "../server/proxy-liveness";
import { inspectUpdateServiceHome, updateServiceHomeRefusal, type UpdateServiceHomeInspector } from "./service-home-authority";
import {
freshServingPid, hasRestartableServiceManager, readParentPid, readServiceManagerPid, restartServiceManagerJob,
supervisionVerdict, unsettledManagerWarning, unsupervisedRuntimeWarning,
Expand All @@ -12,9 +13,10 @@ import {
* detached `ocx start`, and counts it only when the serving proxy is that job. It lives beside the
* worker because `src/update/job.ts` sits at the repository's size cap.
*/
export type ManagerRestartOutcome = "supervised" | "failed" | "unsettled";
export type ManagerRestartOutcome = "supervised" | "failed" | "unsettled" | "refused";

export interface ManagerRestartDeps {
serviceHomeAuthorityFn?: UpdateServiceHomeInspector;
hasManager?: () => boolean;
restartJob?: () => { ok: true } | { ok: false; reason: string; unsettled?: true };
managerPid?: () => number | null;
Expand Down Expand Up @@ -43,6 +45,10 @@ export async function restartServiceManagerAfterUpdate(
): Promise<ManagerRestartOutcome> {
if (!(deps.hasManager ?? hasRestartableServiceManager)()) return "failed";
target.release?.();
const home = (deps.serviceHomeAuthorityFn ?? inspectUpdateServiceHome)();
const refusal = updateServiceHomeRefusal(home);
if (refusal) { log(refusal); return "refused"; }
if (home.kind === "absent") return "failed";
log("Asking the service manager to restart the background service...");
const readRecord = deps.readRecord ?? (() => readRuntimePort());
const probe = deps.probeServing ?? (async (port: number, hostname: string, pid: number, deadlineAt: number) => (
Expand Down
44 changes: 44 additions & 0 deletions src/update/job-update-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { spawnSync } from "node:child_process";
import { createUpdateServiceHomePolicy, inspectUpdateServiceHome, type UpdateServiceHomeInspector } from "./service-home-authority";
import { UPDATE_PACKAGE_ONLY_EXIT_CODE } from "./service-home-policy.mjs";
import { currentServiceHomes } from "../service/state";
import { probeUpdatePackageOnlyLauncher, updateHomeProbeEnvironment } from "./service-home-probe.mjs";

/** Refuse old launchers without executing their pre-dispatch bootstrap. */
export function automaticUpdateLauncherSupported(launcher: string, node: string): boolean {
try {
const env = updateHomeProbeEnvironment(process.env, currentServiceHomes());
return probeUpdatePackageOnlyLauncher(launcher, spawnSync, node, env);
} catch { return false; }
}


/** Keep a worker's no-control decision across delegation, including the otherwise raw Bun lane. */
export function prepareWorkerUpdateHome(command: { bin: string; args: string[]; display: string },
installer: string, launcher: string, node: string, channel: string,
report: (message: string) => void,
inspect: UpdateServiceHomeInspector = inspectUpdateServiceHome,
compatible: (launcher: string, node: string) => boolean = automaticUpdateLauncherSupported) {
const policy = createUpdateServiceHomePolicy(inspect, report);
if (!policy.checkPackage()) return null;
if (installer === "npm" || installer === "pnpm" || policy.packageOnly) {
if (!compatible(launcher, node)) {
report("Automatic update refused: guarded recovery protocol unavailable. Check 'ocx service status' and update this CLI manually before retrying.");
return null;
}
}
if (policy.packageOnly) {
// The package-only protocol promises this restrictive flag is honored by both Node and Bun updaters.
command.bin = node;
command.args = [launcher, "update", "--tag", channel, "--update-package-only"];
command.display = `ocx update --tag ${channel} --update-package-only`;
}
if (installer === "npm" || installer === "pnpm" || policy.packageOnly) command.args.push("--update-worker-result");
return policy;
}

/** A restricted-success status is meaningful only from our package-only protocol update launcher. */
export function packageOnlyUpdateSucceeded(command: { args: string[] }, status: number | null, parentPackageOnly: boolean) {
return status === 0 && parentPackageOnly
|| status === UPDATE_PACKAGE_ONLY_EXIT_CODE && command.args.includes("--update-worker-result");
}
34 changes: 34 additions & 0 deletions src/update/job-windows-restart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { join } from "node:path";
import { getConfigDir } from "../config";
import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers";
import { stopWinswService } from "../lib/winsw";
import { readServiceBackend, stopWindows } from "../service";

/** Stop the installed Windows backend and best-effort kill surviving :loop wrappers. */
export function stopWindowsServiceWrappersBestEffort(): void {
if (process.platform !== "win32") return;
try {
if (readServiceBackend() === "native") {
stopWinswService();
return;
}
stopWindows();
} catch { /* already stopped */ }
killWindowsServiceWrapperProcesses();
}

/**
* Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
* `schtasks /end` ends the task instance but often leaves wscript/cmd running the
* `:loop` batch, which brings the proxy back during post-update reclaim.
*
* This used to match the bare filenames with -like '*name*', which could stop a
* wrapper belonging to a DIFFERENT OpenCodex home under the same account. The
* shared killer scopes to this home's canonical paths as complete tokens.
*/
function killWindowsServiceWrapperProcesses(): void {
killWindowsSchedulerWrappers({
scriptPath: join(getConfigDir(), "opencodex-service.cmd"),
launcherPath: join(getConfigDir(), "opencodex-service-launcher.vbs"),
});
}
Loading
Loading