From aef76f43fc7593a37f9ad944eefcaf9b495b6453 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 15 Oct 2026 01:12:32 -0700 Subject: [PATCH 1/5] fix(update): preserve service-home authority during recovery Scope update recovery to stable service-home authority across worker, Node and Bun paths. Preserve stopped cold updates, require compatible inspection before delegation, and release recovery leases before caller exit. Document the recovery contract and add focused regression coverage. --- bin/ocx.mjs | 104 ++++++-- scripts/test-layout/layout.json | 5 + src/update/index.ts | 47 +++- src/update/job-supervised-restart.ts | 8 +- src/update/job-update-home.ts | 11 + src/update/job-windows-restart.ts | 34 +++ src/update/job.ts | 80 +++--- src/update/service-home-authority.ts | 35 +++ src/update/service-home-policy.d.mts | 16 ++ src/update/service-home-policy.mjs | 60 +++++ src/update/service-home-probe-entry.ts | 9 + src/update/service-home-probe.d.mts | 12 + src/update/service-home-probe.mjs | 55 +++++ src/update/service-home-protocol.json | 1 + structure/ops/update-supervised-recovery.md | 66 ++++- tests/cli/ocx-launcher-source.test.ts | 8 +- tests/fixtures/test-layout-expected.json | 5 + tests/helpers/update-home-fixtures.ts | 11 + tests/update/update-desktop-owner.test.ts | 13 +- tests/update/update-home-launcher.test.ts | 210 ++++++++++++++++ tests/update/update-home-recovery.test.ts | 82 +++++++ tests/update/update-home-worker.test.ts | 30 +++ .../update-job-supervised-restart.test.ts | 4 + tests/update/update-job.test.ts | 27 +++ tests/update/update-restart-lease.test.ts | 5 + .../update/update-service-home-policy.test.ts | 96 ++++++++ tests/update/update-service-home.test.ts | 227 ++++++++++++++++++ 27 files changed, 1180 insertions(+), 81 deletions(-) create mode 100644 src/update/job-update-home.ts create mode 100644 src/update/job-windows-restart.ts create mode 100644 src/update/service-home-authority.ts create mode 100644 src/update/service-home-policy.d.mts create mode 100644 src/update/service-home-policy.mjs create mode 100644 src/update/service-home-probe-entry.ts create mode 100644 src/update/service-home-probe.d.mts create mode 100644 src/update/service-home-probe.mjs create mode 100644 src/update/service-home-protocol.json create mode 100644 tests/helpers/update-home-fixtures.ts create mode 100644 tests/update/update-home-launcher.test.ts create mode 100644 tests/update/update-home-recovery.test.ts create mode 100644 tests/update/update-home-worker.test.ts create mode 100644 tests/update/update-service-home-policy.test.ts create mode 100644 tests/update/update-service-home.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6baf5168b5f..a6aa7b5a7c5 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 } 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)); + if (!homePolicy.check()) 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( @@ -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.check() || 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."); @@ -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") { @@ -762,6 +807,7 @@ function runPackageManagerSelfUpdate(manager) { process.exit(1); } if (stopNeeded) { + if (!homePolicy.check()) { releaseUpdateLease(); process.exit(1); } const preStopPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision(), }); @@ -935,6 +981,8 @@ function runPackageManagerSelfUpdate(manager) { } const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (res.status === 0) { + authorityLauncher = postUpdateLauncher; + 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 +1000,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 +1034,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 +1077,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 +1091,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 +1109,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 +1129,13 @@ if (updateHelpRequested) { process.exit(0); } +// This intercept precedes Desktop handoff, boot restore and install-capable Bun resolution. +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: 1 })); + 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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..68539485b20 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -3,6 +3,11 @@ "root": "tests", "explicitShards": ["layout-clients.json"], "explicit": { "service-log.test.ts": "service", + "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", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/src/update/index.ts b/src/update/index.ts index 6723365ab68..a6a6d54e226 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -15,6 +15,7 @@ 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 { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; import { detectInstallOwnershipFromPath } from "./install-detection.mjs"; @@ -489,12 +490,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)); + if (!homePolicy.check()) 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. @@ -566,10 +569,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 +606,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 +664,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 +680,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 +691,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. @@ -717,6 +735,7 @@ export async function runUpdate(): Promise { // Full `ocx stop` semantics (drain, service stop, restore). if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { + if (!homePolicy.check()) return 1; const preStopPlan = planUpdateRuntimeHandling({ ...(await resolvedRuntimeOwnership()), serviceInstalled: serviceWasInstalled, @@ -915,20 +934,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 +972,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 +985,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 +1001,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..94feec118c4 --- /dev/null +++ b/src/update/job-update-home.ts @@ -0,0 +1,11 @@ +import { spawnSync } from "node:child_process"; +import { currentServiceHomes } from "../service/state"; +import { probeUpdateLauncherHome, 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 probeUpdateLauncherHome(launcher, spawnSync, node, env).kind !== "unknown"; + } catch { return false; } +} 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..86d6dc1e57d 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 { automaticUpdateLauncherSupported } 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, @@ -1807,6 +1793,7 @@ export interface GuiUpdateWorkerIo { restartIo?: RestartIo; /** Resolves who owns the runtime; defaults to the shared service install state. */ resolveOwnershipFn?: () => ServiceOwnershipResolution; + launcherSupportsUpdateHomeFn?: (launcher: string, node: string) => boolean; runCommandFn?: ( job: UpdateJobState, bin: string, @@ -1906,6 +1893,11 @@ export async function runGuiUpdateWorker( } } + if ((check.installer === "npm" || check.installer === "pnpm") + && !(io.launcherSupportsUpdateHomeFn ?? automaticUpdateLauncherSupported)(cmd.args[0]!, cmd.bin)) { + updateJob(job, { status: "failed", error: "Automatic update refused: guarded recovery protocol unavailable. Check 'ocx service status' and update this CLI manually before retrying." }); + return; + } if (process.platform === "win32") { try { const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows"); 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-policy.d.mts b/src/update/service-home-policy.d.mts new file mode 100644 index 00000000000..8c7f76e1ece --- /dev/null +++ b/src/update/service-home-policy.d.mts @@ -0,0 +1,16 @@ +export type UpdateServiceHomeAuthority = { + readonly kind: "same" | "foreign" | "unknown" | "absent"; + readonly subject?: string; +}; +export interface UpdateServiceHomePolicy { + readonly initial: UpdateServiceHomeAuthority; + readonly serviceAllowed: boolean; + check(home?: UpdateServiceHomeAuthority): boolean; +} +export declare const UPDATE_HOME_SCHEMA_VERSION: 1; +export declare const UPDATE_HOME_LAUNCHER_PROTOCOL: 1; +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): 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..9aa2762f7b3 --- /dev/null +++ b/src/update/service-home-policy.mjs @@ -0,0 +1,60 @@ +/** 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; + +/** 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 */ +export function createUpdateServiceHomePolicy(inspect, refuse) { + const initial = inspect(); + let current = initial; + return { + initial, + get serviceAllowed() { return current.kind === "same"; }, + check(home = inspect()) { + 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; + }, + }; +} + +/** 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..7b6eee3ce89 --- /dev/null +++ b/src/update/service-home-probe.d.mts @@ -0,0 +1,12 @@ +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; diff --git a/src/update/service-home-probe.mjs b/src/update/service-home-probe.mjs new file mode 100644 index 00000000000..4b16451e7dc --- /dev/null +++ b/src/update/service-home-probe.mjs @@ -0,0 +1,55 @@ +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" }; } +} 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..9374c3e21da 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,65 @@ 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. + +The dashboard worker's npm/pnpm delegation first checks the fixed package-local declaration +`src/update/service-home-protocol.json` and then the exact delegated launcher's private +`__update-home-probe` response. Missing or invalid declarations cause zero launcher executions. +`src/update/job-update-home.ts` owns this gate. A launcher marker is a compatibility claim from +trusted installed files, not tamper attestation. A new child alone cannot prove an old parent's support. + +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 +125,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..fdadc95f69a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,9 @@ { + "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", "codex-history-pending-wal.test.ts": "codex-integration", "service-log.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", diff --git a/tests/helpers/update-home-fixtures.ts b/tests/helpers/update-home-fixtures.ts new file mode 100644 index 00000000000..edb5a37f44f --- /dev/null +++ b/tests/helpers/update-home-fixtures.ts @@ -0,0 +1,11 @@ +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: () => {}, +}; +export const absentHomeRestartIo: typeof sameHomeRestartIo = { + ...sameHomeRestartIo, + serviceHomeAuthorityFn: () => ({ kind: "absent" }), +}; diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index 5826d96d6fc..f170f3c18fc 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -342,7 +342,7 @@ 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 end = source.indexOf(lane === "Bun" ? ' if (stopStdio === "pipe")' : " const stillHasRuntimeState", start); @@ -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 }, 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 home uncertainty 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..a8b4c6a4922 --- /dev/null +++ b/tests/update/update-home-launcher.test.ts @@ -0,0 +1,210 @@ +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(); } +}); 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..e4310f1809c --- /dev/null +++ b/tests/update/update-home-worker.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { mkdirSync } from "node:fs"; +import { createTempHome } from "../helpers/temp-home"; +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(); + } +}); diff --git a/tests/update/update-job-supervised-restart.test.ts b/tests/update/update-job-supervised-restart.test.ts index ef127b74f62..debcb72ba57 100644 --- a/tests/update/update-job-supervised-restart.test.ts +++ b/tests/update/update-job-supervised-restart.test.ts @@ -1,3 +1,4 @@ +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. @@ -40,6 +41,7 @@ describe("restartServiceManagerAfterUpdate", () => { function deps(over: ManagerRestartDeps = {}): ManagerRestartDeps { let now = 0; return { + serviceHomeAuthorityFn: () => ({ kind: "same" }), hasManager: () => true, restartJob: () => ({ ok: true }), managerPid: () => 50, @@ -118,6 +120,7 @@ describe("restartServiceManagerAfterUpdate", () => { describe("dashboard restart after a failed service refresh", () => { function managedIo(manager: "supervised" | "failed" | "unsettled", spawned: { count: number }): RestartIo { return { + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => false, listListenPidsFn: () => [], @@ -176,6 +179,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..c60bb5cf005 --- /dev/null +++ b/tests/update/update-service-home-policy.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { createUpdateServiceHomePolicy, isUpdateHomeProbe, parseUpdateHomeProbe, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; +import { 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" }); +}); 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(); +}); From 29f34140627d788676a0be51b37c740918949945 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 15 Oct 2026 04:56:27 -0700 Subject: [PATCH 2/5] fix(update): allow guarded package-only updates under initial uncertainty --- bin/ocx.mjs | 35 ++++++--- .../docs/troubleshooting/update-failed.md | 9 ++- src/update/index.ts | 20 +++-- src/update/job-update-home.ts | 37 +++++++++- src/update/job.ts | 25 ++++--- .../service-home-package-only-protocol.json | 1 + src/update/service-home-policy.d.mts | 5 +- src/update/service-home-policy.mjs | 32 +++++--- src/update/service-home-probe.d.mts | 3 + src/update/service-home-probe.mjs | 24 ++++++ structure/ops/update-supervised-recovery.md | 27 +++++-- tests/helpers/update-bun-ownership-child.ts | 18 ++++- .../update/update-bun-ownership-lease.test.ts | 30 +++++++- tests/update/update-desktop-owner.test.ts | 8 +- tests/update/update-home-launcher.test.ts | 65 ++++++++++++++++ tests/update/update-home-worker.test.ts | 74 ++++++++++++++++++- .../update/update-service-home-policy.test.ts | 47 +++++++++++- tests/update/update-stop-first.test.ts | 8 +- 18 files changed, 410 insertions(+), 58 deletions(-) create mode 100644 src/update/service-home-package-only-protocol.json diff --git a/bin/ocx.mjs b/bin/ocx.mjs index a6aa7b5a7c5..bbcdc3b1d5c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -15,7 +15,7 @@ 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 } from "../src/update/service-home-policy.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, @@ -330,8 +330,8 @@ function runPackageManagerSelfUpdate(manager) { ? allServiceStatePaths.slice(0, 1) : allServiceStatePaths; let authorityLauncher = fileURLToPath(import.meta.url); - const homePolicy = createUpdateServiceHomePolicy(() => readNodeUpdateHome(authorityLauncher), message => console.warn(message)); - if (!homePolicy.check()) process.exit(1); + 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. @@ -368,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; @@ -711,7 +711,7 @@ function runPackageManagerSelfUpdate(manager) { // earlier preflight and this boundary therefore blocks stop before it is sent. const lockedOwnership = readOwnership(); const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); - if (!homePolicy.check() || 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."); @@ -724,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, { @@ -799,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 @@ -874,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"); @@ -982,6 +987,12 @@ 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."); @@ -1130,9 +1141,15 @@ if (updateHelpRequested) { } // 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: 1 })); + console.log(JSON.stringify({ schemaVersion: 1, ...readNodeUpdateHome(fileURLToPath(import.meta.url)), launcherProtocolVersion: UPDATE_HOME_LAUNCHER_PROTOCOL })); process.exit(0); } 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/src/update/index.ts b/src/update/index.ts index a6a6d54e226..5c315a1c673 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -16,6 +16,7 @@ import { planUpdateRuntimeHandling, planStoppedRuntimeRecovery, inspectPackageRu 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"; @@ -490,8 +491,8 @@ 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)); - if (!homePolicy.check()) return 1; + 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; @@ -516,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(); @@ -734,7 +735,11 @@ 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()), @@ -824,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 @@ -904,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 diff --git a/src/update/job-update-home.ts b/src/update/job-update-home.ts index 94feec118c4..0a001975796 100644 --- a/src/update/job-update-home.ts +++ b/src/update/job-update-home.ts @@ -1,11 +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 { probeUpdateLauncherHome, updateHomeProbeEnvironment } from "./service-home-probe.mjs"; +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 probeUpdateLauncherHome(launcher, spawnSync, node, env).kind !== "unknown"; + 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.ts b/src/update/job.ts index 86d6dc1e57d..72206ee58ee 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -52,7 +52,7 @@ import { type NpmCachePreflightReason, } from "./npm-cache-preflight.mjs"; import { guiUpdateWorkerCommand } from "./worker-launch"; -import { automaticUpdateLauncherSupported } from "./job-update-home"; +import { prepareWorkerUpdateHome, packageOnlyUpdateSucceeded } from "./job-update-home"; import { withoutSiblingMarker } from "../codex/sibling-start"; import type { WorkerLaunchContext } from "./worker-launch"; @@ -1791,8 +1791,8 @@ 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, @@ -1893,12 +1893,12 @@ export async function runGuiUpdateWorker( } } - if ((check.installer === "npm" || check.installer === "pnpm") - && !(io.launcherSupportsUpdateHomeFn ?? automaticUpdateLauncherSupported)(cmd.args[0]!, cmd.bin)) { - updateJob(job, { status: "failed", error: "Automatic update refused: guarded recovery protocol unavailable. Check 'ocx service status' and update this CLI manually before retrying." }); - return; - } - 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(); @@ -1926,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 @@ -1960,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-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 index 8c7f76e1ece..cef5dc1ae2a 100644 --- a/src/update/service-home-policy.d.mts +++ b/src/update/service-home-policy.d.mts @@ -5,12 +5,15 @@ export type UpdateServiceHomeAuthority = { 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): UpdateServiceHomePolicy; + 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 index 9aa2762f7b3..18c68af52d4 100644 --- a/src/update/service-home-policy.mjs +++ b/src/update/service-home-policy.mjs @@ -3,6 +3,7 @@ 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 */ @@ -30,21 +31,32 @@ export function parseUpdateHomeProbe(value, { requireLauncher = false } = {}) { } /** A foreign registration never manages this foreground runtime. A changed claim is not absence. - * @param {() => UpdateServiceHomeAuthority} inspect @param {(message: string) => void} refuse */ -export function createUpdateServiceHomePolicy(inspect, refuse) { + * @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, - get serviceAllowed() { return current.kind === "same"; }, + packageOnly, + get serviceAllowed() { return !packageOnly && current.kind === "same"; }, + checkPackage(home = inspect()) { + return initial.kind === "unknown" && home.kind === "unknown" || checkAuthority(home); + }, check(home = inspect()) { - 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."); + if (!packageOnly) return checkAuthority(home); + refuse(updateServiceHomeRefusal({ kind: "unknown" })); return false; }, }; diff --git a/src/update/service-home-probe.d.mts b/src/update/service-home-probe.d.mts index 7b6eee3ce89..a138cf3c4ab 100644 --- a/src/update/service-home-probe.d.mts +++ b/src/update/service-home-probe.d.mts @@ -10,3 +10,6 @@ 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 index 4b16451e7dc..d0752d786e3 100644 --- a/src/update/service-home-probe.mjs +++ b/src/update/service-home-probe.mjs @@ -53,3 +53,27 @@ export function probeUpdateServiceHome(run, bin, prefix, { requireLauncher = fal 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/structure/ops/update-supervised-recovery.md b/structure/ops/update-supervised-recovery.md index 9374c3e21da..f9cc640a4e3 100644 --- a/structure/ops/update-supervised-recovery.md +++ b/structure/ops/update-supervised-recovery.md @@ -45,12 +45,27 @@ The Node launcher, Bun updater and dashboard worker use the same transition poli 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. - -The dashboard worker's npm/pnpm delegation first checks the fixed package-local declaration -`src/update/service-home-protocol.json` and then the exact delegated launcher's private -`__update-home-probe` response. Missing or invalid declarations cause zero launcher executions. -`src/update/job-update-home.ts` owns this gate. A launcher marker is a compatibility claim from -trusted installed files, not tamper attestation. A new child alone cannot prove an old parent's support. +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 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/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 f170f3c18fc..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) {"); }); @@ -344,7 +344,7 @@ describe("live Desktop update authority", () => { 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,7 +366,7 @@ async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEviden try { await run({ runtimePlan, serviceWasInstalled: true, readPid: () => 321, readRuntimePort: () => ({}), - homePolicy: { check: () => homeAllowed, serviceAllowed: true }, + homePolicy: { check: () => homeAllowed, serviceAllowed: true, packageOnly: false }, pendingTeardownOutstanding: () => false, stopNeeded: true, resolvedRuntimeOwnership: async () => owner, readOwnership: () => owner, planUpdateRuntimeHandling, supervisionLatch: latch, inspectDesktopSupervision: inspect, @@ -388,7 +388,7 @@ 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 home uncertainty refuses before stop and package replacement", async () => { +test("Bun newly uncertain home refuses before stop and package replacement", async () => { expect(await exercisePreStop("Bun", { kind: "none" }, true, false)).toEqual([0, 0, 0]); }); diff --git a/tests/update/update-home-launcher.test.ts b/tests/update/update-home-launcher.test.ts index a8b4c6a4922..ceb6b0cebf5 100644 --- a/tests/update/update-home-launcher.test.ts +++ b/tests/update/update-home-launcher.test.ts @@ -208,3 +208,68 @@ test("the installed test Bun honors --no-env-file while the control loads the fi } } 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-worker.test.ts b/tests/update/update-home-worker.test.ts index e4310f1809c..8ab639713d6 100644 --- a/tests/update/update-home-worker.test.ts +++ b/tests/update/update-home-worker.test.ts @@ -1,6 +1,8 @@ import { expect, test } from "bun:test"; -import { mkdirSync } from "node:fs"; +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 () => { @@ -28,3 +30,73 @@ test("an old delegated launcher is refused before automatic update starts", asyn 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-service-home-policy.test.ts b/tests/update/update-service-home-policy.test.ts index c60bb5cf005..e9eaf091dde 100644 --- a/tests/update/update-service-home-policy.test.ts +++ b/tests/update/update-service-home-policy.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createUpdateServiceHomePolicy, isUpdateHomeProbe, parseUpdateHomeProbe, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; -import { probeUpdateServiceHome, probeUpdateLauncherHome, updateHomeProbeEnvironment } from "../../src/update/service-home-probe.mjs"; +import { probeUpdatePackageOnlyLauncher, probeUpdateServiceHome, probeUpdateLauncherHome, updateHomeProbeEnvironment } from "../../src/update/service-home-probe.mjs"; const subject = "a".repeat(64); const other = "b".repeat(64); @@ -94,3 +94,48 @@ test("probe pins homes, keeps authoritative SQLite/auth inputs and scrubs only i 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-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); From 9897f453ffc5653237790e218cf93c075718c85e Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 15 Oct 2026 05:49:33 -0700 Subject: [PATCH 3/5] test(update): relocate service-home registrations to avoid merge conflicts --- scripts/test-layout/layout.json | 10 +++++----- tests/fixtures/test-layout-expected.json | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 68539485b20..117fd52ecc5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -3,11 +3,6 @@ "root": "tests", "explicitShards": ["layout-clients.json"], "explicit": { "service-log.test.ts": "service", - "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", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", @@ -1768,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/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fdadc95f69a..1c5f3614be7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,9 +1,4 @@ { - "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", "codex-history-pending-wal.test.ts": "codex-integration", "service-log.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", @@ -1143,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", From 2d8102d0680c388c5a400a54da3cd744a3f66a57 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 15 Oct 2026 13:39:13 -0700 Subject: [PATCH 4/5] test(windows): follow extracted update teardown helper --- .../windows-deploy-close-regressions.test.ts | 14 ++++++++++---- tests/windows/windows-service-wrappers.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) 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'"); } From 1da3dfbfe19ebeff25e497aae374f94550ca49bc Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 15 Oct 2026 19:31:28 -0700 Subject: [PATCH 5/5] test(update): isolate fallback fixtures from host service managers --- tests/helpers/update-home-fixtures.ts | 5 +++- .../update-job-supervised-restart.test.ts | 27 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/helpers/update-home-fixtures.ts b/tests/helpers/update-home-fixtures.ts index edb5a37f44f..0bc3e81edc0 100644 --- a/tests/helpers/update-home-fixtures.ts +++ b/tests/helpers/update-home-fixtures.ts @@ -1,9 +1,12 @@ 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 = { +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, diff --git a/tests/update/update-job-supervised-restart.test.ts b/tests/update/update-job-supervised-restart.test.ts index debcb72ba57..4c7673c6716 100644 --- a/tests/update/update-job-supervised-restart.test.ts +++ b/tests/update/update-job-supervised-restart.test.ts @@ -3,13 +3,14 @@ import { sameHomeRestartIo, absentHomeRestartIo } from "../helpers/update-home-f * 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"; @@ -118,6 +119,30 @@ 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,