diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6baf5168b5f..bbcdc3b1d5c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -15,6 +15,8 @@ import { desktopCliRecordPath } from "../src/lib/desktop-cli-record.mjs"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; +import { createUpdateServiceHomePolicy, isUpdateHomeProbe, UPDATE_HOME_LAUNCHER_PROTOCOL, UPDATE_PACKAGE_ONLY_EXIT_CODE } from "../src/update/service-home-policy.mjs"; +import { probeUpdateServiceHome, supportsUpdateHomeProbe, updateHomeProbeEnvironment } from "../src/update/service-home-probe.mjs"; import { inspectPackageRuntimeLiveness, planStoppedRuntimeRecovery, @@ -103,9 +105,9 @@ function currentPackageVersion() { } } -function pinnedBunVersion() { +function pinnedBunVersion(launcherPath) { try { - const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")); + const pkg = JSON.parse(readFileSync(join(launcherPath ? dirname(launcherPath) : here, "..", "package.json"), "utf8")); return typeof pkg.dependencies?.bun === "string" ? pkg.dependencies.bun : ""; } catch { return ""; @@ -134,6 +136,19 @@ function configDir() { return resolve(raw ? expandUserPath(raw) : join(homedir(), ".opencodex")); } +// Direct Bun entry only: never recurse through another Node launcher or ordinary CLI startup. +function readNodeUpdateHome(launcherPath) { + if (!supportsUpdateHomeProbe(launcherPath)) return { kind: "unknown" }; + const bun = resolveBun({ allowInstall: false, launcherPath, allowMissing: true }); + if (!bun) return { kind: "unknown" }; + const env = updateHomeProbeEnvironment(process.env, { + codexHome: process.env.CODEX_HOME ?? "", opencodexHome: configDir(), + }); + return probeUpdateServiceHome(spawnSync, bun.path, + ["--no-env-file", join(dirname(launcherPath), "..", "src", "update", "service-home-probe-entry.ts")], { env }); +} + + function shouldRepairCodexShim() { return existsSync(join(configDir(), "codex-shim.json")); } @@ -314,7 +329,10 @@ function runPackageManagerSelfUpdate(manager) { const serviceStatePaths = process.env.OCX_TEST_HOME_GUARD === "1" ? allServiceStatePaths.slice(0, 1) : allServiceStatePaths; - const serviceWasInstalled = serviceStatePaths.some(path => existsSync(path)); + let authorityLauncher = fileURLToPath(import.meta.url); + const homePolicy = createUpdateServiceHomePolicy(() => readNodeUpdateHome(authorityLauncher), message => console.warn(message), process.argv.includes("--update-package-only")); + if (!homePolicy.checkPackage()) process.exit(1); + const serviceWasInstalled = homePolicy.serviceAllowed && serviceStatePaths.some(path => existsSync(path)); // What this update may do to the runtime. The same rule the Bun updater applies, from the // same module: a desktop takeover vetoes both the stop and the service refresh below. const readServiceState = () => selectAuthoritativeServiceState( @@ -350,7 +368,7 @@ function runPackageManagerSelfUpdate(manager) { process.exit(1); } const trayBeforeUpdate = planWindowsTrayUpdate( - process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, + !homePolicy.packageOnly && process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, ); /** * Refresh the existing service in place. `service repair` discovers the installed backend; @@ -476,9 +494,12 @@ function runPackageManagerSelfUpdate(manager) { /** Returns true, false, or "vetoed" when another owner holds the runtime (not a failure). */ function startProxyDirectly() { + reacquireUpdateLease(); + if (!homePolicy.check()) return "vetoed"; + const currentOwnership = readOwnership(); const supervision = observeSupervision(); - const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: false, supervision }); - if (!plan.mayStopRuntime) { + const plan = planUpdateRuntimeHandling({ ...currentOwnership, serviceInstalled: false, supervision }); + if (ownershipIdentity(currentOwnership) !== ownershipIdentity(initialOwnership) || !plan.mayStopRuntime || !homePolicy.check()) { console.warn(supervision ? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored." : plan.notice); @@ -520,6 +541,12 @@ function runPackageManagerSelfUpdate(manager) { // "supervised", "failed", or "unsettled" when the manager may still finish the restart. function restartThroughServiceManager() { if (!hasRestartableServiceManager()) return "failed"; + releaseUpdateLease(); + if (!homePolicy.check()) return "refused"; + if (!homePolicy.serviceAllowed) return "failed"; + const owner = readOwnership(); + if (ownershipIdentity(owner) !== ownershipIdentity(initialOwnership) + || !planUpdateRuntimeHandling({ ...owner, serviceInstalled: true, supervision: observeSupervision() }).mayRestoreService) return "refused"; console.log("opencodex: asking the service manager to restart the background service."); const before = readCurrentRuntimeTarget(); const staleRecordPid = before.kind === "target" ? before.target.pid : null; @@ -560,21 +587,28 @@ function runPackageManagerSelfUpdate(manager) { * manager may still finish its restart; callers exit non-zero for both. */ function refreshBackgroundServiceOrStartDirect() { + authorityLauncher = postUpdateLauncher; + if (!homePolicy.check()) return "refused"; + if (!homePolicy.serviceAllowed) return startProxyDirectly() === true ? undefined : "refused"; + let homeRefused = false; const mayRefresh = () => { + if (!homePolicy.check()) { homeRefused = true; return false; } const supervision = observeSupervision(); const plan = planUpdateRuntimeHandling({ ...readOwnership(), 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 (!mayRefresh()) return; + if (!mayRefresh()) return homeRefused ? "refused" : undefined; const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { let svc = spawnSync(process.execPath, serviceRefreshArgs(), { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), }); + if (!homePolicy.check()) return "refused"; + if (!homePolicy.serviceAllowed) return startProxyDirectly() === true ? undefined : "refused"; // `serviceWasInstalled` is inferred from service-state.json alone, which can be // STALE — present while the registration is gone. Repair refuses that case by // design, and its thrown Error is indistinguishable from any other failure at @@ -584,11 +618,12 @@ function runPackageManagerSelfUpdate(manager) { // failure would resurrect the elevation prompt this change exists to avoid, and // could re-register a service the user just uninstalled. if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) { - if (!mayRefresh()) return; + if (!mayRefresh()) return homeRefused ? "refused" : undefined; console.log("No registered service found — installing it instead."); svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), }); + if (!homePolicy.check()) return "refused"; } let needDirectStart = svc.status !== 0; if (!needDirectStart) { @@ -629,6 +664,7 @@ function runPackageManagerSelfUpdate(manager) { const managed = restartThroughServiceManager(); if (managed === "supervised") return; if (managed === "unsettled") return "unsettled"; + if (managed === "refused") return "refused"; // Repair normally avoids elevation for a healthy registration, but a stale Windows // scheduler definition can require it. It can also fail — or exit 0 while leaving // a non-viable manager. Fall back to a direct detached proxy start so the @@ -651,7 +687,7 @@ function runPackageManagerSelfUpdate(manager) { } } - const updateLease = acquireOwnershipMutationLease(serviceStatePaths); + let updateLease = acquireOwnershipMutationLease(serviceStatePaths); delegatedOwnershipMutationToken = updateLease.token; let updateLeaseReleased = false; const releaseUpdateLease = () => { @@ -660,15 +696,22 @@ function runPackageManagerSelfUpdate(manager) { delegatedOwnershipMutationToken = null; updateLease.release(); }; + const reacquireUpdateLease = () => { + if (!updateLeaseReleased) return; + updateLease = acquireOwnershipMutationLease(serviceStatePaths); + updateLeaseReleased = false; + delegatedOwnershipMutationToken = updateLease.token; + }; let res; + let stopAttempted = false; let npmFailure = null; try { // Stop authority is decided under the same lease the child joins. A takeover between the // earlier preflight and this boundary therefore blocks stop before it is sent. const lockedOwnership = readOwnership(); const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); - if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { + if (!homePolicy.checkPackage() || lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { releaseUpdateLease(); console.error(lockedPlan.notice ?? "opencodex: update stopped because runtime ownership changed before stop authorization; rerun from the beginning."); @@ -681,7 +724,7 @@ function runPackageManagerSelfUpdate(manager) { // semantics: graceful drain, service stop, native Codex restore). Gate on the service // and the runtime-port record too: a service-managed or orphaned proxy can be live // while ocx.pid is stale/missing. - if (trayBeforeUpdate.stopBeforeReplacement) { + if (!homePolicy.packageOnly && trayBeforeUpdate.stopBeforeReplacement) { console.log("⏹ Handing off the Windows tray before updating..."); try { handoffWindowsTrayForUpdate(trayBeforeUpdate, { @@ -699,9 +742,10 @@ function runPackageManagerSelfUpdate(manager) { } const hasRuntimeState = existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); - let stopAttempted = false; function recoverStoppedRuntimeAfterFailure(reason) { + authorityLauncher = postUpdateLauncher; + if (!homePolicy.check()) return; const planRecovery = () => { const recoveryOwnership = readOwnership(); const liveness = currentPackageRuntimeLiveness(); @@ -725,6 +769,7 @@ function runPackageManagerSelfUpdate(manager) { // lease, and holding the lease through the repair's health wait keeps that proxy from // starting (#5760). Release it as the successful path does, then decide again. releaseUpdateLease(); + if (!homePolicy.check()) return; ({ liveness: recoveryLiveness, plan: recovery } = planRecovery()); } if (recovery.reason === "desktop-supervised") { @@ -754,7 +799,12 @@ function runPackageManagerSelfUpdate(manager) { // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides // whether the obligation is safe to finish. const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); - const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown; + if (homePolicy.packageOnly && hasPendingTeardown) { + releaseUpdateLease(); + console.error("opencodex: pending teardown requires manual recovery before package replacement; run 'ocx stop' from the owning installation."); + process.exit(1); + } + const stopNeeded = !homePolicy.packageOnly && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown); if (stopNeeded && !runtimePlan.mayStopRuntime) { releaseUpdateLease(); console.error(runtimePlan.notice @@ -762,6 +812,7 @@ function runPackageManagerSelfUpdate(manager) { process.exit(1); } if (stopNeeded) { + if (!homePolicy.check()) { releaseUpdateLease(); process.exit(1); } const preStopPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision(), }); @@ -828,7 +879,7 @@ function runPackageManagerSelfUpdate(manager) { const replacementOwnership = readOwnership(); const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); const replacementLiveness = currentPackageRuntimeLiveness(); - if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + if (!homePolicy.checkPackage() || replacementOwnership.subjectToken !== initialOwnership.subjectToken || !replacementPlan.mayReplacePackage || replacementLiveness !== "dead") { recoverStoppedRuntimeAfterFailure("replacement was refused"); @@ -935,6 +986,14 @@ function runPackageManagerSelfUpdate(manager) { } const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (res.status === 0) { + authorityLauncher = postUpdateLauncher; + if (homePolicy.packageOnly) { + console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); + homePolicy.check(homePolicy.initial); + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); + process.exit(process.argv.includes("--update-worker-result") ? UPDATE_PACKAGE_ONLY_EXIT_CODE : 0); + } + if (!homePolicy.check()) process.exit(1); console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); if (!postInstallPlan.mayStopRuntime) console.warn("Runtime authority does not permit CLI recovery; no service was refreshed or proxy started."); repairCodexShimIfNeeded(postUpdateLauncher); @@ -952,10 +1011,18 @@ function runPackageManagerSelfUpdate(manager) { // launcher so the new files write the baked paths and the service restarts. if (postInstallPlan.mayRestoreService) { console.log("Refreshing the background service with the updated files..."); - const refreshed = refreshBackgroundServiceOrStartDirect(); - if (refreshed === "unsupervised" || refreshed === "unsettled") process.exit(1); - } else if (postInstallPlan.mayStopRuntime) { - console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); + let refreshed; + try { refreshed = refreshBackgroundServiceOrStartDirect(); } + finally { releaseUpdateLease(); } + if (refreshed === "unsupervised" || refreshed === "unsettled" || refreshed === "refused") process.exit(1); + } else if (postInstallPlan.mayStopRuntime && homePolicy.check()) { + authorityLauncher = postUpdateLauncher; + if (stopAttempted) { + let started; + try { started = startProxyDirectly(); } + finally { releaseUpdateLease(); } + if (started !== true) process.exit(1); + } else console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } process.exit(0); } @@ -978,10 +1045,10 @@ function runPackageManagerSelfUpdate(manager) { process.exit(1); } -function bunBinDir() { +function bunBinDir(launcherPath) { // Resolve the `bun` dependency's directory without hardcoding the platform // package — the package manager's os/cpu/libc resolution already picked the right @oven/bun-*. - return dirname(require.resolve("bun/package.json")); + return dirname((launcherPath ? createRequire(launcherPath) : require).resolve("bun/package.json")); } const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; @@ -1021,7 +1088,7 @@ function fail(msg) { process.exit(1); } -function resolveBun({ allowInstall = true } = {}) { +function resolveBun({ allowInstall = true, launcherPath, allowMissing = false } = {}) { // Keep direct package-launcher starts aligned with durable service/shim installs: // a valid explicit runtime must win even when the bundled dependency exists. const override = process.env[BUN_OVERRIDE_ENV]?.trim(); @@ -1035,7 +1102,7 @@ function resolveBun({ allowInstall = true } = {}) { let bunDir = null; try { - bunDir = bunBinDir(); + bunDir = bunBinDir(launcherPath); } catch { /* Missing dependency can still fall back to a validated PATH Bun. */ } let bin = bunDir ? findBunBinary(bunDir) : null; @@ -1053,11 +1120,12 @@ function resolveBun({ allowInstall = true } = {}) { // Reached only when the bundled runtime is unusable. The two probes start the candidate Bun // cold, and on Windows a first run of a copied bun.exe is often held by an on-access scan for // well over a second, so the total budget is generous rather than interactive-tight. - const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS }); + const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(launcherPath), deadlineMs: PATH_BUN_PROBE_BUDGET_MS }); if (pathBun) { console.error(`opencodex: using PATH Bun ${pathBun.version}.`); return { path: pathBun.path, source: "process" }; } + if (allowMissing) return null; fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed."); } @@ -1072,6 +1140,19 @@ if (updateHelpRequested) { process.exit(0); } +// This intercept precedes Desktop handoff, boot restore and install-capable Bun resolution. +// Capability only: no Bun lookup, home inspection, dotenv or ordinary bootstrap. +if (process.argv[2] === "__update-package-only-probe") { + if (process.argv.length !== 3) process.exit(2); + console.log(JSON.stringify({ schemaVersion: 1, packageOnlyProtocolVersion: 1 })); + process.exit(0); +} +if (process.argv[2] === "__update-home-probe") { + if (!isUpdateHomeProbe(process.argv.slice(2))) process.exit(1); + console.log(JSON.stringify({ schemaVersion: 1, ...readNodeUpdateHome(fileURLToPath(import.meta.url)), launcherProtocolVersion: UPDATE_HOME_LAUNCHER_PROTOCOL })); + process.exit(0); +} + const codexCliUpdateInspection = isCodexCliUpdateInspectionArgv(process.argv); if (codexCliUpdateInspection && typeof process.versions.bun === "string") { console.error("opencodex: codex-cli-update inspection must use the published Node launcher."); diff --git a/docs-site/src/content/docs/troubleshooting/update-failed.md b/docs-site/src/content/docs/troubleshooting/update-failed.md index df5b916612c..cc96fd0f85a 100644 --- a/docs-site/src/content/docs/troubleshooting/update-failed.md +++ b/docs-site/src/content/docs/troubleshooting/update-failed.md @@ -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: diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..117fd52ecc5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/update/index.ts b/src/update/index.ts index 6723365ab68..5c315a1c673 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -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"; @@ -489,12 +491,14 @@ export async function runUpdate(): Promise { 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. @@ -513,7 +517,7 @@ export async function runUpdate(): Promise { } 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(); @@ -566,10 +570,13 @@ export async function runUpdate(): Promise { }; /** `"vetoed"`: another owner holds the runtime, so not starting is the correct outcome. */ const startProxyDirectly = async (): Promise => { + // 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); @@ -600,8 +607,14 @@ export async function runUpdate(): Promise { // 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; @@ -652,12 +665,14 @@ export async function runUpdate(): Promise { }); }; 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") { @@ -666,6 +681,8 @@ export async function runUpdate(): Promise { 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."); @@ -675,6 +692,8 @@ export async function runUpdate(): Promise { 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. @@ -716,7 +735,12 @@ export async function runUpdate(): Promise { // 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, @@ -805,7 +829,7 @@ export async function runUpdate(): Promise { supervision: observeSupervision(), }); const replacementLiveness = currentPackageRuntimeLiveness(); - if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + if (!homePolicy.checkPackage() || replacementOwnership.subjectToken !== initialOwnership.subjectToken || !replacementPlan.mayReplacePackage || replacementLiveness !== "dead") { replacementRefusal = replacementPlan.notice @@ -885,6 +909,11 @@ export async function runUpdate(): Promise { }); 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 @@ -915,20 +944,29 @@ export async function runUpdate(): Promise { // 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, @@ -944,10 +982,12 @@ export async function runUpdate(): Promise { // 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(), @@ -955,6 +995,8 @@ export async function runUpdate(): Promise { 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; @@ -969,10 +1011,11 @@ export async function runUpdate(): Promise { 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 diff --git a/src/update/job-supervised-restart.ts b/src/update/job-supervised-restart.ts index 0b9c3968525..d835a59898b 100644 --- a/src/update/job-supervised-restart.ts +++ b/src/update/job-supervised-restart.ts @@ -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, @@ -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; @@ -43,6 +45,10 @@ export async function restartServiceManagerAfterUpdate( ): Promise { 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) => ( diff --git a/src/update/job-update-home.ts b/src/update/job-update-home.ts new file mode 100644 index 00000000000..0a001975796 --- /dev/null +++ b/src/update/job-update-home.ts @@ -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"); +} diff --git a/src/update/job-windows-restart.ts b/src/update/job-windows-restart.ts new file mode 100644 index 00000000000..ba945d8cf1d --- /dev/null +++ b/src/update/job-windows-restart.ts @@ -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"), + }); +} diff --git a/src/update/job.ts b/src/update/job.ts index c9773fb8b92..72206ee58ee 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -6,12 +6,10 @@ import { atomicWriteFile, getConfigDir, loadConfig } from "../config"; import { readPid, readRuntimePort, removePid, removeRuntimePort, verifyPidIdentity } from "../config/process-state"; import { isProcessAlive, killProxy } from "../lib/process-control"; import { selfLaunchArgv } from "../lib/self-launch-argv"; -import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers"; import { buildWindowsElevatedArgumentList, resolveTrustedWindowsPowerShellExe, } from "../lib/windows-elevation"; -import { stopWinswService } from "../lib/winsw"; import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } from "../server/port-reclaim"; import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop"; import { @@ -21,7 +19,9 @@ import { proxyIdentityAt, type HealthzIdentity, } from "../server/proxy-liveness"; -import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; +import { isServiceInstalled, isServiceViable } from "../service"; +import { stopWindowsServiceWrappersBestEffort } from "./job-windows-restart"; +import { createUpdateServiceHomePolicy, inspectUpdateServiceHome, type UpdateServiceHomeInspector } from "./service-home-authority"; import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership"; import { restartServiceManagerAfterUpdate, unsettledManagerRestartFailure, unsupervisedRestartFailure, type ManagerRestartFn } from "./job-supervised-restart"; import { @@ -52,6 +52,7 @@ import { type NpmCachePreflightReason, } from "./npm-cache-preflight.mjs"; import { guiUpdateWorkerCommand } from "./worker-launch"; +import { prepareWorkerUpdateHome, packageOnlyUpdateSucceeded } from "./job-update-home"; import { withoutSiblingMarker } from "../codex/sibling-start"; import type { WorkerLaunchContext } from "./worker-launch"; @@ -887,8 +888,9 @@ function preparePortForPinnedStart( listPids: (port: number) => number[], aliveFn: (pid: number) => boolean, verifyOcx: (pid: number) => number | null = verifyPidIdentity, + stopServiceWrappers: () => void = () => {}, ): void { - stopWindowsServiceWrappersBestEffort(); + stopServiceWrappers(); const pid = readPid(); if (pid) { updateJob(job, {}, `Clearing pre-start proxy PID ${pid} before pinned start.`); @@ -983,6 +985,9 @@ export interface RestartIo { waitForGhostListenClearFn?: typeof waitForGhostListenClear; killProxyFn?: typeof killProxy; serviceInstalledFn?: () => boolean; + serviceHomeAuthorityFn?: UpdateServiceHomeInspector; + stopServiceWrappersFn?: () => void; + readPidFn?: typeof readPid; /** * After a service reinstall exits 0, only trust the service path when this is true. * Defaults to {@link isServiceViable} — installed-but-stale assets must fall through @@ -1091,7 +1096,10 @@ async function restartAfterUpdate( captured?: { port: number; hostname: string; oldPid?: number }, io: RestartIo = {}, ): Promise { - const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)(); + const homePolicy = createUpdateServiceHomePolicy(io.serviceHomeAuthorityFn ?? inspectUpdateServiceHome, + refusal => { updateJob(job, { status: "failed", restarted: false, error: refusal }, refusal); }); + if (!homePolicy.check(homePolicy.initial)) return false; + const serviceInstalled = homePolicy.initial.kind === "same" && (io.serviceInstalledFn ?? isServiceInstalled)(); const config = loadConfig(); // Stop-first update already cleared pid/runtime state, so the pre-update capture is // the authoritative port to wait on; config is only the cold-start fallback. @@ -1141,7 +1149,7 @@ async function restartAfterUpdate( // respawns `ocx start` a few seconds later and races port reclaim, so end the // task again and kill those wrappers before touching the socket, then reclaim it // for wrappers that bake `--port`. - stopWindowsServiceWrappersBestEffort(); + (io.stopServiceWrappersFn ?? stopWindowsServiceWrappersBestEffort)(); const preServiceAllow = reclaimKillAllowlist(); const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow)); let skipServiceInstall = false; @@ -1181,6 +1189,7 @@ async function restartAfterUpdate( // process descendant and cannot join this lease; held through its serving // wait, the lease makes that child die at the acquire deadline (#5760). io.releaseForServiceManagerFn?.(); + if (!homePolicy.check()) return false; const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(Math.trunc(port)); let serviceOk = false; @@ -1189,9 +1198,9 @@ async function restartAfterUpdate( ? WINDOWS_SERVICE_REPAIR_TIMEOUT_MS : RESTART_TIMEOUT_MS; const run = io.runService ?? ((j, bin, args, timeoutMs) => runLoggedCommand(j, bin, args, timeoutMs)); - const result = run(job, cmd.bin, cmd.args, repairTimeoutMs); - serviceOk = result.status === 0; - if (!serviceOk) { + const result = homePolicy.serviceAllowed ? run(job, cmd.bin, cmd.args, repairTimeoutMs) : { status: 0, timedOut: false }; + serviceOk = homePolicy.serviceAllowed && result.status === 0; + if (!serviceOk && homePolicy.serviceAllowed) { if (result.timedOut) { // UAC and scheduler mutation can outlive a fixed child deadline. Once the // worker kills that child, ownership is ambiguous: launching a foreground @@ -1216,7 +1225,8 @@ async function restartAfterUpdate( if (prevBake === undefined) delete process.env.OCX_BAKE_PORT; else process.env.OCX_BAKE_PORT = prevBake; } - if (serviceOk) { + if (!homePolicy.check()) return false; + if (serviceOk && homePolicy.serviceAllowed) { // Registration state cannot distinguish a serving supervisor from one that // registered and bound nothing — `launchctl list` reports both, `schtasks` // reports a task whose child exited immediately, a flapping supervisor can @@ -1243,9 +1253,11 @@ async function restartAfterUpdate( } // #7043: a managed install asks launchd/systemd first; only the manager's own job counts as recovered. - const managed = !serviceInstalled ? "failed" : await (io.restartServiceManagerFn ?? restartServiceManagerAfterUpdate)( + if (!homePolicy.check()) return false; + const managed = !serviceInstalled || !homePolicy.serviceAllowed ? "failed" : await (io.restartServiceManagerFn ?? restartServiceManagerAfterUpdate)( { port, hostname, release: io.releaseForServiceManagerFn }, line => updateJob(job, {}, line)); if (managed === "supervised") return; + if (managed === "refused") { updateJob(job, { status: "failed", restarted: false, error: "Automatic service restart was refused; check 'ocx service status' before restarting manually." }); return false; } if (managed === "unsettled") { updateJob(job, ...unsettledManagerRestartFailure(port)); return false; } const fallback = serviceInstalled ? "unsupervised" as const : undefined; // every exit below is unproven ownership // The service refresh ran outside the lease; take it back so the veto and the kills/start below stay serialized. @@ -1254,18 +1266,19 @@ async function restartAfterUpdate( updateJob(job, restartVeto.failed ? { status: "failed", restarted: false, error: restartVeto.notice } : { status: "succeeded", restarted: false }, restartVeto.notice); return false; } + if (!homePolicy.check()) return false; - const pid = readPid(); + const pid = (io.readPidFn ?? readPid)(); if (pid) { updateJob(job, {}, `Stopping current proxy PID ${pid}.`); try { - killProxy(pid); + (io.killProxyFn ?? killProxy)(pid); } catch { // A PID that resists taskkill must not abort recovery: reclaim + pinned start // below are the path that repairs stuck Windows listeners. } } - if (serviceInstalled) stopWindowsServiceWrappersBestEffort(); + if (serviceInstalled && homePolicy.serviceAllowed) (io.stopServiceWrappersFn ?? stopWindowsServiceWrappersBestEffort)(); // Reclaim before the pinned start: spawning `--port` while the old socket is busy // is how updates used to fail health checks or hop ports. const directAllow = reclaimKillAllowlist(); @@ -1361,7 +1374,8 @@ async function restartAfterUpdate( killSpawnAttempt(lastChild); lastChild = null; } - preparePort(job, port, listPids, aliveFn, verifyOcx); + if (!homePolicy.check()) return false; + preparePort(job, port, listPids, aliveFn, verifyOcx, serviceInstalled && homePolicy.serviceAllowed ? (io.stopServiceWrappersFn ?? stopWindowsServiceWrappersBestEffort) : undefined); const ready = await waitForGhost( port, hostname, @@ -1378,6 +1392,7 @@ async function restartAfterUpdate( ); continue; } + if (!homePolicy.check()) return false; const child = spawnPinnedStart(job, job.installer, port, launcher); lastChild = child; const retireChild = () => { @@ -1419,35 +1434,6 @@ function formatPortHolders( return `holders=[${holders.join(", ") || "none"}] allow=[${allow.join(", ") || "none"}]`; } -/** Stop the installed Windows backend and best-effort kill surviving :loop wrappers. */ -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"), - }); -} - /** Exposed for tests: drives the non-service restart path with injected io. */ export function restartAfterUpdateForTests( job: UpdateJobState, @@ -1805,8 +1791,9 @@ export interface GuiUpdateWorkerIo { resolvePnpmActiveLauncherFn?: (owner: PnpmGlobalOwner) => string | null; /** Restart seams used by focused worker tests; the verified launcher is always injected. */ restartIo?: RestartIo; - /** Resolves who owns the runtime; defaults to the shared service install state. */ - resolveOwnershipFn?: () => ServiceOwnershipResolution; + resolveOwnershipFn?: () => ServiceOwnershipResolution; // Defaults to the shared service install state. + serviceHomeAuthorityFn?: UpdateServiceHomeInspector; + launcherSupportsUpdateHomeFn?: (launcher: string, node: string) => boolean; runCommandFn?: ( job: UpdateJobState, bin: string, @@ -1906,7 +1893,12 @@ export async function runGuiUpdateWorker( } } - if (process.platform === "win32") { + let homeRefusal: string | undefined; + const homePolicy = prepareWorkerUpdateHome(cmd, check.installer, activeLauncher, nodeBin(), channel, + message => { homeRefusal = message; job = updateJob(job!, {}, message); }, io.serviceHomeAuthorityFn, io.launcherSupportsUpdateHomeFn); + if (!homePolicy) { updateJob(job, { status: "failed", restarted: false, error: homeRefusal }); return; } + job = updateJob(job, { command: cmd.display }); + if (!homePolicy.packageOnly && process.platform === "win32") { try { const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows"); const tray = getWindowsTrayStatus(); @@ -1934,6 +1926,11 @@ export async function runGuiUpdateWorker( - 선택 근거: 현재 CLI의 package-manager self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다. */ const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS); + if (packageOnlyUpdateSucceeded(cmd, result.status, homePolicy.packageOnly)) { + homePolicy.check({ kind: "unknown" }); updateJob(job, { status: "succeeded", restarted: false }, + "Update installed. Restart the proxy manually after checking 'ocx service status'."); + return; + } if (result.status !== 0) { if (check.installer === "pnpm") { const verifiedLauncher = pnpmOwner @@ -1968,7 +1965,7 @@ export async function runGuiUpdateWorker( if (trayWasRunning) runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); } } - + if (!homePolicy.check()) { updateJob(job, { status: "failed", restarted: false }); return; } if (restart) { const outcome = await runUpdateRestartWithOwnershipLease(io.resolveOwnershipFn, async lease => { job = updateJob(job!, { status: "restarting" }, "Update installed. Restarting proxy..."); diff --git a/src/update/service-home-authority.ts b/src/update/service-home-authority.ts new file mode 100644 index 00000000000..1b7148e6106 --- /dev/null +++ b/src/update/service-home-authority.ts @@ -0,0 +1,35 @@ +import { isDeepStrictEqual } from "node:util"; +import { createHash } from "node:crypto"; +import { resolveCodexSqliteHome } from "../codex/paths"; +import { inspectNativeCodexOwnership, type OwnershipDeps } from "../integrations/native/ownership-preflight"; +import { compareServicePathToInstall, currentServiceHomes, inspectServiceStateEvidence, resolveServiceState, serviceStatePaths } from "../service/state"; + +export { createUpdateServiceHomePolicy, updateServiceHomeRefusal } from "./service-home-policy.mjs"; +export type UpdateServiceHomeAuthority = import("./service-home-policy.mjs").UpdateServiceHomeAuthority; +export type UpdateServiceHomeInspector = () => UpdateServiceHomeAuthority; + +/** Automatic restart authority, distinct from the interactive guard's fail-open policy. */ +export function inspectUpdateServiceHome(deps: OwnershipDeps = {}): UpdateServiceHomeAuthority { + try { + const paths = deps.statePaths ?? serviceStatePaths(); + const homes = deps.currentHomes ?? currentServiceHomes(); + const before = resolveServiceState(inspectServiceStateEvidence(paths)); + if (before.kind === "unknown") return { kind: "unknown" }; + const ownership = inspectNativeCodexOwnership({ ...deps, statePaths: paths, currentHomes: homes }); + if (ownership.ownership === "unknown") return { kind: "unknown" }; + // 'owned' alone includes no record and no registration. Preserve that absence. + let kind: UpdateServiceHomeAuthority["kind"] = ownership.ownership === "foreign" ? "foreign" : before.kind === "none" ? "absent" : "same"; + if (kind === "same" && before.kind === "state" && before.state.codexSqliteHome !== undefined) { + const sqlite = compareServicePathToInstall(before.state.codexSqliteHome, + resolveCodexSqliteHome({ codexHome: homes.codexHome }), { realpathSync: deps.realpathSync }); + if (sqlite !== "same") kind = sqlite === "different" ? "foreign" : "unknown"; + } + const after = resolveServiceState(inspectServiceStateEvidence(paths)); + if (!isDeepStrictEqual(before, after) || !isDeepStrictEqual(homes, deps.currentHomes ?? currentServiceHomes())) { + return { kind: "unknown" }; + } + return kind === "foreign" ? { kind, subject: createHash("sha256").update(JSON.stringify({ before, homes })).digest("hex") } : { kind }; + } catch { + return { kind: "unknown" }; + } +} diff --git a/src/update/service-home-package-only-protocol.json b/src/update/service-home-package-only-protocol.json new file mode 100644 index 00000000000..c40013fa896 --- /dev/null +++ b/src/update/service-home-package-only-protocol.json @@ -0,0 +1 @@ +{"schemaVersion":1,"packageOnlyProtocolVersion":1,"entry":"__update-package-only-probe"} diff --git a/src/update/service-home-policy.d.mts b/src/update/service-home-policy.d.mts new file mode 100644 index 00000000000..cef5dc1ae2a --- /dev/null +++ b/src/update/service-home-policy.d.mts @@ -0,0 +1,19 @@ +export type UpdateServiceHomeAuthority = { + readonly kind: "same" | "foreign" | "unknown" | "absent"; + readonly subject?: string; +}; +export interface UpdateServiceHomePolicy { + readonly initial: UpdateServiceHomeAuthority; + readonly serviceAllowed: boolean; + readonly packageOnly: boolean; + checkPackage(home?: UpdateServiceHomeAuthority): boolean; + check(home?: UpdateServiceHomeAuthority): boolean; +} +export declare const UPDATE_HOME_SCHEMA_VERSION: 1; +export declare const UPDATE_HOME_LAUNCHER_PROTOCOL: 1; +export declare const UPDATE_PACKAGE_ONLY_EXIT_CODE: 75; +export declare function isUpdateHomeProbe(args: string[]): boolean; +export declare function parseUpdateHomeProbe(value: unknown, options?: { requireLauncher?: boolean }): UpdateServiceHomeAuthority; +export declare function createUpdateServiceHomePolicy(inspect: () => UpdateServiceHomeAuthority, + refuse: (message: string) => void, packageOnly?: boolean): UpdateServiceHomePolicy; +export declare function updateServiceHomeRefusal(authority: UpdateServiceHomeAuthority): string | null; diff --git a/src/update/service-home-policy.mjs b/src/update/service-home-policy.mjs new file mode 100644 index 00000000000..18c68af52d4 --- /dev/null +++ b/src/update/service-home-policy.mjs @@ -0,0 +1,72 @@ +/** Pure policy shared by the Node launcher and Bun update paths. */ +/** @typedef {{ readonly kind: "same" | "foreign" | "unknown" | "absent", readonly subject?: string }} UpdateServiceHomeAuthority */ + +export const UPDATE_HOME_SCHEMA_VERSION = 1; +export const UPDATE_HOME_LAUNCHER_PROTOCOL = 1; +export const UPDATE_PACKAGE_ONLY_EXIT_CODE = 75; + +/** Exact argv only: malformed variants must not enter the ordinary status collector. + * @param {string[]} args */ +export function isUpdateHomeProbe(args) { + return args.length === 1 && args[0] === "__update-home-probe"; +} + +/** @param {unknown} value @param {{ requireLauncher?: boolean }} [options] + * @returns {UpdateServiceHomeAuthority} */ +export function parseUpdateHomeProbe(value, { requireLauncher = false } = {}) { + if (!value || typeof value !== "object" || Array.isArray(value)) return { kind: "unknown" }; + const record = /** @type {Record} */ (value); + const keys = Object.keys(record); + if (keys.some(key => !["schemaVersion", "kind", "subject", "launcherProtocolVersion"].includes(key)) + || record.schemaVersion !== UPDATE_HOME_SCHEMA_VERSION + || (requireLauncher && record.launcherProtocolVersion !== UPDATE_HOME_LAUNCHER_PROTOCOL) + || (record.launcherProtocolVersion !== undefined && record.launcherProtocolVersion !== UPDATE_HOME_LAUNCHER_PROTOCOL)) return { kind: "unknown" }; + if (record.kind === "foreign") { + return typeof record.subject === "string" && /^[a-f0-9]{64}$/.test(record.subject) + ? { kind: "foreign", subject: record.subject } : { kind: "unknown" }; + } + if (record.subject !== undefined) return { kind: "unknown" }; + return record.kind === "same" || record.kind === "absent" || record.kind === "unknown" + ? { kind: record.kind } : { kind: "unknown" }; +} + +/** A foreign registration never manages this foreground runtime. A changed claim is not absence. + * @param {() => UpdateServiceHomeAuthority} inspect @param {(message: string) => void} refuse + * @param {boolean} [packageOnly] A parent's restriction can only remove runtime authority. */ +export function createUpdateServiceHomePolicy(inspect, refuse, packageOnly = false) { + const initial = inspect(); + let current = initial; + packageOnly ||= initial.kind === "unknown"; + const checkAuthority = (home) => { + current = home; + const allowed = initial.kind === "same" ? home.kind === "same" || home.kind === "absent" + : initial.kind === "absent" ? home.kind === "absent" + : initial.kind === "foreign" && home.kind === "foreign" && initial.subject !== undefined && initial.subject === home.subject; + if (allowed) return true; + refuse(updateServiceHomeRefusal(home) + ?? "Automatic update restart refused: service registration authority changed. Check 'ocx service status' before restarting manually."); + return false; + }; + return { + initial, + packageOnly, + get serviceAllowed() { return !packageOnly && current.kind === "same"; }, + checkPackage(home = inspect()) { + return initial.kind === "unknown" && home.kind === "unknown" || checkAuthority(home); + }, + check(home = inspect()) { + if (!packageOnly) return checkAuthority(home); + refuse(updateServiceHomeRefusal({ kind: "unknown" })); + return false; + }, + }; +} + +/** Static persisted guidance: never echo recorded paths or a probe's error output. + * @param {UpdateServiceHomeAuthority} authority @returns {string | null} */ +export function updateServiceHomeRefusal(authority) { + if (authority.kind === "same" || authority.kind === "absent") return null; + return authority.kind === "foreign" + ? "Automatic update restart refused: the installed service belongs to different directories. Run 'ocx service status' from the registered installation; no restart was authorized." + : "Automatic update restart refused: service registration ownership could not be verified. Check 'ocx service status' and the recorded installation before restarting manually."; +} diff --git a/src/update/service-home-probe-entry.ts b/src/update/service-home-probe-entry.ts new file mode 100644 index 00000000000..5cb6a27e6a0 --- /dev/null +++ b/src/update/service-home-probe-entry.ts @@ -0,0 +1,9 @@ +import { inspectUpdateServiceHome } from "./service-home-authority"; +import { isUpdateHomeProbe } from "./service-home-policy.mjs"; + +// This entry deliberately does not import the CLI root, status collector or launcher. +// --no-env-file is supplied by the Node parent; malformed/old entrypoints are unknown. +if (import.meta.main) { + if (!isUpdateHomeProbe(process.argv.slice(2))) process.exit(1); + console.log(JSON.stringify({ schemaVersion: 1, ...inspectUpdateServiceHome() })); +} diff --git a/src/update/service-home-probe.d.mts b/src/update/service-home-probe.d.mts new file mode 100644 index 00000000000..a138cf3c4ab --- /dev/null +++ b/src/update/service-home-probe.d.mts @@ -0,0 +1,15 @@ +import type { UpdateServiceHomeAuthority } from "./service-home-policy.mjs"; +import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process"; +export declare const UPDATE_HOME_PROBE_ARGS: string[]; +export declare function supportsUpdateHomeProbe(launcher: string, read?: (path: string) => string): boolean; +export declare function updateHomeProbeEnvironment(env: Record, + homes: { codexHome: string; opencodexHome: string }): Record; +export declare function probeUpdateLauncherHome(launcher: string, run: Parameters[0], + node: string, env: Record, read?: (path: string) => string): UpdateServiceHomeAuthority; +export declare function probeUpdateServiceHome( + run: (bin: string, args: string[], options: SpawnSyncOptionsWithStringEncoding) => { status: number | null; stdout?: unknown; error?: unknown; signal?: unknown }, + bin: string, prefix: string[], options?: { requireLauncher?: boolean; env?: Record }, +): UpdateServiceHomeAuthority; + +export declare function probeUpdatePackageOnlyLauncher(launcher: string, run: Parameters[0], + node: string, env: Record, read?: (path: string) => string): boolean; diff --git a/src/update/service-home-probe.mjs b/src/update/service-home-probe.mjs new file mode 100644 index 00000000000..d0752d786e3 --- /dev/null +++ b/src/update/service-home-probe.mjs @@ -0,0 +1,79 @@ +import { parseUpdateHomeProbe } from "./service-home-policy.mjs"; +import { readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export const UPDATE_HOME_PROBE_ARGS = ["__update-home-probe"]; + +/** A trusted installed-file compatibility claim, not a tamper attestation. + * Never execute an old launcher merely to ask whether it supports this protocol. + * @param {string} launcher @param {(path: string) => string} [read] */ +export function supportsUpdateHomeProbe(launcher, read = path => { + if (statSync(path).size > 4096) throw new Error("oversized declaration"); + return readFileSync(path, "utf8"); +}) { + try { + const value = JSON.parse(read(join(dirname(launcher), "..", "src", "update", "service-home-protocol.json"))); + return value?.schemaVersion === 1 && value?.launcherProtocolVersion === 1 + && value?.entry === "__update-home-probe" && Object.keys(value).length === 3; + } catch { return false; } +} + +/** Preserve user/home/auth inputs; remove only internal launch/service/delegation tokens. + * @param {Record} env + * @param {{ codexHome: string, opencodexHome: string }} homes */ +export function updateHomeProbeEnvironment(env, homes) { + const clean = { ...env, CODEX_HOME: homes.codexHome, OPENCODEX_HOME: homes.opencodexHome }; + for (const key of ["OCX_NODE_LAUNCH_CONTEXT", "OCX_PRE_BUN_ANTHROPIC_ENV", "OCX_SERVICE", "OCX_SERVICE_MANAGED", + "OCX_UPDATE_RESTART_CHILD", "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN", "OCX_SIBLING_OF_PORT", "OCX_SIBLING_HANDOFF_NONCE", + "OCX_BUN_RUNTIME_SOURCE", "OCX_BUN_RUNTIME_PATH"]) delete clean[key]; + return clean; +} + +/** @param {string} launcher @param {Parameters[0]} run + * @param {string} node @param {Record} env + * @param {(path: string) => string} [read] */ +export function probeUpdateLauncherHome(launcher, run, node, env, read) { + if (!supportsUpdateHomeProbe(launcher, read)) return { kind: "unknown" }; + return probeUpdateServiceHome(run, node, [launcher], { requireLauncher: true, env }); +} + +/** A narrow local subprocess protocol. It never falls back to ordinary status. + * @param {(bin: string, args: string[], options: object) => { status: number | null, stdout?: unknown, error?: unknown, signal?: unknown }} run + * @param {string} bin @param {string[]} prefix + * @param {{ requireLauncher?: boolean, env?: Record }} [options] + * @returns {import('./service-home-policy.mjs').UpdateServiceHomeAuthority} */ +export function probeUpdateServiceHome(run, bin, prefix, { requireLauncher = false, env } = {}) { + try { + const result = run(bin, [...prefix, ...UPDATE_HOME_PROBE_ARGS], { + encoding: "utf8", timeout: 8_000, maxBuffer: 4096, windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], ...(env ? { env } : {}), + }); + if (result.status !== 0 || result.error || result.signal || typeof result.stdout !== "string" + || Buffer.byteLength(result.stdout, "utf8") > 4096) return { kind: "unknown" }; + return parseUpdateHomeProbe(JSON.parse(result.stdout), { requireLauncher }); + } catch { return { kind: "unknown" }; } +} + +/** Separate capability: keep the original home-probe declaration readable by running v1 updaters. + * @param {string} launcher @param {Parameters[0]} run + * @param {string} node @param {Record} env + * @param {(path: string) => string} [read] */ +export function probeUpdatePackageOnlyLauncher(launcher, run, node, env, read = path => { + if (statSync(path).size > 4096) throw new Error("oversized declaration"); + return readFileSync(path, "utf8"); +}) { + if (!supportsUpdateHomeProbe(launcher, read)) return false; + try { + const declaration = JSON.parse(read(join(dirname(launcher), "..", "src", "update", "service-home-package-only-protocol.json"))); + if (declaration?.schemaVersion !== 1 || declaration?.packageOnlyProtocolVersion !== 1 + || declaration?.entry !== "__update-package-only-probe" || Object.keys(declaration).length !== 3) return false; + const result = run(node, [launcher, "__update-package-only-probe"], { + encoding: "utf8", timeout: 8_000, maxBuffer: 4096, windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], env, + }); + if (result.status !== 0 || result.error || result.signal || typeof result.stdout !== "string" + || Buffer.byteLength(result.stdout, "utf8") > 4096) return false; + const response = JSON.parse(result.stdout); + return response?.schemaVersion === 1 && response?.packageOnlyProtocolVersion === 1 && Object.keys(response).length === 2; + } catch { return false; } +} diff --git a/src/update/service-home-protocol.json b/src/update/service-home-protocol.json new file mode 100644 index 00000000000..0d3e739a3f9 --- /dev/null +++ b/src/update/service-home-protocol.json @@ -0,0 +1 @@ +{"schemaVersion":1,"launcherProtocolVersion":1,"entry":"__update-home-probe"} diff --git a/structure/ops/update-supervised-recovery.md b/structure/ops/update-supervised-recovery.md index 8ea35e16b9d..f9cc640a4e3 100644 --- a/structure/ops/update-supervised-recovery.md +++ b/structure/ops/update-supervised-recovery.md @@ -7,7 +7,7 @@ that refresh fails or leaves a non-viable manager. ## Manager first -Both `ocx update` lanes, the npm launcher in `bin/ocx.mjs` and the Bun updater in +For matching service homes, both `ocx update` lanes, the npm launcher in `bin/ocx.mjs` and the Bun updater in `src/update/index.ts`, ask the manager to restart the job it already holds before any detached start, through the plain synchronous ESM in `src/update/supervised-recovery.mjs`: launchd with `launchctl kickstart -k gui//com.opencodex.proxy`, the systemd user manager with @@ -38,9 +38,80 @@ the manager the same way before its direct fallback; that path already exits non An ownership veto at the direct start (Desktop or another owner claimed the runtime meanwhile) is the correct outcome and is not reported as unsupervised. +## Service home authority + +The Node launcher, Bun updater and dashboard worker use the same transition policy in +`src/update/service-home-policy.mjs`. Their direct recovery helpers reacquire the ownership +lease themselves and recheck both home authority and runtime ownership before spawning, including +on failed-update recovery. Matching-home service refresh runs unleased; home/owner evidence is +checked again immediately before manager recovery. A distinct refusal never permits direct fallback. +An initially unknown service home selects a package-only transaction: no proxy/tray stop, service +refresh, shim repair or direct start. Success prints the static refusal and manual restart guidance; +the dashboard records success with `restarted: false`. Package replacement still requires stable +durable ownership, no Desktop veto and a dead package runtime. Home evidence is rechecked before +replacement; changed authority refuses the transaction. Initial uncertainty never grants recovery +permission later, even if installing the package repairs its probe. Unresolved pending teardown +receipts, including quarantined unreadable receipts, also block package-only replacement. +The worker passes its restriction as `--update-package-only`; package-only-capable launchers preserve it +in both Node and Bun update paths even when their own probe matches. A package-only Bun worker +uses that guarded launcher instead of its ordinary raw package-manager command. Delegated launchers +also receive `--update-worker-result`: successful package-only transactions report exit 75 instead +of ordinary success. The worker handles this restricted-success result before any tray refresh or +restart, even when its own probe still matches. Unmarked package-manager exit 75 is a failure. + +The dashboard worker checks the unchanged v1 `src/update/service-home-protocol.json` plus +`src/update/service-home-package-only-protocol.json`, then the delegated launcher's exact private +`__update-package-only-probe` response. This separate capability probe performs no Bun resolution +or home inspection. Missing declarations, failed calls and malformed responses refuse delegation. +Keeping the original home-probe declaration and response at v1 lets already-running v1 updaters +inspect and recover after installing the new package. `src/update/job-update-home.ts` owns the +capability gate. Markers claim compatibility of trusted installed files, not tamper attestation. + +The new Node launcher intercepts this exact private command before Desktop handoff, boot restore +and ordinary install-capable Bun resolution. It resolves Bun and its compatibility floor from the +actual probe target's package with installation disabled, then uses +`--no-env-file` with `src/update/service-home-probe-entry.ts`, which imports the authoritative +inspector rather than the CLI root/status collector. No full status fallback exists. Probes have +an eight-second timeout and 4 KiB output ceiling; errors, unsupported versions and malformed JSON +are unknown. Explicit home snapshots preserve the SQLite-home resolver and user/auth environment; +only internal launcher, service, restart-child, sibling and delegation tokens are removed. +Missing Bun in this probe returns unknown rather than exiting its caller; ordinary launcher +resolution keeps its installation and terminal failure behavior. Nested probe deadlines can refuse +a valid installation during slow cold starts; the bounds are not a readiness guarantee. +Successful updates recover a foreground runtime only when this update attempted its stop. A cold +CLI update without prior runtime state stays stopped and prints the existing manual-start guidance. + +An already-running old Node parent keeps its old implementation after package replacement; these +checks cannot retrofit it. Automatic delegation requires the guarded launcher already present. +Existing stop authorization remains unchanged: stable foreign foreground recovery applies only +when that runtime's own stop/update is permitted and succeeds, not to every cross-home combination. + ## The dashboard update worker -The dashboard restart worker in `src/update/job.ts` follows the same order through +`src/update/service-home-authority.ts` distinguishes matching service homes, a known foreign +registration, unknown evidence and confirmed absence. It combines service-state resolution with +the unattended native ownership inspector and the lifecycle SQLite-home comparison; physical +directory aliases remain equivalent. A changing state read is unknown. The inspector's `owned` +result alone does not establish a registration, because it also includes confirmed absence. + +The dashboard worker permits service repair, manager restart and global Windows service stops +only for a matching home. Stable foreign registration and confirmed absence retain the captured +foreground runtime's existing PID/port and lease checks for direct recovery, without touching +the other registration or reporting that foreground runtime as UNSUPERVISED. Unknown evidence +refuses automatic restart. A matching home that becomes foreign or unknown after the unleased +refresh refuses both manager recovery and direct fallback. A foreign record must remain the same +observed subject for foreground recovery. Refusals persist static manual-status guidance, never +recorded paths or probe output. + +Home authority is checked before initial reclaim, after releasing the repair lease, before manager +recovery, after direct-fallback lease reacquisition and at pinned-start preparation/send boundaries. +The manager helper checks independently after lease release, immediately before contacting the +manager, and returns distinct `refused` for foreign or unknown homes; callers cannot treat this as +an ordinary failed restart. `src/update/job-windows-restart.ts` owns the unchanged scoped Windows +wrapper cleanup. Pinned direct recovery receives global service-stop authority only for a matching +managed home; foreground recovery does not acquire that authority. + +For a matching managed home, the dashboard restart worker in `src/update/job.ts` follows the same order through `src/update/job-supervised-restart.ts`, which sits beside it because `job.ts` is at the repository size cap. When a service-managed install's refresh fails, is not viable, or never serves, the worker asks the manager to restart its job before it re-acquires the lease for a direct start, @@ -69,3 +140,9 @@ non-zero exit. `tests/update/update-job-supervised-restart.test.ts` drives the dashboard worker: the proof against stale and foreign records, a supervised restart skipping the direct start, an unsettled restart failing the job, and a healthy direct fallback failing it as UNSUPERVISED. +`tests/update/update-service-home.test.ts` covers real two-home records, stable foreign foreground +recovery, unknown evidence, aliases, SQLite homes, state changes, post-release refusal, pinned-start +service-stop authority and matching-home recovery. Manager, probe and process side effects are injected. +`tests/update/update-service-home-policy.test.ts` covers schema/capability validation and transition +semantics; `tests/update/update-home-launcher.test.ts` and `tests/update/update-home-recovery.test.ts` +exercise the actual Node/Bun helper bodies with injected lease, manager, owner and spawn boundaries. diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index 2938f1c165e..67d3a963706 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -19,7 +19,7 @@ describe("ocx.mjs package launcher (source invariants)", () => { const start = source.indexOf("function resolveBun("); const end = source.indexOf("// `ocx update --help`", start); const resolver = source.slice(start, end); - expect(resolver.indexOf("bunDir = bunBinDir()")).toBeGreaterThan(resolver.indexOf('source: "override"')); + expect(resolver.indexOf("bunDir = bunBinDir(launcherPath)")).toBeGreaterThan(resolver.indexOf('source: "override"')); expect(resolver.indexOf("findPathBun(")).toBeGreaterThan(resolver.indexOf("spawnSync(process.execPath, [installJs]")); expect(resolver.indexOf("fail(bunDir ?")).toBeGreaterThan(resolver.indexOf("findPathBun(")); expect(resolver).toContain('source: "process"'); @@ -28,7 +28,7 @@ describe("ocx.mjs package launcher (source invariants)", () => { test("fallback derives its compatibility floor from the package dependency", () => { expect(source).toContain('typeof pkg.dependencies?.bun === "string" ? pkg.dependencies.bun : ""'); - expect(source).toContain("pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS"); + expect(source).toContain("pinnedVersion: pinnedBunVersion(launcherPath), deadlineMs: PATH_BUN_PROBE_BUDGET_MS"); // Bounded, but sized for a cold first run of a scanned bun.exe on Windows. expect(source).toContain("const PATH_BUN_PROBE_BUDGET_MS = 5_000;"); expect(source).not.toContain('pinnedVersion: "1.4.2"'); @@ -173,10 +173,10 @@ describe("ocx.mjs package launcher (source invariants)", () => { expect(source).toContain("const overridePath = resolve(override);"); expect(source).toContain('if (isRealBunBinary(overridePath)) return { path: overridePath, source: "override" };'); - const resolveStart = source.indexOf("function resolveBun({ allowInstall = true } = {}) {"); + const resolveStart = source.indexOf("function resolveBun({ allowInstall = true, launcherPath, allowMissing = false } = {}) {"); const overrideCheck = source.indexOf("process.env[BUN_OVERRIDE_ENV]?.trim()", resolveStart); const overrideResolve = source.indexOf("resolve(override)", overrideCheck); - const bundledLookup = source.indexOf("bunDir = bunBinDir()", resolveStart); + const bundledLookup = source.indexOf("bunDir = bunBinDir(launcherPath)", resolveStart); expect(resolveStart).toBeGreaterThanOrEqual(0); expect(overrideCheck).toBeGreaterThan(resolveStart); expect(overrideResolve).toBeGreaterThan(overrideCheck); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..1c5f3614be7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1138,6 +1138,11 @@ "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", "update-refresh.test.ts": "update", "update-restart-lease.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-classification.test.ts": "update", "update-stop-first.test.ts": "update", "update-transactional-leftovers.test.ts": "update", "update-transactional.test.ts": "update", "update-tray-handoff.test.ts": "update", "update-tree-ownership.test.ts": "update", diff --git a/tests/helpers/update-bun-ownership-child.ts b/tests/helpers/update-bun-ownership-child.ts index fa8c53a9692..5a8d2436d02 100644 --- a/tests/helpers/update-bun-ownership-child.ts +++ b/tests/helpers/update-bun-ownership-child.ts @@ -15,7 +15,11 @@ const authority = join(home, "authority.json"); const runtime = join(home, "runtime-port.json"); const leaseModule = pathToFileURL(repoPath("src/service/ownership-mutation-lease.mjs")).href; const trace: Array> = []; -let running = true; +let running = (!scenario.startsWith("home-unknown") && !scenario.startsWith("parent-package-only")) || scenario === "home-unknown-live"; +if (scenario.startsWith("parent-package-only")) process.argv.push("--update-package-only"); +if (scenario === "parent-package-only-result") process.argv.push("--update-worker-result"); +if (scenario === "home-unknown-pending") writeFileSync(join(home, "pending-teardown-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.unreadable.json"), "{broken"); +let homeReads = 0; const direct = scenario.startsWith("direct-"); const reserved = direct ? Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("fixture") }) : undefined; const port = reserved?.port ?? 23456; @@ -56,6 +60,16 @@ mock.module(repoPath("src/service.ts"), () => ({ ...service, ownership: { owner: row.owner, installId: row.installId, consentGeneration: row.consentGeneration } }; }, })); +mock.module(repoPath("src/update/service-home-authority.ts"), () => ({ + inspectUpdateServiceHome: () => { + homeReads++; + if (scenario === "home-changed-after-stop" && homeReads > 3) return { kind: "unknown" }; + if (scenario === "home-unknown-changed" && homeReads > 2) return { kind: "same" }; + if (scenario === "home-changed" && homeReads > 2) return { kind: "unknown" }; + if (scenario.startsWith("home-unknown")) return { kind: "unknown" }; + return { kind: direct ? "absent" : "same" }; + }, +})); const state = await import("../../src/config/process-state"); mock.module(repoPath("src/config/process-state.ts"), () => ({ ...state, readPid: () => running ? 4321 : null, @@ -125,7 +139,7 @@ mock.module("node:child_process", () => ({ ...processTools, if (phase === "replacement") { if (scenario === "replacement-throw") throw new Error("injected replacement failure"); if (scenario === "foreign-owner") writeFileSync(authority, JSON.stringify({ revision: 2, owner: "desktop", installId: "owner-b", consentGeneration: 2 })); - if (direct || scenario === "package-fail" || scenario === "foreign-owner" || scenario === "recovery-throw") return { status: 1 }; + if (direct || scenario === "home-unknown-fail" || scenario === "package-fail" || scenario === "foreign-owner" || scenario === "recovery-throw") return { status: 1 }; } if (phase === "recovery") { if (scenario === "recovery-throw") throw new Error("injected recovery failure"); diff --git a/tests/helpers/update-home-fixtures.ts b/tests/helpers/update-home-fixtures.ts new file mode 100644 index 00000000000..0bc3e81edc0 --- /dev/null +++ b/tests/helpers/update-home-fixtures.ts @@ -0,0 +1,14 @@ +import type { RestartIo } from "../../src/update/job"; + +/** Non-home restart tests explicitly own their fake service and never touch a live manager. */ +export const sameHomeRestartIo: Pick = { + serviceHomeAuthorityFn: () => ({ kind: "same" }), + stopServiceWrappersFn: () => {}, + // These fixtures exercise direct fallback, not host-dependent launchd/systemd probes. + // Preserve the real manager boundary's lease release before the fallback reacquires it. + restartServiceManagerFn: async target => { target.release?.(); return "failed"; }, +}; +export const absentHomeRestartIo: typeof sameHomeRestartIo = { + ...sameHomeRestartIo, + serviceHomeAuthorityFn: () => ({ kind: "absent" }), +}; diff --git a/tests/update/update-bun-ownership-lease.test.ts b/tests/update/update-bun-ownership-lease.test.ts index afb8e05f43c..4e22147755d 100644 --- a/tests/update/update-bun-ownership-lease.test.ts +++ b/tests/update/update-bun-ownership-lease.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { readUpdateRuntimeTarget, withUpdateOwnershipLease } from "../../src/update/ownership-transaction"; @@ -27,7 +27,7 @@ async function run(scenario: string) { while (alive() && Date.now() < deadline) await Bun.sleep(20); expect(alive(), "owned recovery fixture must exit").toBe(false); } - return { exit, trace, log: out + err }; + return { exit, trace, log: out + err, pendingReceipt: existsSync(home.path("pending-teardown-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.unreadable.json")) }; } finally { home.remove(); } } @@ -171,3 +171,29 @@ test.skipIf(process.platform === "win32").each(["direct-spawn-error", "direct-ti if (scenario === "direct-foreign") expect(result.trace.some(row => row.phase === "health" && row.pidBound)).toBe(true); expect(result.trace.at(-1)).toMatchObject({ reacquired: true, parentToken: false }); }, 30_000); + +for (const scenario of ["home-unknown", "home-unknown-fail", "parent-package-only", "parent-package-only-result"]) test(`${scenario} replaces only the package and releases its lease`, async () => { + const result = await run(scenario); + expect(result.exit, result.log).toBe(scenario.endsWith("result") ? 75 : scenario.endsWith("fail") ? 1 : 0); + expect(result.trace.filter(row => !["replacement", "after"].includes(row.phase))).toEqual([]); + expect(result.trace.find(row => row.phase === "replacement")).toMatchObject({ contenderBlocked: true, delegated: false }); + expect(result.trace.at(-1)).toMatchObject({ reacquired: true, parentToken: false }); + if (!scenario.endsWith("fail")) { + expect(result.log).toContain("ownership could not be verified"); + expect(result.log).toContain("Restart the proxy:"); + } +}); + +test("a home that becomes unknown before stop blocks package replacement", async () => { + const result = await run("home-changed"); + expect(result.exit, result.log).toBe(1); + expect(result.trace.map(row => row.phase)).toEqual(["after"]); +}); + +for (const scenario of ["home-unknown-live", "home-unknown-changed", "home-changed-after-stop", "home-unknown-pending"]) test(`${scenario} still refuses package replacement`, async () => { + const result = await run(scenario); + expect(result.exit, result.log).toBe(1); + expect(result.trace.map(row => row.phase)).toEqual(scenario === "home-changed-after-stop" ? ["stop", "after"] : ["after"]); + if (scenario === "home-unknown-pending") expect(result.pendingReceipt).toBe(true); + expect(result.trace.at(-1)).toMatchObject({ reacquired: true, parentToken: false }); +}); diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index 5826d96d6fc..9d7067c2bcb 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -172,7 +172,7 @@ describe("both updaters consult the shared rule", () => { test("the Bun updater gates its stop, its refresh and its restart hint", () => { expect(bunPath).toContain("from \"./runtime-ownership.mjs\""); - expect(bunPath).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(bunPath).toContain("if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); expect(bunPath).toContain("if (!runtimePlan.mayReplacePackage)"); expect(bunPath).toContain("if (postInstallPlan.mayRestoreService) {"); }); @@ -342,9 +342,9 @@ describe("live Desktop update authority", () => { } }); -async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEvidence, runtimeTrusted = true): Promise { +async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEvidence, runtimeTrusted = true, homeAllowed = true): Promise { const source = readFileSync(repoPath(lane === "Bun" ? "src/update/index.ts" : "bin/ocx.mjs"), "utf8"); - const start = source.indexOf(lane === "Bun" ? " if (runtimePlan.mayStopRuntime &&" : " if (stopNeeded) {"); + const start = source.indexOf(lane === "Bun" ? " if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime &&" : " if (stopNeeded) {"); const end = source.indexOf(lane === "Bun" ? ' if (stopStdio === "pipe")' : " const stillHasRuntimeState", start); expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); const counts = [0, 0, 0]; @@ -366,6 +366,7 @@ async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEviden try { await run({ runtimePlan, serviceWasInstalled: true, readPid: () => 321, readRuntimePort: () => ({}), + homePolicy: { check: () => homeAllowed, serviceAllowed: true, packageOnly: false }, pendingTeardownOutstanding: () => false, stopNeeded: true, resolvedRuntimeOwnership: async () => owner, readOwnership: () => owner, planUpdateRuntimeHandling, supervisionLatch: latch, inspectDesktopSupervision: inspect, @@ -387,11 +388,15 @@ test("Bun pre-stop uses inspector correlation when runtime identity is untrusted expect(await exercisePreStop("Bun", { kind: "none" }, false)).toEqual([1, 1, 1]); }); +test("Bun newly uncertain home refuses before stop and package replacement", async () => { + expect(await exercisePreStop("Bun", { kind: "none" }, true, false)).toEqual([0, 0, 0]); +}); + describe("Node service refresh mutation boundaries", () => { // The same function handles normal restoration and stopped-runtime failure recovery. const source = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); const start = source.indexOf(" function refreshBackgroundServiceOrStartDirect() {"); - const end = source.indexOf(" const updateLease =", start); + const end = source.indexOf(" let updateLease =", start); const run = new Function("deps", `with (deps) { ${source.slice(start, end)} return refreshBackgroundServiceOrStartDirect(); @@ -410,6 +415,7 @@ describe("Node service refresh mutation boundaries", () => { const warnings: string[] = []; const result = run({ observeSupervision: () => latch.observe(evidence.shift() ?? { kind: "none" }), + homePolicy: { check: () => true, serviceAllowed: true }, authorityLauncher: "fixture", planUpdateRuntimeHandling, readOwnership: () => ({ ownership: null }), process: { env: {}, execPath: "fixture" }, bakePort: 12345, postUpdateLauncher: "fixture", console: { log() {}, warn(line: string) { warnings.push(line); } }, mutationChildEnvironment: () => ({}), @@ -450,7 +456,7 @@ describe("Node service refresh mutation boundaries", () => { expect(outcome.managerRestarts).toBe(1); expect(outcome.result).toBe("unsupervised"); expect(outcome.warnings).toContain("UNSUPERVISED 12345"); - expect(source).toContain('if (refreshed === "unsupervised" || refreshed === "unsettled") process.exit(1);'); + expect(source).toContain('if (refreshed === "unsupervised" || refreshed === "unsettled" || refreshed === "refused") process.exit(1);'); }); test("#7043: a restart the manager may still finish starts no competing proxy and is reported", () => { const outcome = refreshWithResult([{ kind: "none" }], undefined, "unsettled"); @@ -497,6 +503,7 @@ test("Bun refresh gate vetoes retained Desktop and activates only after positive ] as const) { expect(await run({ resolvedRuntimeOwnership: async () => ({ ownership: null }), + homePolicy: { check: () => true, serviceAllowed: true }, observeSupervision: () => latch.observe(evidence), planUpdateRuntimeHandling, console: { warn() {} }, })).toBe(allowed); diff --git a/tests/update/update-home-launcher.test.ts b/tests/update/update-home-launcher.test.ts new file mode 100644 index 00000000000..ceb6b0cebf5 --- /dev/null +++ b/tests/update/update-home-launcher.test.ts @@ -0,0 +1,275 @@ +import { expect, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { createTempHome } from "../helpers/temp-home"; +import { dirname, join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; +import { createUpdateServiceHomePolicy, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; +import { updateHomeProbeEnvironment } from "../../src/update/service-home-probe.mjs"; +import { planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; + +const source = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); +function extract(start: string, end: string, invoke: string) { + const from = source.indexOf(start), to = source.indexOf(end, from); + expect(from).toBeGreaterThan(0); expect(to).toBeGreaterThan(from); + return new Function("deps", `with (deps) { ${source.slice(from, to)}; return ${invoke}; }`); +} + +test("private Node interception precedes handoff/boot restore and uses only the dedicated no-env entry", () => { + const at = source.indexOf('if (process.argv[2] === "__update-home-probe")'); + for (const boundary of ["const desktopPlan =", "const probe = bootRestoreProbe", "const bunRuntime = resolveBun"]) { + expect(at).toBeLessThan(source.indexOf(boundary)); + } + const run = extract("function readNodeUpdateHome(", "function shouldRepairCodexShim(", "readNodeUpdateHome('/fixture/bin/ocx.mjs')"); + let calls = 0; + const deps = { supportsUpdateHomeProbe: () => true, + resolveBun: (options: unknown) => { expect(options).toEqual({ allowInstall: false, launcherPath: "/fixture/bin/ocx.mjs", allowMissing: true }); return { path: "/fixture/bun" }; }, + updateHomeProbeEnvironment, process: { env: { CODEX_HOME: "/codex", CODEX_SQLITE_HOME: "/sqlite", OCX_SERVICE: "1" } }, + configDir: () => "/proxy", dirname: () => "/fixture/bin", join: (...parts: string[]) => parts.join("/"), spawnSync() {}, + probeUpdateServiceHome: (_run: unknown, binary: string, prefix: string[], options: { env: object }) => { + calls++; expect(binary).toBe("/fixture/bun"); expect(prefix[0]).toBe("--no-env-file"); + expect(prefix[1]).toEndWith("src/update/service-home-probe-entry.ts"); + expect(options.env).toEqual({ CODEX_HOME: "/codex", OPENCODEX_HOME: "/proxy", CODEX_SQLITE_HOME: "/sqlite" }); + return { kind: "same" }; + }, + }; + expect(run(deps)).toEqual({ kind: "same" }); expect(calls).toBe(1); + calls = 0; + expect(run({ ...deps, supportsUpdateHomeProbe: () => false })).toEqual({ kind: "unknown" }); expect(calls).toBe(0); + expect(run({ ...deps, resolveBun: () => null })).toEqual({ kind: "unknown" }); expect(calls).toBe(0); + const entry = readFileSync(repoPath("src/update/service-home-probe-entry.ts"), "utf8"); + expect(entry).not.toContain('from "../cli'); expect(entry).not.toContain("launcherProtocolVersion"); + expect(entry).toContain("...inspectUpdateServiceHome()"); +}); + +test("post-update probe resolves Bun and its compatibility floor from the actual new package", () => { + const launcherPath = "/new/package/bin/ocx.mjs"; + const resolve = extract("function bunBinDir(", "const BUN_OVERRIDE_ENV", `bunBinDir(${JSON.stringify(launcherPath)})`); + expect(resolve({ dirname, + require: { resolve() { throw new Error("old package must not be used"); } }, + createRequire: (path: string) => { expect(path).toBe(launcherPath); return { resolve: () => "/new/package/node_modules/bun/package.json" }; }, + })).toBe(dirname("/new/package/node_modules/bun/package.json")); + const floor = extract("function pinnedBunVersion(", "function updateTag(", `pinnedBunVersion(${JSON.stringify(launcherPath)})`); + expect(floor({ dirname, join, here: "/old/package/bin", + readFileSync: (path: string) => { expect(path).toBe(join(dirname(launcherPath), "..", "package.json")); return '{"dependencies":{"bun":"1.4.3"}}'; }, + })).toBe("1.4.3"); +}); + +test("Node successful cold update stays stopped; a stopped foreground runtime recovers", () => { + const start = source.indexOf(" } else if (postInstallPlan.mayStopRuntime && homePolicy.check())"); + const end = source.indexOf(" process.exit(0);", start); + const run = new Function("deps", `with (deps) { if (false) { ${source.slice(start, end)} }`); + for (const stopAttempted of [false, true]) { + let starts = 0; + run({ stopAttempted, postInstallPlan: { mayStopRuntime: true }, homePolicy: { check: () => true }, + authorityLauncher: "old", postUpdateLauncher: "new", process: { exit() { throw new Error("unexpected exit"); } }, + console: { log() {} }, launcherStartHint: () => "manual start", bakePort: 12345, + startProxyDirectly: () => { starts++; return true; }, releaseUpdateLease() {} }); + expect(starts).toBe(stopAttempted ? 1 : 0); + } +}); + +test("Node foreground recovery releases exactly once before caller exit on veto or failure", () => { + const start = source.indexOf(" } else if (postInstallPlan.mayStopRuntime && homePolicy.check())"); + const end = source.indexOf(" process.exit(0);", start); + const run = new Function("deps", `with (deps) { if (false) { ${source.slice(start, end)} process.exit(0); }`); + for (const result of ["vetoed", false, true]) { + const events: string[] = []; + let releaseCountAtExit = -1; + const terminal = new Error("fixture terminal"); + try { + run({ stopAttempted: true, postInstallPlan: { mayStopRuntime: true }, homePolicy: { check: () => true }, + authorityLauncher: "old", postUpdateLauncher: "new", + startProxyDirectly: () => { events.push("direct"); return result; }, + releaseUpdateLease: () => events.push("release"), + process: { exit(code: number) { + // Snapshot before throwing: a throw can run finally, a real exit cannot. + releaseCountAtExit = events.filter(event => event === "release").length; + events.push(`exit:${code}`); throw terminal; + } }, + }); + } catch (error) { if (error !== terminal) throw error; } + expect(releaseCountAtExit).toBe(1); + expect(events).toEqual(["direct", "release", `exit:${result === true ? 0 : 1}`]); + } +}); + +test("probe-only missing Bun returns uncertainty without install or abrupt exit and permits cleanup", () => { + const run = extract("function resolveBun(", "// `ocx update --help`", "resolveBun({ allowInstall: false, allowMissing: true })"); + const events: string[] = []; + let result; + const deps = { process: { env: {} }, BUN_OVERRIDE_ENV: "OPENCODEX_BUN_PATH", bunBinDir: () => "/stub", + findBunBinary: () => null, join, existsSync: () => true, + spawnSync: () => { events.push("install"); throw new Error("install forbidden"); }, + findPathBun: () => null, pinnedBunVersion: () => "1.4.3", PATH_BUN_PROBE_BUDGET_MS: 5000, + fail: () => { events.push("exit"); throw new Error("abrupt caller exit"); }, + }; + try { + result = run(deps); + const policy = createUpdateServiceHomePolicy(() => ({ kind: "unknown" }), () => events.push("refused")); + expect(policy.check()).toBe(false); + } finally { events.push("cleanup"); } + expect(result).toBeNull(); expect(events).toEqual(["refused", "cleanup"]); + events.length = 0; + const ordinary = extract("function resolveBun(", "// `ocx update --help`", "resolveBun({ allowInstall: false })"); + expect(() => ordinary(deps)).toThrow("abrupt caller exit"); + expect(events).toEqual(["exit"]); +}); + +const foreign = { kind: "foreign", subject: "a".repeat(64) } as const; +test("Node normal and failure recovery share a home-guarded direct helper with owned lease recheck", () => { + const run = extract(" function startProxyDirectly()", " // #7043:", "startProxyDirectly()"); + for (const initial of [foreign, { kind: "same" }, { kind: "absent" }] as const) { + for (const changed of [undefined, { kind: "unknown" }, { kind: "foreign", subject: "b".repeat(64) }] as const) { + let home: UpdateServiceHomeAuthority = initial; + const events: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, () => {}); + const result = run({ homePolicy, reacquireUpdateLease() { events.push("lease"); if (changed) home = changed; }, + readOwnership: () => ({ ownership: null }), ownershipIdentity: () => "same", initialOwnership: {}, + planUpdateRuntimeHandling, observeSupervision: () => false, + postUpdateLauncherUsable: true, postUpdateLauncher: "/fixture/launcher", existsSync: () => true, + mutationChildEnvironment: () => ({ OCX_SERVICE: "1", OCX_SIBLING_OF_PORT: "12345" }), + console: { log() {}, warn() {}, error() {} }, process: { execPath: "/fixture/node" }, bakePort: 12345, bakeHostname: "127.0.0.1", + spawn: (_bin: string, _args: string[], options: { env: Record }) => { + events.push("spawn"); expect(options.env.OCX_SERVICE).toBeUndefined(); expect(options.env.OCX_SIBLING_OF_PORT).toBeUndefined(); + return { on() {}, unref() {} }; + }, + UPDATE_RECOVERY_READY_MS: 1000, + readCurrentRuntimeTarget: () => ({ kind: "target", target: { port: 12345 } }), probeProxyLiveness: () => "live", + }); + expect(events[0]).toBe("lease"); expect(result).toBe(changed ? "vetoed" : true); + expect(events.includes("spawn")).toBe(!changed); + } + } +}); + +test("Node shared refresh skips every global service action for stable foreign/absence and refuses change", () => { + const run = extract(" function refreshBackgroundServiceOrStartDirect()", " let updateLease =", "refreshBackgroundServiceOrStartDirect()"); + for (const initial of [foreign, { kind: "absent" }] as const) { + for (const changed of [false, true]) { + let home: UpdateServiceHomeAuthority = initial; + const events: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, () => {}); + if (changed) home = { kind: "unknown" }; + const result = run({ homePolicy, authorityLauncher: "old", postUpdateLauncher: "new", + startProxyDirectly: () => { events.push("direct"); return true; }, + spawnSync: () => { throw new Error("global service action reached"); }, + restartThroughServiceManager: () => { throw new Error("manager reached"); }, + }); + expect(events).toEqual(changed ? [] : ["direct"]); expect(result).toBe(changed ? "refused" : undefined); + } + } +}); + +test("Node manager rechecks after release, and an ordinary same-home failure retains manager-first recovery", () => { + const run = extract(" function restartThroughServiceManager()", " /**", "restartThroughServiceManager()"); + for (const changed of [undefined, { kind: "unknown" }, foreign] as const) { + let home: UpdateServiceHomeAuthority = { kind: "same" }; + const events: string[] = []; + const result = run({ homePolicy: createUpdateServiceHomePolicy(() => home, () => {}), + hasRestartableServiceManager: () => true, + releaseUpdateLease() { events.push("release"); if (changed) home = changed; }, + readOwnership: () => ({ ownership: null }), ownershipIdentity: () => "same", initialOwnership: {}, + observeSupervision: () => false, planUpdateRuntimeHandling, + console: { log() {}, warn() {} }, readCurrentRuntimeTarget: () => ({ kind: "absent" }), + restartServiceManagerJob: () => { events.push("manager"); return { ok: false, reason: "ordinary failure" }; }, + }); + expect(events).toEqual(changed ? ["release"] : ["release", "manager"]); + expect(result).toBe(changed ? "refused" : "failed"); + } +}); + +test("Node repair changing home authority refuses manager and direct fallback", () => { + const run = extract(" function refreshBackgroundServiceOrStartDirect()", " let updateLease =", "refreshBackgroundServiceOrStartDirect()"); + let home: UpdateServiceHomeAuthority = { kind: "same" }; + const events: string[] = []; + const result = run({ homePolicy: createUpdateServiceHomePolicy(() => home, () => {}), + authorityLauncher: "old", postUpdateLauncher: "new", observeSupervision: () => false, + planUpdateRuntimeHandling, readOwnership: () => ({ ownership: null }), + console: { log() {}, warn() {} }, process: { env: { OCX_BAKE_PORT: "prior" }, execPath: "fixture" }, bakePort: 12345, + serviceRefreshArgs: () => ["repair"], mutationChildEnvironment: () => ({}), + spawnSync: () => { events.push("repair"); home = foreign; return { status: 1 }; }, + restartThroughServiceManager: () => events.push("manager"), startProxyDirectly: () => events.push("direct"), + }); + expect(result).toBe("refused"); expect(events).toEqual(["repair"]); +}); + +test("the installed test Bun honors --no-env-file while the control loads the fixture dotenv", () => { + const box = createTempHome("ocx-update-home-dotenv-"); + try { + writeFileSync(box.path(".env"), "OCX_UPDATE_PROBE_DOTENV=from-fixture\n"); + writeFileSync(box.path("probe.js"), 'console.log(process.env.OCX_UPDATE_PROBE_DOTENV ?? "absent");'); + const env = { ...process.env, HOME: box.root }; + delete env.OCX_UPDATE_PROBE_DOTENV; + for (const disabled of [false, true]) { + const result = Bun.spawnSync([process.execPath, ...(disabled ? ["--no-env-file"] : []), box.path("probe.js")], + { cwd: box.root, env, stdout: "pipe", stderr: "pipe", timeout: 8000 }); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe(disabled ? "absent" : "from-fixture"); + } + } finally { box.remove(); } +}); + +for (const delegated of [false, true]) test(`Node package-only success reports a ${delegated ? "restricted worker" : "CLI"} result before recovery`, () => { + const start = source.indexOf(" if (homePolicy.packageOnly) {", source.indexOf(" const postInstallPlan")); + const end = source.indexOf(" if (!homePolicy.check()) process.exit(1);", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const run = new Function("deps", `with (deps) { ${source.slice(start, end)} }`); + const events: string[] = []; + const terminal = new Error("exit"); + const homePolicy = createUpdateServiceHomePolicy(() => ({ kind: "unknown" }), text => events.push(text)); + expect(() => run({ homePolicy, latest: "2", postUpdateLauncher: "new", bakePort: 42, + launcherStartHint: () => "manual start", console: { log: (text: string) => events.push(text) }, + UPDATE_PACKAGE_ONLY_EXIT_CODE: 75, + process: { argv: delegated ? ["--update-worker-result"] : [], exit(code: number) { expect(code).toBe(delegated ? 75 : 0); throw terminal; } }, + })).toThrow("exit"); + expect(events).toHaveLength(3); + expect(events[0]).toContain("Updated"); expect(events[1]).toContain("ownership could not be verified"); + expect(events[2]).toContain("Restart the proxy:"); +}); + +test("Node package-only refuses an unresolved teardown before package replacement and releases its lease", () => { + const start = source.indexOf(" if (homePolicy.packageOnly && hasPendingTeardown) {"); + const end = source.indexOf(" const stopNeeded =", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const run = new Function("deps", `with (deps) { ${source.slice(start, end)} }`); + const events: string[] = []; + const terminal = new Error("exit"); + expect(() => run({ homePolicy: { packageOnly: true }, hasPendingTeardown: true, + releaseUpdateLease: () => events.push("release"), console: { error: (text: string) => events.push(text) }, + process: { exit(code: number) { expect(code).toBe(1); events.push("exit"); throw terminal; } }, + })).toThrow("exit"); + expect(events[0]).toBe("release"); expect(events[1]).toContain("pending teardown"); expect(events[2]).toBe("exit"); +}); + +test("the new package remains inspectable by an already-running v1 updater", () => { + const declaration = JSON.parse(readFileSync(repoPath("src/update/service-home-protocol.json"), "utf8")); + // This is the frozen predicate in the old Node process, which package replacement cannot update. + const legacyAccepts = (value: typeof declaration) => value?.schemaVersion === 1 && value?.launcherProtocolVersion === 1 + && value?.entry === "__update-home-probe" && Object.keys(value).length === 3; + expect(legacyAccepts(declaration)).toBe(true); + const start = source.indexOf('if (process.argv[2] === "__update-home-probe")'); + const end = source.indexOf("const desktopPlan =", start); + const run = new Function("deps", `with (deps) { ${source.slice(start, end).replace("import.meta.url", "\"fixture\"")} }`); + let response = ""; + const terminal = new Error("exit"); + expect(() => run({ process: { argv: ["node", "ocx", "__update-home-probe"], exit(code: number) { expect(code).toBe(0); throw terminal; } }, + isUpdateHomeProbe: () => true, readNodeUpdateHome: () => ({ kind: "same" }), fileURLToPath: () => "launcher", + UPDATE_HOME_LAUNCHER_PROTOCOL: declaration.launcherProtocolVersion, console: { log: (text: string) => { response = text; } }, + })).toThrow("exit"); + expect(JSON.parse(response)).toEqual({ schemaVersion: 1, launcherProtocolVersion: 1, kind: "same" }); +}); + +test("package-only capability interception is exact and precedes install-capable bootstrap", () => { + const start = source.indexOf('if (process.argv[2] === "__update-package-only-probe")'); + const end = source.indexOf('if (process.argv[2] === "__update-home-probe")', start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const run = new Function("deps", `with (deps) { ${source.slice(start, end)} }`); + for (const extra of [false, true]) { + const events: unknown[] = []; + const terminal = new Error("exit"); + expect(() => run({ process: { argv: ["node", "ocx", "__update-package-only-probe", ...(extra ? ["extra"] : [])], + exit(code: number) { events.push(code); throw terminal; } }, console: { log: (text: string) => events.push(JSON.parse(text)) }, + })).toThrow("exit"); + expect(events).toEqual(extra ? [2] : [{ schemaVersion: 1, packageOnlyProtocolVersion: 1 }, 0]); + } +}); diff --git a/tests/update/update-home-recovery.test.ts b/tests/update/update-home-recovery.test.ts new file mode 100644 index 00000000000..194f17e0701 --- /dev/null +++ b/tests/update/update-home-recovery.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; +import { createUpdateServiceHomePolicy, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; +import { planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; + +const source = readFileSync(repoPath("src/update/index.ts"), "utf8"); +function extracted(start: string, end: string, result: string) { + const from = source.indexOf(start), to = source.indexOf(end, from); + expect(from).toBeGreaterThan(0); expect(to).toBeGreaterThan(from); + const code = new Bun.Transpiler({ loader: "ts" }).transformSync(`async function run() { + ${source.slice(from, to)} + return ${result}; + }`); + return new Function("deps", `with (deps) { ${code}; return run(); }`); +} +const direct = extracted(" const startProxyDirectly = async", " // #7043:", "await startProxyDirectly()"); +const manager = extracted(" const restartThroughServiceManager = async", " /** Direct start as", "await restartThroughServiceManager()"); +const foreign = { kind: "foreign", subject: "a".repeat(64) } as const; + +test("Bun successful cold update stays stopped; a stopped foreground runtime recovers", async () => { + const start = source.indexOf(" if (!homePolicy.serviceAllowed && postInstallPlan.mayStopRuntime)"); + const end = source.indexOf(" if (postInstallPlan.mayRestoreService)", start); + const code = new Bun.Transpiler({ loader: "ts" }).transformSync(`async function run() { ${source.slice(start, end)} }`); + const run = new Function("deps", `with (deps) { ${code}; return run(); }`); + for (const stopAttempted of [false, true]) { + let starts = 0; + await run({ stopAttempted, homePolicy: { serviceAllowed: false }, postInstallPlan: { mayStopRuntime: true }, + console: { log() {} }, launcherStartHint: () => "manual start", postUpdateLauncher: "fixture", capturedListen: { port: 12345 }, + startProxyDirectly: async () => { starts++; return true; } }); + expect(starts).toBe(stopAttempted ? 1 : 0); + } +}); + +test("Bun direct recovery reacquires its own lease and checks homes again before spawning", async () => { + for (const initial of [foreign, { kind: "absent" }, { kind: "same" }] as const) { + const events: string[] = []; + let home: UpdateServiceHomeAuthority = initial; + const homePolicy = createUpdateServiceHomePolicy(() => { events.push("home"); return home; }, () => {}); + events.length = 0; + const run = (changed?: UpdateServiceHomeAuthority, ownerSame = true) => direct({ + homePolicy, mutation: { reacquire() { events.push("lease"); if (changed) home = changed; }, controlEnvironment: () => ({}) }, + resolvedRuntimeOwnership: async () => { events.push("owner"); return { ownership: null }; }, + sameOwner: () => ownerSame, observeSupervision: () => false, planUpdateRuntimeHandling, + postUpdateLauncherUsable: true, postUpdateLauncher: "/fixture/launcher", existsSync: () => true, + withoutSiblingMarker: () => ({}), process: { env: {}, execPath: "/fixture/bun" }, + capturedListen: { port: 12345, hostname: "127.0.0.1" }, withProcessRuntimeProvenance: (env: object) => env, + spawn: () => { events.push("spawn"); return { once() {}, unref() {} }; }, + readUpdateRuntimeTarget: () => ({ kind: "target", target: { pid: 91, port: 12345, hostname: "127.0.0.1" } }), + getRuntimePortPath: () => "/fixture/runtime", proxyIdentityAt: async () => true, console: { warn() {} }, + }); + expect(await run()).toBe(true); + expect(events[0]).toBe("lease"); expect(events.indexOf("home")).toBeLessThan(events.indexOf("spawn")); + events.length = 0; + expect(await run({ kind: "unknown" })).toBe("vetoed"); + expect(events).not.toContain("spawn"); expect(events).not.toContain("owner"); + home = initial; events.length = 0; + expect(await run(undefined, false)).toBe("vetoed"); expect(events).not.toContain("spawn"); + } +}); + +test("Bun manager boundary releases first, refuses changed homes, and skips stable foreign jobs", async () => { + for (const initial of [{ kind: "same" }, foreign, { kind: "absent" }] as const) { + for (const changed of [undefined, { kind: "unknown" }, { kind: "foreign", subject: "b".repeat(64) }] as const) { + let home: UpdateServiceHomeAuthority = initial; + const events: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, () => events.push("refuse")); + const result = await manager({ homePolicy, hasRestartableServiceManager: () => true, + resolvedRuntimeOwnership: async () => ({ ownership: null }), sameOwner: () => true, + planUpdateRuntimeHandling, observeSupervision: () => false, + mutation: { release() { events.push("release"); if (changed) home = changed; } }, + console: { log() {}, warn() {} }, + readUpdateRuntimeTarget: () => ({ kind: "absent" }), getRuntimePortPath: () => "fixture", + capturedListen: { port: 12345 }, + restartServiceManagerJob: () => { events.push("manager"); return { ok: false, reason: "ordinary failure" }; }, + }); + expect(events[0]).toBe("release"); + expect(events.filter(event => event === "manager").length).toBe(!changed && initial.kind === "same" ? 1 : 0); + expect(result).toBe(changed ? "refused" : "failed"); + } + } +}); diff --git a/tests/update/update-home-worker.test.ts b/tests/update/update-home-worker.test.ts new file mode 100644 index 00000000000..8ab639713d6 --- /dev/null +++ b/tests/update/update-home-worker.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { createTempHome } from "../helpers/temp-home"; +import { automaticUpdateLauncherSupported, packageOnlyUpdateSucceeded } from "../../src/update/job-update-home"; +import { join } from "node:path"; +import { readUpdateJob, runGuiUpdateWorker } from "../../src/update/job"; + +test("an old delegated launcher is refused before automatic update starts", async () => { + const box = createTempHome("ocx-update-home-worker-"); + const priorHome = process.env.HOME; + process.env.HOME = box.root; + mkdirSync(box.codexHome, { recursive: true }); + try { + const events: string[] = []; + await runGuiUpdateWorker("old-launcher", "latest", false, { + checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", channel: "latest", + installer: "npm", updateAvailable: true, canUpdate: true, command: "fixture", releaseNotesUrl: "fixture" }), + integrityFn: () => ({ ok: true, integrity: "sha512-testfixturevalue000000000" }), + cachePreflightFn: () => ({ ok: true, reason: "ok" }), + launcherSupportsUpdateHomeFn: (launcher, node) => { + expect(launcher).toMatch(/bin[\\/]ocx\.mjs$/); expect(node).toBeTruthy(); events.push("probe"); return false; + }, + runCommandFn: () => { events.push("update"); return { status: 0, signal: null }; }, + }); + expect(events).toEqual(["probe"]); + expect(readUpdateJob("old-launcher")?.status).toBe("failed"); + expect(readUpdateJob("old-launcher")?.error).toContain("guarded recovery protocol"); + } finally { + if (priorHome === undefined) delete process.env.HOME; else process.env.HOME = priorHome; + box.remove(); + } +}); + +test("package-only capability can be verified without home or Bun inspection", () => { + const box = createTempHome("ocx-update-home-protocol-"); + try { + mkdirSync(join(box.root, "bin")); mkdirSync(join(box.root, "src", "update"), { recursive: true }); + writeFileSync(join(box.root, "src", "update", "service-home-protocol.json"), JSON.stringify({ + schemaVersion: 1, launcherProtocolVersion: 1, entry: "__update-home-probe", + })); + writeFileSync(join(box.root, "src", "update", "service-home-package-only-protocol.json"), JSON.stringify({ + schemaVersion: 1, packageOnlyProtocolVersion: 1, entry: "__update-package-only-probe", + })); + const launcher = join(box.root, "bin", "ocx.mjs"); + writeFileSync(launcher, 'console.log(JSON.stringify({schemaVersion:1,packageOnlyProtocolVersion:1}));'); + expect(automaticUpdateLauncherSupported(launcher, process.execPath)).toBe(true); + writeFileSync(launcher, 'console.log("malformed");'); + expect(automaticUpdateLauncherSupported(launcher, process.execPath)).toBe(false); + } finally { box.remove(); } +}); + +for (const installer of ["npm", "bun"] as const) test(`worker ${installer} installs with initial unknown but never restarts`, async () => { + const box = createTempHome("ocx-update-home-unknown-worker-"); + mkdirSync(box.codexHome, { recursive: true }); + try { + const events: string[] = []; + await runGuiUpdateWorker(`unknown-${installer}`, "latest", true, { + checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", channel: "latest", + installer, updateAvailable: true, canUpdate: true, command: "fixture", releaseNotesUrl: "fixture" }), + integrityFn: () => ({ ok: true, integrity: "sha512-testfixturevalue000000000" }), + cachePreflightFn: () => ({ ok: true, reason: "ok" }), + launcherSupportsUpdateHomeFn: () => true, + serviceHomeAuthorityFn: () => ({ kind: "unknown" }), + resolveOwnershipFn: () => { throw new Error("restart must not acquire runtime authority"); }, + runCommandFn: (_job, bin, args) => { + expect(args).toEqual([expect.stringMatching(/bin[\\/]ocx\.mjs$/), "update", "--tag", "latest", "--update-package-only", "--update-worker-result"]); + expect(bin).toBeTruthy(); events.push("update"); return { status: 0, signal: null }; + }, + }); + expect(events).toEqual(["update"]); + const job = readUpdateJob(`unknown-${installer}`); + expect(job).toMatchObject({ status: "succeeded", restarted: false }); + expect(JSON.stringify(job?.log)).toContain("ownership could not be verified"); + expect(JSON.stringify(job?.log)).toContain("Restart the proxy manually"); + } finally { box.remove(); } +}); + +test("a child package-only success vetoes parent restart even when its own home remains matching", async () => { + const box = createTempHome("ocx-update-home-child-result-"); + mkdirSync(box.codexHome, { recursive: true }); + try { + let restartChecks = 0; + await runGuiUpdateWorker("child-package-only", "latest", true, { + checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", channel: "latest", + installer: "npm", updateAvailable: true, canUpdate: true, command: "fixture", releaseNotesUrl: "fixture" }), + integrityFn: () => ({ ok: true, integrity: "sha512-testfixturevalue000000000" }), + cachePreflightFn: () => ({ ok: true, reason: "ok" }), launcherSupportsUpdateHomeFn: () => true, + serviceHomeAuthorityFn: () => ({ kind: "same" }), + resolveOwnershipFn: () => { restartChecks++; throw new Error("runtime recovery forbidden"); }, + runCommandFn: (_job, _bin, args) => { + expect(args).toContain("--update-worker-result"); return { status: 75, signal: null }; + }, + }); + expect(restartChecks).toBe(0); + expect(readUpdateJob("child-package-only")).toMatchObject({ status: "succeeded", restarted: false }); + } finally { box.remove(); } +}); + +test("package-only success status is never accepted from an unmarked package manager", () => { + expect(packageOnlyUpdateSucceeded({ args: ["add", "-g"] }, 75, false)).toBe(false); + expect(packageOnlyUpdateSucceeded({ args: ["--update-worker-result"] }, 1, true)).toBe(false); +}); diff --git a/tests/update/update-job-supervised-restart.test.ts b/tests/update/update-job-supervised-restart.test.ts index ef127b74f62..4c7673c6716 100644 --- a/tests/update/update-job-supervised-restart.test.ts +++ b/tests/update/update-job-supervised-restart.test.ts @@ -1,14 +1,16 @@ +import { sameHomeRestartIo, absentHomeRestartIo } from "../helpers/update-home-fixtures"; /** * Dashboard update worker: manager-first recovery after a failed service refresh (#7043), the * src/update/job.ts port of what #7074 did for `ocx update`. Every manager call is injected. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { finishGuiUpdateRestart, readUpdateJob, restartAfterUpdateForTests, updateJobPath, type RestartIo, type UpdateJobState, } from "../../src/update/job"; +import * as managerRestart from "../../src/update/job-supervised-restart"; import { restartServiceManagerAfterUpdate, type ManagerRestartDeps } from "../../src/update/job-supervised-restart"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -40,6 +42,7 @@ describe("restartServiceManagerAfterUpdate", () => { function deps(over: ManagerRestartDeps = {}): ManagerRestartDeps { let now = 0; return { + serviceHomeAuthorityFn: () => ({ kind: "same" }), hasManager: () => true, restartJob: () => ({ ok: true }), managerPid: () => 50, @@ -116,8 +119,33 @@ describe("restartServiceManagerAfterUpdate", () => { }); describe("dashboard restart after a failed service refresh", () => { + test("the same-home fallback fixture releases its lease without consulting the host manager", async () => { + const events: string[] = []; + expect(sameHomeRestartIo.restartServiceManagerFn).toBeFunction(); + const outcome = await sameHomeRestartIo.restartServiceManagerFn!( + { port: 19100, hostname: "127.0.0.1", release: () => { events.push("release"); } }, + line => events.push(line), + ); + expect(outcome).toBe("failed"); + expect(events).toEqual(["release"]); + const hostManager = spyOn(managerRestart, "restartServiceManagerAfterUpdate").mockImplementation(async () => { + throw new Error("fallback fixture reached the host manager"); + }); + try { + const spawned = { count: 0 }; + const job = freshJob("fixture-manager-isolated"); + const result = await restartAfterUpdateForTests(job, { port: 19100, hostname: "127.0.0.1" }, { + ...managedIo("failed", spawned), ...sameHomeRestartIo, + }); + expect(result).toBe("unsupervised"); + expect(spawned.count).toBe(1); + expect(hostManager).not.toHaveBeenCalled(); + } finally { hostManager.mockRestore(); } + }); + function managedIo(manager: "supervised" | "failed" | "unsettled", spawned: { count: number }): RestartIo { return { + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => false, listListenPidsFn: () => [], @@ -176,6 +204,7 @@ describe("dashboard restart after a failed service refresh", () => { const spawned = { count: 0 }; const result = await restartAfterUpdateForTests(job, { port: 19103, hostname: "127.0.0.1" }, { ...managedIo("failed", spawned), + ...absentHomeRestartIo, serviceInstalledFn: () => false, restartServiceManagerFn: async () => { managerCalls++; return "failed"; }, }); diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts index 0b945ff2935..9af5a894f48 100644 --- a/tests/update/update-job.test.ts +++ b/tests/update/update-job.test.ts @@ -1,3 +1,4 @@ +import { sameHomeRestartIo, absentHomeRestartIo } from "../helpers/update-home-fixtures"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; @@ -71,6 +72,7 @@ describe("pinned-start child cleanup", () => { }; writeFileSync(updateJobPath(), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 19111, hostname: "127.0.0.1" }, { + ...absentHomeRestartIo, serviceInstalledFn: () => false, waitForPort: async () => true, listListenPidsFn: () => [], @@ -585,6 +587,7 @@ describe("GUI update execution decisions", () => { let restartLauncher = ""; let now = 0; await runGuiUpdateWorker("pnpm-active-launcher", "latest", true, { + launcherSupportsUpdateHomeFn: () => true, checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", @@ -609,6 +612,7 @@ describe("GUI update execution decisions", () => { integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), runCommandFn: () => ({ status: 0, signal: null }), restartIo: { + ...absentHomeRestartIo, serviceInstalledFn: () => false, restartAfterUpdateFn: async (_job, _captured, io) => { restartLauncher = io?.packageLauncherPathFn?.() ?? ""; @@ -656,6 +660,7 @@ describe("GUI update execution decisions", () => { writeFileSync(updateJobPath(directJob.id), JSON.stringify(directJob)); let directLauncher = ""; await restartAfterUpdateForTests(directJob, { port: 19001, hostname: "127.0.0.1" }, { + ...absentHomeRestartIo, serviceInstalledFn: () => false, packageLauncherPathFn: () => activeLauncher, listListenPidsFn: () => [], @@ -668,6 +673,7 @@ describe("GUI update execution decisions", () => { writeFileSync(updateJobPath(serviceJob.id), JSON.stringify(serviceJob)); let serviceArgs: string[] = []; await restartAfterUpdateForTests(serviceJob, { port: 19002, hostname: "127.0.0.1" }, { + ...sameHomeRestartIo, serviceInstalledFn: () => true, packageLauncherPathFn: () => activeLauncher, listListenPidsFn: () => [], @@ -702,6 +708,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 19997, hostname: "127.0.0.1" }, { + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, waitForPort: async () => false, @@ -753,6 +760,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 12345, hostname: "127.0.0.1" }, { + ...absentHomeRestartIo, serviceInstalledFn: () => false, // drive the proxy-mode branch regardless of host state listListenPidsFn: () => [], waitForPort: async (port, hostname, opts) => { @@ -796,6 +804,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 10100, hostname: "127.0.0.1", oldPid: 4242 }, { + ...absentHomeRestartIo, serviceInstalledFn: () => false, listListenPidsFn: () => [], waitForPort: async (_port, _hostname, opts) => { @@ -828,6 +837,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 19111, hostname: "127.0.0.1", oldPid: 100 }, { + ...absentHomeRestartIo, serviceInstalledFn: () => false, // Simulate a respawned bun child that is not the pre-update PID. listListenPidsFn: () => [100, 200], @@ -858,6 +868,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 10100, hostname: "127.0.0.1", oldPid: 100 }, { + ...absentHomeRestartIo, serviceInstalledFn: () => false, waitForPort: async () => false, listListenPidsFn: () => [555], @@ -888,6 +899,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); await restartAfterUpdateForTests(job, { port: 10100, hostname: "127.0.0.1", oldPid: 100 }, { + ...absentHomeRestartIo, serviceInstalledFn: () => false, waitForPort: async () => false, listListenPidsFn: () => [100], @@ -921,6 +933,7 @@ describe("GUI update execution decisions", () => { delete process.env.OCX_BAKE_PORT; try { await restartAfterUpdateForTests(job, { port: 18765, hostname: "127.0.0.1" }, { + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, // The service-recovery gate now asks the port before skipping the direct-start @@ -968,6 +981,7 @@ describe("GUI update execution decisions", () => { delete process.env.OCX_SERVICE; try { await restartAfterUpdateForTests(job, { port: 19999, hostname: "127.0.0.1" }, { + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => false, waitForPort: async () => true, @@ -1013,6 +1027,7 @@ describe("GUI update execution decisions", () => { try { await restartAfterUpdateForTests(job, { port: 19998, hostname: "127.0.0.1" }, { platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, waitForPort: async () => true, @@ -1057,6 +1072,7 @@ describe("GUI update execution decisions", () => { await expect(restartAfterUpdateForTests(job, { port: 19010, hostname: "127.0.0.1" }, { platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, waitForPort: async () => true, @@ -1090,6 +1106,7 @@ describe("GUI update execution decisions", () => { delete process.env.OCX_SERVICE; try { await restartAfterUpdateForTests(job, { port: 19100, hostname: "127.0.0.1" }, { + ...sameHomeRestartIo, serviceInstalledFn: () => true, // Installed but stale/missing assets — the status line users see after a dead update. serviceViableFn: () => false, @@ -1133,6 +1150,7 @@ describe("GUI update execution decisions", () => { "npm", { // No Background Service installed — interactive dashboard update must still recover. + ...absentHomeRestartIo, serviceInstalledFn: () => false, serviceViableFn: () => false, waitForPort: async () => true, @@ -1290,6 +1308,7 @@ describe("GUI update execution decisions", () => { { port: 10100, hostname: "127.0.0.1", oldPid: 111 }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, // Soft-probe path only runs when a live listen owner exists. listListenPidsFn: () => [222], @@ -1338,6 +1357,7 @@ describe("GUI update execution decisions", () => { { port: 10100, hostname: "127.0.0.1", oldPid: 111 }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, listListenPidsFn: () => [222], isAliveFn: () => true, @@ -1378,6 +1398,7 @@ describe("GUI update execution decisions", () => { { port: 10100, hostname: "127.0.0.1", oldPid: 111 }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, listListenPidsFn: () => [111], isAliveFn: pid => pid === 111, @@ -1429,6 +1450,7 @@ describe("GUI update execution decisions", () => { { port: 10100, hostname: "127.0.0.1", oldPid: 111 }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, listListenPidsFn: () => [livePid], isAliveFn: () => true, @@ -1475,6 +1497,7 @@ describe("GUI update execution decisions", () => { "npm", { // Live holder after reclaim timeout — must not spawn a second listener. + ...absentHomeRestartIo, serviceInstalledFn: () => false, waitForPort: async () => false, listListenPidsFn: () => [111], @@ -1516,6 +1539,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); const ok = await finishGuiUpdateRestart(job, { port: 10100, hostname: "127.0.0.1" }, "npm", { + ...absentHomeRestartIo, serviceInstalledFn: () => false, probeProxy: async () => { // Only becomes healthy after the explicit restart (launcher printed `ocx start` only). @@ -1559,6 +1583,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); const ok = await finishGuiUpdateRestart(job, { port: 10100, hostname: "127.0.0.1" }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, listListenPidsFn: () => [], // Soft probe times out (proxy down after npm update); confirm after explicit restart succeeds. @@ -1599,6 +1624,7 @@ describe("GUI update execution decisions", () => { }; writeFileSync(updateJobPath(job.id), JSON.stringify(job)); const ok = await finishGuiUpdateRestart(job, { port: 10100, hostname: "127.0.0.1", oldPid: 111 }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, scanListenPidsFn: () => ({ ok: false, error: "lsof/netstat unavailable" }), probeProxy: async () => true, @@ -1899,6 +1925,7 @@ describe("service recovery is health-gated, not viability-gated", () => { delete process.env.OCX_SERVICE; try { await restartAfterUpdateForTests(healthGateJob(id), { port: 18765, hostname: "127.0.0.1" }, { + ...sameHomeRestartIo, serviceInstalledFn: () => true, runService: () => ({ status: 0 }), waitForPort: async () => true, diff --git a/tests/update/update-restart-lease.test.ts b/tests/update/update-restart-lease.test.ts index 0f27ca2456f..efa7fb4cea5 100644 --- a/tests/update/update-restart-lease.test.ts +++ b/tests/update/update-restart-lease.test.ts @@ -1,3 +1,4 @@ +import { sameHomeRestartIo } from "../helpers/update-home-fixtures"; /** * #5760's other half. A service-manager start child is not a descendant of the * updater: Task Scheduler, launchd and systemd spawn `ocx start` with the stored @@ -495,6 +496,7 @@ describe("the restart veto lease frees a service-manager child (#5760)", () => { async lease => { await restartAfterUpdateForTests(job, { port, hostname: "127.0.0.1" }, { platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, // `ocx service repair` only exits 0 after ITS own 45s serving probe, so the @@ -550,6 +552,7 @@ describe("the restart veto lease frees a service-manager child (#5760)", () => { const outcome = await runUpdateRestartWithOwnershipLease(resolve, async lease => restartAfterUpdateForTests(job, { port, hostname: "127.0.0.1" }, { platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, // The refresh ran (and failed) while the lease was released; a desktop @@ -599,6 +602,7 @@ describe("the restart veto lease frees a service-manager child (#5760)", () => { async lease => { await restartAfterUpdateForTests(job, { port, hostname: "127.0.0.1" }, { platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, runService: () => { @@ -649,6 +653,7 @@ describe("the restart veto lease frees a service-manager child (#5760)", () => { async lease => restartAfterUpdateForTests(job, { port, hostname: "127.0.0.1" }, { platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, // A claimant that acquired the freed lease during the refresh window and diff --git a/tests/update/update-service-home-policy.test.ts b/tests/update/update-service-home-policy.test.ts new file mode 100644 index 00000000000..e9eaf091dde --- /dev/null +++ b/tests/update/update-service-home-policy.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test"; +import { createUpdateServiceHomePolicy, isUpdateHomeProbe, parseUpdateHomeProbe, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; +import { probeUpdatePackageOnlyLauncher, probeUpdateServiceHome, probeUpdateLauncherHome, updateHomeProbeEnvironment } from "../../src/update/service-home-probe.mjs"; + +const subject = "a".repeat(64); +const other = "b".repeat(64); +test("narrow update-home command requires exact ordered argv", () => { + expect(isUpdateHomeProbe(["__update-home-probe"])).toBe(true); + for (const argv of [["status", "--json"], ["status", "--update-home-only", "--json"], + ["status", "--json", "--update-home-only", "extra"], ["update", "--json", "--update-home-only"]]) { + expect(isUpdateHomeProbe(argv)).toBe(false); + } +}); + +test("child schema is not evidence of its Node parent's protocol", () => { + const child = { schemaVersion: 1, kind: "foreign", subject }; + expect(parseUpdateHomeProbe(child)).toEqual({ kind: "foreign", subject }); + // An old launcher can forward the new CLI response verbatim. + expect(parseUpdateHomeProbe(child, { requireLauncher: true })).toEqual({ kind: "unknown" }); + expect(parseUpdateHomeProbe({ ...child, launcherProtocolVersion: 1 }, { requireLauncher: true })) + .toEqual({ kind: "foreign", subject }); +}); + +test("unsupported or malformed protocol is unknown, never absence", () => { + for (const value of [undefined, null, "", [], {}, { schemaVersion: 1, kind: "foreign" }, + { schemaVersion: 2, kind: "absent" }, { schemaVersion: 1, kind: "same", subject }, + { schemaVersion: 1, kind: "absent", launcherProtocolVersion: 2 }, + { schemaVersion: 1, kind: "foreign", subject: "private-path" }, + { schemaVersion: 1, kind: "same", startup: {} }]) { + expect(parseUpdateHomeProbe(value)).toEqual({ kind: "unknown" }); + } + for (const kind of ["same", "absent", "unknown"] as const) { + expect(parseUpdateHomeProbe({ schemaVersion: 1, kind })).toEqual({ kind }); + } +}); + +test("transition policy preserves only stable foreign foreground authority", () => { + const cases: [UpdateServiceHomeAuthority, UpdateServiceHomeAuthority, boolean][] = [ + [{ kind: "same" }, { kind: "same" }, true], [{ kind: "same" }, { kind: "absent" }, true], + [{ kind: "same" }, { kind: "foreign", subject }, false], [{ kind: "same" }, { kind: "unknown" }, false], + [{ kind: "absent" }, { kind: "absent" }, true], [{ kind: "absent" }, { kind: "same" }, false], + [{ kind: "foreign", subject }, { kind: "foreign", subject }, true], + [{ kind: "foreign", subject }, { kind: "foreign", subject: other }, false], + [{ kind: "foreign", subject }, { kind: "absent" }, false], + [{ kind: "foreign" }, { kind: "foreign" }, false], [{ kind: "unknown" }, { kind: "absent" }, false], + ]; + for (const [initial, current, allowed] of cases) { + const messages: string[] = []; + const policy = createUpdateServiceHomePolicy(() => initial, message => messages.push(message)); + expect(policy.check(current)).toBe(allowed); + expect(policy.serviceAllowed).toBe(current.kind === "same"); + expect(messages.length).toBe(allowed ? 0 : 1); + expect(messages.join()).not.toContain(subject); + } +}); + +test("probe rejects old CLI, timeout, nonzero and oversized output without another command", () => { + for (const result of [{ status: 1, stdout: "unknown flag" }, { status: null, error: new Error("timeout") }, + { status: 0, stdout: "{" }, { status: 0, stdout: " ".repeat(4097) }, + { status: 0, stdout: '{"schemaVersion":1,"kind":"absent"}', signal: "SIGTERM" }]) { + let calls = 0; + expect(probeUpdateServiceHome((_bin, args, options) => { + calls++; + expect(args).toEqual(["/fixed/launcher", "__update-home-probe"]); + expect(options).toMatchObject({ timeout: 8000, maxBuffer: 4096, windowsHide: true }); + return result; + }, "/fixed/node", ["/fixed/launcher"], { requireLauncher: true })).toEqual({ kind: "unknown" }); + expect(calls).toBe(1); + } + expect(probeUpdateServiceHome(() => { throw new Error("spawn failed"); }, "/node", [])) + .toEqual({ kind: "unknown" }); +}); + +test("missing or invalid package capability never executes the old launcher", () => { + for (const read of [() => { throw new Error("missing"); }, () => "{}", () => '{"schemaVersion":2}']) { + let calls = 0; + expect(probeUpdateLauncherHome("/fixed/launcher", () => { calls++; return { status: 0 }; }, "/node", {}, read)) + .toEqual({ kind: "unknown" }); + expect(calls).toBe(0); + } + const read = () => '{"schemaVersion":1,"launcherProtocolVersion":1,"entry":"__update-home-probe"}'; + for (const marker of [undefined, 1]) { + expect(probeUpdateLauncherHome("/fixed/launcher", () => ({ status: 0, + stdout: JSON.stringify({ schemaVersion: 1, kind: "same", launcherProtocolVersion: marker }) }), "/node", {}, read)) + .toEqual({ kind: marker ? "same" : "unknown" }); + } +}); + +test("probe pins homes, keeps authoritative SQLite/auth inputs and scrubs only internal tokens", () => { + const env = updateHomeProbeEnvironment({ CODEX_HOME: "old", OPENCODEX_HOME: "old", CODEX_SQLITE_HOME: "sqlite", + ANTHROPIC_API_KEY: "fixture", OCX_USER_CHOICE: "keep", OCX_UPDATE_RESTART_CHILD: "remove", + OCX_OWNERSHIP_MUTATION_LEASE_TOKEN: "remove", OCX_SERVICE: "remove", OCX_NODE_LAUNCH_CONTEXT: "remove" }, + { codexHome: "codex", opencodexHome: "proxy" }); + expect(env).toEqual({ CODEX_HOME: "codex", OPENCODEX_HOME: "proxy", CODEX_SQLITE_HOME: "sqlite", + ANTHROPIC_API_KEY: "fixture", OCX_USER_CHOICE: "keep" }); +}); + +test("initial unknown permits package replacement but never grants runtime control", () => { + let home: UpdateServiceHomeAuthority = { kind: "unknown" }; + const messages: string[] = []; + const policy = createUpdateServiceHomePolicy(() => home, message => messages.push(message)); + expect(policy.packageOnly).toBe(true); + expect(policy.checkPackage()).toBe(true); + expect(policy.check()).toBe(false); + expect(policy.serviceAllowed).toBe(false); + for (const kind of ["same", "absent", "foreign"] as const) { + home = { kind, ...(kind === "foreign" ? { subject } : {}) }; + expect(policy.checkPackage()).toBe(false); + expect(policy.packageOnly).toBe(true); + expect(policy.serviceAllowed).toBe(false); + } + const known = createUpdateServiceHomePolicy(() => ({ kind: "same" }), () => {}); + expect(known.checkPackage({ kind: "unknown" })).toBe(false); +}); + +test("package-only capability rejects old declarations and malformed or failed responses", () => { + const declaration = (path: string) => path.endsWith("service-home-protocol.json") + ? '{"schemaVersion":1,"launcherProtocolVersion":1,"entry":"__update-home-probe"}' + : '{"schemaVersion":1,"packageOnlyProtocolVersion":1,"entry":"__update-package-only-probe"}'; + const response = { schemaVersion: 1, packageOnlyProtocolVersion: 1 }; + for (const value of [response, { ...response, extra: true }, { ...response, packageOnlyProtocolVersion: 2 }, null]) { + const result = probeUpdatePackageOnlyLauncher("/launcher", () => ({ status: 0, stdout: JSON.stringify(value) }), "/node", {}, declaration); + expect(result).toBe(value === response); + } + expect(probeUpdatePackageOnlyLauncher("/launcher", () => ({ status: null, error: new Error("timeout") }), "/node", {}, declaration)).toBe(false); + let calls = 0; + expect(probeUpdatePackageOnlyLauncher("/launcher", () => { calls++; return { status: 0 }; }, "/node", {}, path => { + if (path.endsWith("service-home-package-only-protocol.json")) throw new Error("old package"); + return declaration(path); + })).toBe(false); + expect(calls).toBe(0); +}); + +test("a parent package-only restriction cannot be upgraded by a matching child probe", () => { + const messages: string[] = []; + const policy = createUpdateServiceHomePolicy(() => ({ kind: "same" }), message => messages.push(message), true); + expect(policy.packageOnly).toBe(true); expect(policy.serviceAllowed).toBe(false); + expect(policy.checkPackage()).toBe(true); expect(policy.check()).toBe(false); + expect(messages.join()).toContain("ownership could not be verified"); + expect(policy.checkPackage({ kind: "foreign", subject })).toBe(false); +}); diff --git a/tests/update/update-service-home.test.ts b/tests/update/update-service-home.test.ts new file mode 100644 index 00000000000..69c9c339fa4 --- /dev/null +++ b/tests/update/update-service-home.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import { inspectUpdateServiceHome, updateServiceHomeRefusal } from "../../src/update/service-home-authority"; +import { restartServiceManagerAfterUpdate } from "../../src/update/job-supervised-restart"; +import { readUpdateJob, restartAfterUpdateForTests, updateJobPath, type RestartIo, type UpdateJobState } from "../../src/update/job"; +import type { OwnershipDeps } from "../../src/integrations/native/ownership-preflight"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +let box: TempHome; +let priorHome: string | undefined; +let priorSqlite: string | undefined; +let a: string; +let b: string; +let statePath: string; +let deps: OwnershipDeps; +beforeEach(() => { + priorHome = process.env.HOME; + priorSqlite = process.env.CODEX_SQLITE_HOME; + box = createTempHome("ocx-update-service-home-"); + process.env.HOME = box.root; + delete process.env.CODEX_SQLITE_HOME; + a = box.path("registered-a"); b = box.path("foreground-b"); + for (const home of [a, b]) mkdirSync(join(home, ".codex"), { recursive: true }); + process.env.OPENCODEX_HOME = b; + process.env.CODEX_HOME = join(b, ".codex"); + statePath = join(a, "service-state.json"); + deps = { statePaths: [statePath], currentHomes: { codexHome: join(b, ".codex"), opencodexHome: b }, + platform: "linux", home: box.root, configDir: b, + run: () => ({ status: 0, stdout: "LoadState=not-found\nActiveState=inactive\nFragmentPath=\nNeedDaemonReload=no\n", stderr: "", timedOut: false, spawnFailed: false }), + runRaw: () => { throw new Error("unexpected manager query"); }, + }; +}); +afterEach(() => { + if (priorHome === undefined) delete process.env.HOME; else process.env.HOME = priorHome; + if (priorSqlite === undefined) delete process.env.CODEX_SQLITE_HOME; else process.env.CODEX_SQLITE_HOME = priorSqlite; + box.remove(); +}); + +function record(home: string, extra: Record = {}, path = statePath): void { + writeFileSync(path, JSON.stringify({ version: 1, codexHome: join(home, ".codex"), opencodexHome: home, ...extra })); +} +function inspect() { return inspectUpdateServiceHome(deps); } +function fixtureJob(): UpdateJobState { + const job: UpdateJobState = { id: "home-guard", status: "restarting", startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + currentVersion: "1.0.0", latestVersion: "1.0.1", channel: "latest", installer: "bun", restart: true, + command: "", releaseNotesUrl: "", log: [] }; + writeFileSync(updateJobPath(), JSON.stringify(job)); + return job; +} +function workerIo() { + const calls = { repair: 0, manager: 0, reclaim: 0, kill: 0, spawn: 0, wrappers: 0 }; + const io: RestartIo = { + platform: "linux", serviceInstalledFn: () => true, serviceHomeAuthorityFn: inspect, + stopServiceWrappersFn: () => { calls.wrappers++; }, + readPidFn: () => 4242, + killProxyFn: () => { calls.kill++; }, + waitForPort: async () => { calls.reclaim++; return true; }, + listListenPidsFn: () => [], + isAliveFn: () => true, + runService: () => { calls.repair++; return { status: 1 }; }, + restartServiceManagerFn: async () => { calls.manager++; return "supervised"; }, + reacquireForDirectStartFn: () => null, + spawnStart: () => { calls.spawn++; }, + }; + return { calls, io }; +} + +test.each(["same", "foreign", "absent"] as const)("real two-home state distinguishes %s", kind => { + if (kind !== "absent") record(kind === "same" ? b : a); + expect(inspect()).toMatchObject({ kind }); +}); + +test.each(["malformed", "unreadable", "conflicting"])("%s state refuses automatic home authority", kind => { + if (kind === "malformed") writeFileSync(statePath, "{invalid"); + else if (kind === "unreadable") mkdirSync(statePath); + else { + record(a); const other = join(b, "service-state.json"); record(b, {}, other); + deps = { ...deps, statePaths: [other, statePath] }; + } + expect(inspect()).toEqual({ kind: "unknown" }); +}); + +test("home authority preserves physical directory aliases", () => { + record(a); + deps = { ...deps, realpathSync: path => path === a ? b : path === join(a, ".codex") ? join(b, ".codex") : path }; + expect(inspect()).toEqual({ kind: "same" }); +}); + +test("a manager query timeout remains unknown despite a matching state record", () => { + record(b); + deps = { ...deps, run: () => ({ status: null, stdout: "", stderr: "fixture", timedOut: true, spawnFailed: false }) }; + expect(inspect()).toEqual({ kind: "unknown" }); +}); + +test.each(["same", "foreign", "unknown", "alias"] as const)("SQLite home %s uses the lifecycle's physical comparison", kind => { + const installed = join(a, "sqlite"); const actual = join(b, "sqlite"); + mkdirSync(installed); mkdirSync(actual); + process.env.CODEX_SQLITE_HOME = actual; + record(b, { codexSqliteHome: kind === "same" ? actual : installed }); + if (kind === "alias") deps = { ...deps, realpathSync: path => path === installed ? actual : path }; + if (kind === "unknown") deps = { ...deps, realpathSync: () => { throw Object.assign(new Error("fixture"), { code: "EACCES" }); } }; + expect(inspect()).toMatchObject({ kind: kind === "alias" ? "same" : kind }); +}); + +test("a state change during the unattended inspection is unknown", () => { + record(b, { codexSqliteHome: join(a, ".codex") }); + deps = { ...deps, realpathSync: path => { record(a); return path === join(a, ".codex") ? join(b, ".codex") : path; } }; + expect(inspect()).toEqual({ kind: "unknown" }); +}); + +test.each(["foreign", "unknown"])("standalone manager refuses %s before any manager or probe side effect", async kind => { + if (kind === "foreign") record(a); else writeFileSync(statePath, "{invalid"); + let manager = 0; let probes = 0; let released = false; + const lines: string[] = []; + const result = await restartServiceManagerAfterUpdate({ port: 19001, hostname: "127.0.0.1", release: () => { released = true; } }, line => lines.push(line), { + hasManager: () => true, serviceHomeAuthorityFn: () => { expect(released).toBe(true); return inspect(); }, + restartJob: () => { manager++; return { ok: false, reason: "fixture" }; }, + readRecord: () => { probes++; return null; }, + }); + expect(result).toBe("refused"); expect(manager).toBe(0); expect(probes).toBe(0); + expect(lines.join("\n")).not.toContain(a); expect(lines.join("\n")).not.toContain(b); +}); + +test("the manager rechecks after release, not before it", async () => { + record(b); let manager = 0; + const result = await restartServiceManagerAfterUpdate({ port: 19001, hostname: "127.0.0.1", release: () => record(a) }, () => {}, { + hasManager: () => true, serviceHomeAuthorityFn: inspect, + restartJob: () => { manager++; return { ok: false, reason: "fixture" }; }, + }); + expect(result).toBe("refused"); expect(manager).toBe(0); +}); + +test("unknown service home blocks the unmarked dashboard worker before every mutation", async () => { + writeFileSync(statePath, "{invalid"); + const { calls, io } = workerIo(); const job = fixtureJob(); + expect(await restartAfterUpdateForTests(job, { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 0, kill: 0, spawn: 0, wrappers: 0 }); + const saved = readUpdateJob(job.id); + expect(saved).toMatchObject({ status: "failed", restarted: false }); + expect(JSON.stringify(saved)).not.toContain(a); expect(JSON.stringify(saved)).not.toContain(b); +}); + +test("a stable foreign service is untouched while B's captured foreground runtime recovers directly", async () => { + record(a); const { calls, io } = workerIo(); + io.serviceInstalledFn = () => { throw new Error("foreign service must not be queried as B's manager"); }; + io.spawnStart = (_job, _installer, port) => { expect(port).toBe(19001); calls.spawn++; }; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1", oldPid: 4242 }, io)).toBeUndefined(); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 1, kill: 1, spawn: 1, wrappers: 0 }); +}); + +test("foreign direct pinned-start preparation receives no global service-stop authority", async () => { + record(a); const { calls, io } = workerIo(); + delete io.spawnStart; + io.probeProxyIdentity = async () => null; + io.probeProxy = async () => true; + io.preparePortForPinnedStartFn = (_job, _port, _list, _alive, _verify, stop) => { stop?.(); }; + io.waitForGhostListenClearFn = async () => ({ ok: true, accessDenied: false }); + io.spawnDetachedStartFn = () => { calls.spawn++; return Object.assign(new EventEmitter(), { pid: 5555, exitCode: null, signalCode: null }) as ChildProcess; }; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1", oldPid: 4242 }, io)).toBeUndefined(); + expect(calls.wrappers).toBe(0); expect(calls.manager).toBe(0); expect(calls.repair).toBe(0); expect(calls.spawn).toBe(1); +}); + +test("a changed foreign registration is not treated as stable B recovery authority", async () => { + record(a); const { calls, io } = workerIo(); + io.reacquireForDirectStartFn = () => { record(a, { bunPath: "/fixture/replaced-runtime" }); return null; }; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 0, kill: 0, spawn: 0, wrappers: 0 }); +}); + +test("same-home ordinary repair failure still recovers through the manager", async () => { + record(b); const { calls, io } = workerIo(); + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBeUndefined(); + expect(calls).toEqual({ repair: 1, manager: 1, reclaim: 1, kill: 0, spawn: 0, wrappers: 1 }); +}); + +test("confirmed absent service keeps the normal direct start without touching a manager", async () => { + const { calls, io } = workerIo(); + io.serviceInstalledFn = () => { throw new Error("absence needs no global installed probe"); }; + io.readPidFn = () => null; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBeUndefined(); + expect(calls.manager).toBe(0); expect(calls.repair).toBe(0); expect(calls.wrappers).toBe(0); expect(calls.spawn).toBe(1); +}); + +test.each([ + ["release", "foreign"], ["repair", "foreign"], ["reacquire", "foreign"], + ["release", "unknown"], ["repair", "unknown"], ["reacquire", "unknown"], +])("a home change during %s to %s refuses all remaining recovery", async (boundary, kind) => { + record(b); const { calls, io } = workerIo(); + const change = () => kind === "foreign" ? record(a) : writeFileSync(statePath, "{invalid"); + if (boundary === "release") io.releaseForServiceManagerFn = change; + else if (boundary === "repair") io.runService = () => { calls.repair++; change(); return { status: 1 }; }; + else { + io.restartServiceManagerFn = async () => { calls.manager++; return "failed"; }; + io.reacquireForDirectStartFn = () => { change(); return null; }; + } + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(calls.reclaim).toBe(1); expect(calls.kill).toBe(0); expect(calls.spawn).toBe(0); + expect(calls.repair).toBe(boundary === "release" ? 0 : 1); + expect(calls.manager).toBe(boundary === "reacquire" ? 1 : 0); + const persisted = readUpdateJob("home-guard"); + expect(persisted?.error).toContain("ocx service status"); + expect(persisted?.error).not.toContain("withheld"); + expect(JSON.stringify(persisted)).not.toContain(statePath); +}); + +test("confirmed absence after lease release skips repair and every later global service operation", async () => { + record(b); const { calls, io } = workerIo(); + io.releaseForServiceManagerFn = () => unlinkSync(statePath); + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe("unsupervised"); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 2, kill: 1, spawn: 1, wrappers: 1 }); +}); + +test("a manager's explicit refusal cannot turn into an ordinary fallback", async () => { + record(b); const { calls, io } = workerIo(); + io.restartServiceManagerFn = async () => "refused"; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(calls.kill).toBe(0); expect(calls.spawn).toBe(0); + expect(readUpdateJob("home-guard")).toMatchObject({ status: "failed", restarted: false }); +}); + +test("refusal guidance is static and points to manual service status", () => { + for (const kind of ["foreign", "unknown"] as const) expect(updateServiceHomeRefusal({ kind })).toContain("ocx service status"); + for (const kind of ["same", "absent"] as const) expect(updateServiceHomeRefusal({ kind })).toBeNull(); +}); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 3485555a38a..bccd8792997 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -536,7 +536,7 @@ describe("update stops the running proxy before replacing files", () => { expect(stopAt).toBeLessThan(updateAt); // The four signals are now inside a runtime-ownership veto: a desktop-owned runtime is // not stopped at all. Every original reason to stop still reaches the gate unchanged. - expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(updateSource).toContain("if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); }); test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { @@ -793,8 +793,8 @@ esac // A pending-teardown receipt is a fourth reason to stop: after a parent crashed // mid-deferral the service, pid and runtime records can all be absent while shared // client config still points at a proxy that is gone (#3008). - expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); - expect(launcherSource).toContain("const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown"); + expect(updateSource).toContain("if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(launcherSource).toContain("const stopNeeded = !homePolicy.packageOnly && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)"); expect(launcherSource).toContain("if (stopNeeded && !runtimePlan.mayStopRuntime)"); expect(launcherSource).toContain("if (stopNeeded) {"); // The rule now lives in the shared post-stop decision both lanes import (#3008): a @@ -805,7 +805,7 @@ esac }); test("the Node updater holds one authority from stop permission through replacement", () => { - const leaseAt = launcherSource.indexOf("const updateLease = acquireOwnershipMutationLease("); + const leaseAt = launcherSource.indexOf("let updateLease = acquireOwnershipMutationLease("); const lockedPlanAt = launcherSource.indexOf("const lockedPlan = planUpdateRuntimeHandling(", leaseAt); const stopAt = launcherSource.indexOf('[launcher, "stop"]', lockedPlanAt); const replacementAt = launcherSource.indexOf("const replacementOwnership = readOwnership()", stopAt); diff --git a/tests/windows/windows-deploy-close-regressions.test.ts b/tests/windows/windows-deploy-close-regressions.test.ts index 4a86cdd4688..e27941984dd 100644 --- a/tests/windows/windows-deploy-close-regressions.test.ts +++ b/tests/windows/windows-deploy-close-regressions.test.ts @@ -50,14 +50,20 @@ describe("update-job restart avoids the shell-less .cmd EINVAL (Windows, bun/sou // update — the common Windows path — with a stale service it could have refreshed. expect(src).toContain('process.env.OCX_SERVICE === "1" && refreshRegisters'); expect(src).toContain('const refreshRegisters = (svcArgs ?? []).includes("install")'); - // Native WinSW installs must stop via stopWinswService, not Task Scheduler /end only. - expect(src).toContain("readServiceBackend"); - expect(src).toContain("stopWinswService"); + // Follow the extracted teardown helper through its import and restart call site. + expect(src).toContain('import { stopWindowsServiceWrappersBestEffort } from "./job-windows-restart"'); + expect(src).toContain("(io.stopServiceWrappersFn ?? stopWindowsServiceWrappersBestEffort)();"); + const restart = read("src/update/job-windows-restart.ts"); + expect(restart).toContain("export function stopWindowsServiceWrappersBestEffort()"); + expect(restart).toContain('if (process.platform !== "win32") return;'); + // Native WinSW installs stop and return before Task Scheduler /end or wrapper cleanup. + expect(restart).toMatch(/if \(readServiceBackend\(\) === "native"\) \{\s*stopWinswService\(\);\s*return;\s*\}\s*stopWindows\(\);/); + expect(restart.indexOf("killWindowsServiceWrapperProcesses();")).toBeGreaterThan(restart.indexOf("stopWindows();")); // The wrapper-killer script (and its self-exclusion) moved to the shared // lib/windows-service-wrappers so the updater and the service teardown path // cannot drift apart again. Follow the invariant to where it lives; the // matching rules themselves are covered by windows-service-wrappers.test.ts. - expect(src).toContain("killWindowsSchedulerWrappers"); + expect(restart).toContain("killWindowsSchedulerWrappers({"); expect(read("src/lib/windows-service-wrappers.ts")).toContain("$_.ProcessId -eq $PID"); // Pinned-child cleanup is exercised through the retry loop in update/update-job.test.ts, // including exited children, live retry/final cleanup, and successful health probes. diff --git a/tests/windows/windows-service-wrappers.test.ts b/tests/windows/windows-service-wrappers.test.ts index 25a759a4c12..5e3f4dbe968 100644 --- a/tests/windows/windows-service-wrappers.test.ts +++ b/tests/windows/windows-service-wrappers.test.ts @@ -127,9 +127,9 @@ describe("the generated script still implements that rule", () => { describe("both teardown paths use the shared killer", () => { test("neither file keeps a private matcher", () => { - for (const rel of ["src/service/windows-ops.ts", "src/update/job.ts"]) { + for (const rel of ["src/service/windows-ops.ts", "src/update/job-windows-restart.ts"]) { const src = read(rel); - expect(src).toContain("killWindowsSchedulerWrappers"); + expect(src).toContain("killWindowsSchedulerWrappers({"); expect(src).not.toContain("-like ('*' + $p + '*')"); expect(src).not.toContain("$pats = @('opencodex-service.cmd'"); }