diff --git a/bin/ocx.mjs b/bin/ocx.mjs index bf15425fb3d..2be5336d9b5 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { guardedRun, guardedSpawn, guardedSpawnSync, guardedExit, probeProcessAlive, relayOwnSignal } from "../src/update/mutation-guard.mjs"; /** * opencodex published-package bin launcher. * @@ -8,13 +9,15 @@ * Node shim. (Dev still runs `bun run src/cli/index.ts` directly via the shebang on * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) */ -import { spawn, spawnSync } from "node:child_process"; +import { probeSpawn, probeSpawnSync } from "../src/update/mutation-guard.mjs"; import { createSupervisionLatch, inspectDesktopSupervision } from "../src/service/desktop-supervision.mjs"; import { planDesktopCliHandoff, runDesktopCliHandoff } from "../src/lib/desktop-cli-handoff.mjs"; 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, parseUpdateWorkerHome, UPDATE_HOME_LAUNCHER_PROTOCOL, UPDATE_PACKAGE_ONLY_EXIT_CODE, UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION } from "../src/update/service-home-policy.mjs"; +import { probeUpdateServiceHome, supportsUpdateHomeProbe, updateHomeProbeEnvironment } from "../src/update/service-home-probe.mjs"; import { inspectPackageRuntimeLiveness, planStoppedRuntimeRecovery, @@ -85,7 +88,6 @@ try { /* best-effort */ } } -const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); const installOwnership = detectInstallOwnershipFromPath(here, { exists: existsSync }); const installMethod = installOwnership.installer; @@ -109,9 +111,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 ""; @@ -140,6 +142,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(probeSpawnSync, 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")); } @@ -156,9 +171,10 @@ function historyRestoreIncomplete() { } } -function repairCodexShimIfNeeded(launcherPath = fileURLToPath(import.meta.url)) { +function repairCodexShimIfNeeded(launcherPath, authorizeMutation) { if (!shouldRepairCodexShim()) return; - const res = spawnSync(process.execPath, [launcherPath, "codex-shim", "install"], { + if (!authorizeMutation("shim")) return false; + const res = guardedSpawnSync(authorizeMutation, "shim", process.execPath, [launcherPath, "codex-shim", "install"], { stdio: "inherit", windowsHide: true, }); @@ -174,15 +190,16 @@ function trayInstallState() { try { const heartbeat = JSON.parse(readFileSync(join(configDir(), "tray-heartbeat.json"), "utf8")); if (Number.isSafeInteger(heartbeat.pid) && Date.now() - Number(heartbeat.timestamp) < 15_000) { - process.kill(heartbeat.pid, 0); + probeProcessAlive(heartbeat.pid); running = true; } } catch { /* installed but not running */ } return { installed: true, running }; } -function runTrayLifecycle(launcher, action) { - return spawnSync(process.execPath, [launcher, "tray", action], { +function runTrayLifecycle(launcher, action, authorizeMutation) { + if (!authorizeMutation(action === "start" ? "own-tray" : "tray")) return { status: 1 }; + return guardedSpawnSync(authorizeMutation, action === "start" ? "own-tray" : "tray", process.execPath, [launcher, "tray", action], { stdio: "inherit", windowsHide: true, }); @@ -228,7 +245,7 @@ async function runPackageManagerSelfUpdate(manager) { runPnpm: (commandPath, args, capture = false) => { const invocation = pnpmInvocationForPath(commandPath, args); if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { + return probeSpawnSync(invocation.file, invocation.args, { stdio: capture ? "pipe" : "ignore", encoding: "utf8", timeout: 20_000, @@ -271,7 +288,7 @@ async function runPackageManagerSelfUpdate(manager) { console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`); process.exit(1); } - const latestResult = spawnSync(latestInvocation.file, latestInvocation.args, readProbeOptions(latestInvocation)); + const latestResult = probeSpawnSync(latestInvocation.file, latestInvocation.args, readProbeOptions(latestInvocation)); const latest = latestResult.status === 0 && typeof latestResult.stdout === "string" ? latestResult.stdout.trim() : ""; console.log(`opencodex v${current} (installed via ${manager}, tag ${tag})`); @@ -283,7 +300,7 @@ async function runPackageManagerSelfUpdate(manager) { const integrity = checkRegistryPackageIntegrity(PKG, latest || null, args => { const invocation = managerInvocation(args); if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, readProbeOptions(invocation)); + return probeSpawnSync(invocation.file, invocation.args, readProbeOptions(invocation)); }); if (integrity.ok === false) { console.error(`opencodex: ${integrity.reason}; aborting before stopping the proxy.`); @@ -320,7 +337,10 @@ async function runPackageManagerSelfUpdate(manager) { const serviceStatePaths = process.env.OCX_TEST_HOME_GUARD === "1" ? allServiceStatePaths.slice(0, 1) : allServiceStatePaths; - const serviceWasInstalled = serviceStatePaths.some(path => existsSync(path)); + let authorityLauncher = fileURLToPath(import.meta.url); + const homePolicy = createUpdateServiceHomePolicy(() => readNodeUpdateHome(authorityLauncher), message => console.warn(message), process.argv.includes("--update-package-only"), parseUpdateWorkerHome(process.argv)); + if (!homePolicy.authorizeMutation("package")) 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( @@ -356,7 +376,7 @@ async 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; @@ -387,7 +407,7 @@ async function runPackageManagerSelfUpdate(manager) { */ function readServiceInstalledFromStatus(launcherPath) { try { - const st = spawnSync(process.execPath, [launcherPath, "status", "--json"], { + const st = probeSpawnSync(process.execPath, [launcherPath, "status", "--json"], { encoding: "utf8", timeout: 20_000, windowsHide: true, @@ -482,13 +502,20 @@ async function runPackageManagerSelfUpdate(manager) { /** Returns true, false, or "vetoed" when another owner holds the runtime (not a failure). */ function startProxyDirectly() { + try { reacquireUpdateLease(); } catch { + console.warn("opencodex: recovery could not acquire runtime ownership; run 'ocx service repair' or 'ocx start' manually after the other operation finishes."); + return false; + } + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + const currentOwnership = readOwnership(); const supervision = observeSupervision(); - const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: false, supervision }); - if (!plan.mayStopRuntime) { + const plan = planUpdateRuntimeHandling({ ...currentOwnership, serviceInstalled: false, supervision }); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + if (ownershipIdentity(currentOwnership) !== ownershipIdentity(initialOwnership) || !plan.mayStopRuntime) { console.warn(supervision ? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored." : plan.notice); - return "vetoed"; + return homePolicy.authorizeMutation("publish") ? "vetoed" : "refused"; } if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); @@ -500,7 +527,8 @@ async function runPackageManagerSelfUpdate(manager) { delete env.OCX_SIBLING_OF_PORT; delete env.OCX_SIBLING_HANDOFF_NONCE; console.log(`Attempting to restart the proxy on port ${bakePort}.`); - const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + const child = guardedSpawn(homePolicy.authorizeMutation, "runtime", process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { detached: true, stdio: "ignore", windowsHide: true, @@ -514,7 +542,7 @@ async function runPackageManagerSelfUpdate(manager) { while (Date.now() < deadline) { const current = readCurrentRuntimeTarget(); if (current.kind === "target" - && probeProxyLiveness(current.target.port, current.target.hostname ?? bakeHostname) === "live") return true; + && probeProxyLiveness(current.target.port, current.target.hostname ?? bakeHostname) === "live") return homePolicy.authorizeMutation("publish") ? true : "refused"; Atomics.wait(UPDATE_RECOVERY_SLEEP, 0, 0, UPDATE_RECOVERY_POLL_MS); } console.error("opencodex: the recovery proxy did not publish a healthy runtime before the recovery deadline."); @@ -526,10 +554,19 @@ async function runPackageManagerSelfUpdate(manager) { // "supervised", "failed", or "unsettled" when the manager may still finish the restart. function restartThroughServiceManager() { if (!hasRestartableServiceManager()) return "failed"; + releaseUpdateLease(); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + if (!homePolicy.serviceAllowed) return "failed"; + const owner = readOwnership(); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + if (ownershipIdentity(owner) !== ownershipIdentity(initialOwnership) + || !planUpdateRuntimeHandling({ ...owner, serviceInstalled: true, supervision: observeSupervision() }).mayRestoreService) return homePolicy.authorizeMutation("publish") ? "vetoed" : "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; - const restarted = restartServiceManagerJob(); + if (!homePolicy.authorizeMutation("service")) return homePolicy.authorizeMutation("runtime") ? "failed" : "refused"; + const restarted = restartServiceManagerJob({}, homePolicy.authorizeMutation); + if (!restarted.ok && restarted.refused) return "refused"; const unsettled = !restarted.ok && restarted.unsettled === true; let verdict; if (!restarted.ok && !unsettled) { @@ -550,7 +587,8 @@ async function runPackageManagerSelfUpdate(manager) { verdict = supervisionVerdict({ servingPid, managerPid: readServiceManagerPid(), parentPid: pid => readParentPid(pid) }); } if (verdict.kind === "supervised") { - console.log(`opencodex: the proxy is healthy on port ${bakePort} under the service manager (pid ${verdict.managerPid}).`); + if (!homePolicy.authorizeMutation("publish")) return "refused"; + guardedRun(homePolicy.authorizeMutation, "publish", console.log, [`opencodex: the proxy is healthy on port ${bakePort} under the service manager (pid ${verdict.managerPid}).`]); return "supervised"; } if (unsettled) { @@ -566,21 +604,34 @@ async function runPackageManagerSelfUpdate(manager) { * manager may still finish its restart; callers exit non-zero for both. */ function refreshBackgroundServiceOrStartDirect() { + authorityLauncher = postUpdateLauncher; + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(startProxyDirectly()) ? undefined : "refused"; + let homeRefused = false; const mayRefresh = () => { + if (!homePolicy.authorizeMutation("runtime")) { homeRefused = true; return false; } const supervision = observeSupervision(); const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); + if (!homePolicy.authorizeMutation("runtime")) { homeRefused = true; return false; } 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()) { + if (homeRefused) return "refused"; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(startProxyDirectly()) ? undefined : "refused"; + return; + } const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { - let svc = spawnSync(process.execPath, serviceRefreshArgs(), { + if (!homePolicy.authorizeMutation("service")) return homePolicy.authorizeMutation("runtime") && [true, "vetoed"].includes(startProxyDirectly()) ? undefined : "refused"; + let svc = guardedSpawnSync(homePolicy.authorizeMutation, "service", process.execPath, serviceRefreshArgs(), { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), }); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(startProxyDirectly()) ? 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 @@ -590,18 +641,25 @@ async 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()) { + if (homeRefused) return "refused"; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(startProxyDirectly()) ? undefined : "refused"; + return; + } console.log("No registered service found — installing it instead."); - svc = spawnSync(process.execPath, serviceInstallArgs(), { + const serviceInstallArgsForSpawn = serviceInstallArgs(); + if (!homePolicy.authorizeMutation("service")) return homePolicy.authorizeMutation("runtime") && [true, "vetoed"].includes(startProxyDirectly()) ? undefined : "refused"; + svc = guardedSpawnSync(homePolicy.authorizeMutation, "service", process.execPath, serviceInstallArgsForSpawn, { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), }); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; } let needDirectStart = svc.status !== 0; if (!needDirectStart) { // Exit 0 can still leave stale/missing assets that never bring the proxy // back — match the GUI/CLI fallthrough so /healthz is not left dead. try { - const st = spawnSync(process.execPath, [postUpdateLauncher, "status", "--json"], { + const st = probeSpawnSync(process.execPath, [postUpdateLauncher, "status", "--json"], { encoding: "utf8", timeout: 20_000, windowsHide: true, @@ -625,6 +683,7 @@ async function runPackageManagerSelfUpdate(manager) { // just returned is indistinguishable from any other failure at this layer. const supervision = observeSupervision(); const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; if (!nowOwned.mayStopRuntime) { console.warn(supervision ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." @@ -632,9 +691,11 @@ async function runPackageManagerSelfUpdate(manager) { return; } // #7043: the manager first. Only a proxy it owns counts as recovered. + if (!homePolicy.authorizeMutation("runtime")) return "refused"; const managed = restartThroughServiceManager(); - if (managed === "supervised") return; + if (managed === "supervised" || managed === "vetoed") 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 @@ -648,6 +709,7 @@ async function runPackageManagerSelfUpdate(manager) { const started = startProxyDirectly(); // An ownership veto is the correct outcome, not a degraded one. if (started === "vetoed") return; + if (started === "refused") return "refused"; if (started) console.warn(unsupervisedRuntimeWarning(bakePort)); return "unsupervised"; } @@ -657,7 +719,7 @@ async function runPackageManagerSelfUpdate(manager) { } } - const updateLease = acquireOwnershipMutationLease(serviceStatePaths); + let updateLease = acquireOwnershipMutationLease(serviceStatePaths); delegatedOwnershipMutationToken = updateLease.token; let updateLeaseReleased = false; const releaseUpdateLease = () => { @@ -666,15 +728,22 @@ async 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.authorizeMutation("package") || 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."); @@ -687,15 +756,19 @@ async 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 { + if (!homePolicy.authorizeMutation("tray")) { releaseUpdateLease(); process.exit(1); } + let stopAttempted = false; handoffWindowsTrayForUpdate(trayBeforeUpdate, { stop: () => { - const stopped = runTrayLifecycle(launcher, "stop"); + if (!homePolicy.authorizeMutation("tray")) throw new Error("Automatic update tray handoff refused."); + stopAttempted = true; + const stopped = runTrayLifecycle(launcher, "stop", homePolicy.authorizeMutation); return { exitStatus: stopped.status, running: trayInstallState().running }; }, - start: () => runTrayLifecycle(launcher, "start"), + start: () => { if (stopAttempted && homePolicy.authorizeMutation("own-tray")) return runTrayLifecycle(launcher, "start", homePolicy.authorizeMutation); }, }); } catch { releaseUpdateLease(); @@ -705,9 +778,10 @@ async 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.authorizeMutation("runtime")) return; const planRecovery = () => { const recoveryOwnership = readOwnership(); const liveness = currentPackageRuntimeLiveness(); @@ -731,6 +805,7 @@ async 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.authorizeMutation("runtime")) return; ({ liveness: recoveryLiveness, plan: recovery } = planRecovery()); } if (recovery.reason === "desktop-supervised") { @@ -760,7 +835,12 @@ async 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 @@ -768,6 +848,7 @@ async function runPackageManagerSelfUpdate(manager) { process.exit(1); } if (stopNeeded) { + if (!homePolicy.authorizeMutation("runtime")) { releaseUpdateLease(); process.exit(1); } const preStopPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision(), }); @@ -776,9 +857,16 @@ async function runPackageManagerSelfUpdate(manager) { releaseUpdateLease(); process.exit(1); } + if (!homePolicy.authorizeMutation("runtime")) { releaseUpdateLease(); process.exit(1); } + if (serviceWasInstalled && !homePolicy.serviceAllowed) { + console.warn("Service authority changed before proxy stop; check 'ocx service status' before retrying the update."); + releaseUpdateLease(); + process.exit(1); + } stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); - const stopRes = spawnSync(process.execPath, [launcher, "stop"], { + if (!homePolicy.authorizeMutation(serviceWasInstalled ? "service" : "runtime")) { releaseUpdateLease(); process.exit(1); } + const stopRes = guardedSpawnSync(homePolicy.authorizeMutation, serviceWasInstalled ? "service" : "runtime", process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), }); const stillHasRuntimeState = @@ -802,7 +890,7 @@ async function runPackageManagerSelfUpdate(manager) { }); const historyOnlyStop = decision.reason === "history-only"; if (!decision.proceed) { - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (trayBeforeUpdate.restoreOnFailure && homePolicy.authorizeMutation("own-tray")) runTrayLifecycle(launcher, "start", homePolicy.authorizeMutation); if (decision.reason === "teardown-outstanding") { console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); @@ -834,12 +922,12 @@ async function runPackageManagerSelfUpdate(manager) { const replacementOwnership = readOwnership(); const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); const replacementLiveness = currentPackageRuntimeLiveness(); - if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + if (!homePolicy.authorizeMutation("package") || replacementOwnership.subjectToken !== initialOwnership.subjectToken || !replacementPlan.mayReplacePackage || replacementLiveness !== "dead") { recoverStoppedRuntimeAfterFailure("replacement was refused"); releaseUpdateLease(); - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (trayBeforeUpdate.restoreOnFailure && homePolicy.authorizeMutation("own-tray")) runTrayLifecycle(launcher, "start", homePolicy.authorizeMutation); console.error(replacementPlan.notice ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); process.exit(1); @@ -853,19 +941,30 @@ async function runPackageManagerSelfUpdate(manager) { if (manager === "npm") { const packageDir = resolve(here, ".."); const transactionalNpmUpdate = process.platform === "win32" ? transactionalNpmUpdateAsync : transactionalNpmUpdateSync; + if (!homePolicy.authorizeMutation("package")) throw new Error("Automatic package update refused."); const tx = await transactionalNpmUpdate({ packageDir, pkgName: PKG, targetVersion: latest || undefined, tag, cachePath: npmCachePath, + mayReplacePackage: () => { + const ownership = readOwnership(); + const plan = planUpdateRuntimeHandling({ ...ownership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); + const liveness = currentPackageRuntimeLiveness(); + return homePolicy.authorizeMutation("package") && ownership.subjectToken === initialOwnership.subjectToken + && plan.mayReplacePackage && liveness === "dead"; + }, runNpm: (args) => { const invocation = npmInvocation(args); + if (!homePolicy.authorizeMutation("package")) return { status: 1 }; if (process.platform === "win32") return runNpmInstall(invocation, { + authorizeMutation: homePolicy.authorizeMutation, env: unprivilegedOwnershipMutationEnvironment(invocation?.options?.env ?? process.env), }); if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { + if (!homePolicy.authorizeMutation("package")) return { status: 1 }; + return guardedSpawnSync(homePolicy.authorizeMutation, "package", invocation.file, invocation.args, { ...invocation.options, stdio: "inherit", timeout: 180000, @@ -879,7 +978,7 @@ async function runPackageManagerSelfUpdate(manager) { if (!tx.ok) npmFailure = tx; if (tx.ok) { res = { status: 0 }; - } else if (tx.phase === "stage" || tx.phase === "verify") { + } else if (tx.phase === "stage" || tx.phase === "verify" || tx.phase === "authorize") { // Live tree untouched: report and stop. Nothing to roll back. console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); res = { status: 1 }; @@ -888,6 +987,7 @@ async function runPackageManagerSelfUpdate(manager) { res = { status: 1 }; } } else { + if (!homePolicy.authorizeMutation("package")) throw new Error("Automatic package update refused."); const update = runPnpmGlobalUpdate({ packageName: PKG, currentVersion: current, @@ -898,7 +998,7 @@ async function runPackageManagerSelfUpdate(manager) { runPnpm: (args, capture = false) => { const invocation = pnpmOwnerInvocation(owner, args); if (!invocation) return { status: 1 }; - return withPnpmCommandCwd(args, cwd => spawnSync(invocation.file, invocation.args, { + return withPnpmCommandCwd(args, cwd => homePolicy.authorizeMutation("package") ? guardedSpawnSync(homePolicy.authorizeMutation, "package", invocation.file, invocation.args, { ...invocation.options, stdio: capture ? "pipe" : "inherit", encoding: "utf8", @@ -908,7 +1008,7 @@ async function runPackageManagerSelfUpdate(manager) { // keep a cwd handle inside the package Windows is replacing. cwd, env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env)), - })); + }) : { status: 1 }); }, log: line => console.log(line), }); @@ -945,31 +1045,63 @@ async function runPackageManagerSelfUpdate(manager) { } const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (res.status === 0) { - console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); + authorityLauncher = postUpdateLauncher; + if (homePolicy.packageOnly) { + if (!homePolicy.authorizeMutation("package-publish")) process.exit(1); + guardedRun(homePolicy.authorizeMutation, "package-publish", console.log, [`\nUpdated${latest ? ` to v${latest}` : ""}.`]); + homePolicy.check(homePolicy.initial); + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); + if (!homePolicy.authorizeMutation("package-publish")) process.exit(1); + guardedExit(homePolicy.authorizeMutation, "package-publish", process.argv.includes("--update-worker-result") ? UPDATE_PACKAGE_ONLY_EXIT_CODE : 0); + } + const restoreOwnStoppedTray = () => { + if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable && existsSync(postUpdateLauncher)) { + try { if (homePolicy.authorizeMutation("own-tray")) runTrayLifecycle(postUpdateLauncher, "start", homePolicy.authorizeMutation); } catch { /* preserve the authority refusal */ } + } + }; + if (!homePolicy.authorizeMutation("publish")) { + restoreOwnStoppedTray(); + process.exit(1); + } + guardedRun(homePolicy.authorizeMutation, "publish", 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); + if (!homePolicy.authorizeMutation("shim") || repairCodexShimIfNeeded(postUpdateLauncher, homePolicy.authorizeMutation) === false) { restoreOwnStoppedTray(); process.exit(1); } if (trayBeforeUpdate.refreshAfterReplacement) { - const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], { + if (!homePolicy.authorizeMutation("tray")) { + restoreOwnStoppedTray(); + process.exit(1); + } + const tray = guardedSpawnSync(homePolicy.authorizeMutation, "tray", process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], { stdio: "inherit", windowsHide: true, }); if (tray.status !== 0) { console.warn("opencodex: Windows tray refresh failed. Run: ocx tray install"); - if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); + if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable && homePolicy.authorizeMutation("own-tray")) runTrayLifecycle(postUpdateLauncher, "start", homePolicy.authorizeMutation); } } // The stop above unloaded any managed service; refresh via the freshly-installed // 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); + let refreshed; + try { refreshed = refreshBackgroundServiceOrStartDirect(); } + finally { releaseUpdateLease(); } + if (refreshed === "unsupervised" || refreshed === "unsettled" || refreshed === "refused") process.exit(1); } else if (postInstallPlan.mayStopRuntime) { - console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); + if (!homePolicy.authorizeMutation("runtime")) process.exit(1); + authorityLauncher = postUpdateLauncher; + if (stopAttempted) { + let started; + try { started = startProxyDirectly(); } + finally { releaseUpdateLease(); } + if (started !== true && started !== "vetoed") process.exit(1); + } else console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } - process.exit(0); + if (!homePolicy.authorizeMutation("publish")) process.exit(1); + guardedExit(homePolicy.authorizeMutation, "publish", 0); } - if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); + if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable && homePolicy.authorizeMutation("own-tray")) runTrayLifecycle(postUpdateLauncher, "start", homePolicy.authorizeMutation); if (npmFailure) { // Phase-specific next step (#5624): whether the previous version is still in place decides // between "retry" and "restore", and a bare reinstall must follow a stop (#5496). @@ -988,10 +1120,11 @@ async 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")); + const resolver = createRequire(launcherPath || import.meta.url); + return dirname(resolver.resolve("bun/package.json")); } const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; @@ -1040,7 +1173,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(); @@ -1054,7 +1187,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; @@ -1064,7 +1197,7 @@ function resolveBun({ allowInstall = true } = {}) { // ~450-byte placeholder stub. Run the bun package's own installer once. const installJs = bunDir ? join(bunDir, "install.js") : null; if (allowInstall && installJs && existsSync(installJs)) { - const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); + const r = probeSpawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } if (bin) return { path: bin, source: "bundled", version: bundledBunVersion(bunDir) }; @@ -1072,11 +1205,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", version: pathBun.version }; } + if (allowMissing) return null; fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed."); } @@ -1091,6 +1225,19 @@ if (updateHelpRequested) { process.exit(0); } +// This intercept precedes Desktop handoff, boot restore and install-capable Bun resolution. +// Capability only: no Bun lookup, home inspection, dotenv or ordinary bootstrap. +if (process.argv[2] === "__update-package-only-probe") { + if (process.argv.length !== 3) process.exit(2); + console.log(JSON.stringify({ schemaVersion: 1, packageOnlyProtocolVersion: UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION })); + process.exit(0); +} +if (process.argv[2] === "__update-home-probe") { + if (!isUpdateHomeProbe(process.argv.slice(2))) process.exit(1); + console.log(JSON.stringify({ schemaVersion: 1, ...readNodeUpdateHome(fileURLToPath(import.meta.url)), launcherProtocolVersion: UPDATE_HOME_LAUNCHER_PROTOCOL })); + process.exit(0); +} + const codexCliUpdateInspection = isCodexCliUpdateInspectionArgv(process.argv); // Windows inspection currently refuses before candidate I/O, so a long-lived // Windows server does not duplicate PATH into its inherited environment block. @@ -1121,7 +1268,7 @@ if (desktopPlan.kind === "handoff") { } if (result.kind === "exit") { if (result.signal) { - process.kill(process.pid, result.signal); + relayOwnSignal(result.signal); } else { process.exit(result.code); } @@ -1277,7 +1424,7 @@ function createNodeLaunchContext() { return { launchProof, launchContext, inheritedEnv }; } -const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { +const child = probeSpawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { stdio: "inherit", // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no // console to inherit. Without this flag Windows allocates a visible console for @@ -1319,7 +1466,7 @@ child.on("exit", (code, signal) => { clearHandlers(); // Mirror the child's terminating signal/exit code so this launcher's status matches. if (signal) { - process.kill(process.pid, signal); + relayOwnSignal(signal); return; } process.exit(code ?? 1); diff --git a/docs-site/src/content/docs/troubleshooting/update-failed.md b/docs-site/src/content/docs/troubleshooting/update-failed.md index 678eb5b03d7..d23220dc6dc 100644 --- a/docs-site/src/content/docs/troubleshooting/update-failed.md +++ b/docs-site/src/content/docs/troubleshooting/update-failed.md @@ -20,7 +20,14 @@ and, afterwards, a folder next to the package that cannot be deleted because `bu The updater stops the proxy, installs the new version into a separate staging folder, checks it, and only then swaps it in. When the install step fails, the swap never happens: the previous version stays installed and runnable, and the updater restarts the previous background service (or the -previous proxy) before it exits. You do not need to reinstall to get back to a working proxy. +previous proxy) before it exits, when ownership and service-home checks authorize recovery. + +If the initial service-home check is inconclusive, for example because Bun is missing or a service +query fails, the updater can replace an idle package without stopping or restarting the proxy. +A successful package-only update prints a manual restart hint and explains that service ownership +could not be verified. Check `ocx service status` and the registered installation before restarting. +Changes in authority, unknown durable runtime ownership, Desktop supervision, a live package +runtime or an unresolved teardown receipt still block package replacement; an inconclusive check is not permission to control them. Check that with: diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0b939a4f83f..5f77589eba5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -46,7 +46,7 @@ "cli-capability-provider-workflows.test.ts": "cli", "cli-capability-access-workflows.test.ts": "cli", "cli-capability-lab-workflows.test.ts": "cli", "skill-ocx-workflows.test.ts": "ci-workflows", "cli-capability-data.test.ts": "cli", "skill-ocx-generated.test.ts": "ci-workflows", "cli-update-restart.test.ts": "cli", "cli-update-restart-child.test.ts": "cli", "cli-update-restart-transport.test.ts": "cli", "cli-update-restart-home.test.ts": "cli", - "cli-update-restart-service-record.test.ts": "cli", "cli-update-restart-supervision.test.ts": "cli", "cli-update-restart-admission.test.ts": "cli", "cli-update-restart-desktop-service.test.ts": "cli", + "cli-update-restart-service-record.test.ts": "cli", "cli-update-restart-supervision.test.ts": "cli", "cli-update-restart-admission.test.ts": "cli", "cli-update-restart-desktop-service.test.ts": "cli", "update-reclaim-authorizer.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-worker.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-capability-policy.test.ts": "update", "codex-shim-overlay.test.ts": "codex-integration", "codex-shim-migration.test.ts": "codex-integration", "codex-shim-overlay-transaction.test.ts": "codex-integration", "codex-shim-overlay-readiness.test.ts": "codex-integration", "codex-shim-overlay-candidates.test.ts": "codex-integration", diff --git a/src/config/owner-registry.ts b/src/config/owner-registry.ts index 8f7d67b3ad8..289e7cea909 100644 --- a/src/config/owner-registry.ts +++ b/src/config/owner-registry.ts @@ -156,10 +156,11 @@ export function readOwnerRegistry(): OwnerRegistryRead { } /** Retire 'home' from the shared registry. Best-effort like registration. */ -export function unregisterOwnerRegistryHome(home: string): void { - try { - unlinkSync(registryEntryPath(ownerRegistryDir(), home)); - } catch { /* a missing pointer needs no removal */ } +export function unregisterOwnerRegistryHome(home: string, beforeMutation?: () => void): void { + let path: string; + try { path = registryEntryPath(ownerRegistryDir(), home); } catch { return; } + beforeMutation?.(); + try { unlinkSync(path); } catch { /* a missing pointer needs no removal */ } } /** Register this process's own home after its runtime record is published. */ diff --git a/src/config/process-state.ts b/src/config/process-state.ts index c9053ab1512..f61f714b8d9 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -97,17 +97,21 @@ export function readRuntimePort(expectedPid?: number): RuntimePortState | null { } } -export function removePid(expectedPid?: number): void { +export function removePid(expectedPid?: number, beforeMutation?: () => void): void { if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return; - try { unlinkSync(getPidPath()); } catch { /* ignore */ } + const path = getPidPath(); + beforeMutation?.(); + try { unlinkSync(path); } catch { /* ignore */ } } -export function removeRuntimePort(expectedPid?: number): void { +export function removeRuntimePort(expectedPid?: number, beforeMutation?: () => void): void { if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return; - try { unlinkSync(getRuntimePortPath()); } catch { /* ignore */ } + const path = getRuntimePortPath(); + beforeMutation?.(); + try { unlinkSync(path); } catch { /* ignore */ } // The record is gone, so the registry pointer names a dead home. Retire it // beside the record or stale pointers accumulate toward the reader's cap. - unregisterOwnerRegistryHome(getConfigDir()); + unregisterOwnerRegistryHome(getConfigDir(), beforeMutation); } /** diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 94fd9dcab2e..d2fe5b7471f 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -367,22 +367,31 @@ async function waitForStoppedPort( } } -export function killProxy(pid: number): void { +export function killProxy(pid: number, mayTerminate?: () => boolean): void { if (!isProcessAlive(pid)) return; + const authorize = () => { + if (mayTerminate && mayTerminate() !== true) throw Object.assign(new Error("Process termination refused"), { code: "EUPDATEAUTHORITY" }); + }; if (process.platform === "win32") { // Windows process.kill(SIGTERM/SIGINT) is TerminateProcess — not a graceful signal. // Graceful drain happens only via stopProxyGracefully() (POST /api/stop). This path // is the hard fallback: taskkill /T /F so the process tree exits (ghost LISTEN / // CLOSE_WAIT are then cleared by reclaimListenPort / SetTcpEntry). const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; + const args = ["/PID", String(pid), "/T", "/F"]; + authorize(); try { - execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true }); + execFileSync(taskkill, args, { stdio: "pipe", windowsHide: true }); } catch (err) { if (isProcessAlive(pid)) throw err; } } else { + authorize(); process.kill(pid, "SIGTERM"); - if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL"); + if (!waitForExit(pid, 5000)) { + authorize(); + process.kill(pid, "SIGKILL"); + } } if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`); } diff --git a/src/lib/windows-service-wrappers.ts b/src/lib/windows-service-wrappers.ts index b74d93deba8..358e6a6771d 100644 --- a/src/lib/windows-service-wrappers.ts +++ b/src/lib/windows-service-wrappers.ts @@ -63,14 +63,17 @@ export function windowsWrapperKillScript(paths: readonly string[]): string { export function killWindowsSchedulerWrappers(paths: { scriptPath: string; launcherPath: string; -}): void { +}, beforeMutation?: () => void): void { if (process.platform !== "win32") return; + let file: string, args: string[]; try { - spawnSync(resolveTrustedWindowsPowerShellExe(), [ - // No `-WindowStyle Hidden` here: Bun 1.3.14 can fail that direct CLI pair - // before the command runs (#1589). `windowsHide` below is sufficient. - "-NoProfile", "-NoLogo", "-NonInteractive", - "-Command", windowsWrapperKillScript([paths.scriptPath, paths.launcherPath]), - ], { stdio: "ignore", timeout: 5000, windowsHide: true }); + file = resolveTrustedWindowsPowerShellExe(); + args = ["-NoProfile", "-NoLogo", "-NonInteractive", "-Command", + windowsWrapperKillScript([paths.scriptPath, paths.launcherPath])]; + } catch { return; } + beforeMutation?.(); + try { + // windowsHide avoids Bun's direct -WindowStyle argv failure (#1589). + spawnSync(file, args, { stdio: "ignore", timeout: 5000, windowsHide: true }); } catch { /* best-effort */ } } diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 5e4de978811..b7e16493f1f 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -184,9 +184,11 @@ const SERVICE_COMMAND_TIMEOUT_MS = 15_000; /** `stopwait` outlasts the SCM `` (20s in the generated service definition) plus overhead; still bounded so a wedged SCM cannot hang the CLI. */ const SERVICE_STOPWAIT_TIMEOUT_MS = 40_000; -function runWinsw(args: string[]): string { +function runWinsw(args: string[], beforeMutation?: () => void): string { const timeoutMs = args[0] === "stopwait" ? SERVICE_STOPWAIT_TIMEOUT_MS : SERVICE_COMMAND_TIMEOUT_MS; - return execFileSync(winswExePath(), args, { + const file = winswExePath(); + beforeMutation?.(); + return execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, timeout: timeoutMs, }).trim(); } @@ -352,8 +354,10 @@ export function startWinswService(): void { runWinsw(["start"]); } * catch cannot decide whether it is safe for lifecycle callers to continue. Re-read * SCM state and only accept the two states that cannot still own the proxy listener. */ -export function stopWinswService(): void { - try { runWinsw(["stopwait"]); } catch { /* classify by verified state below */ } +export function stopWinswService(beforeMutation?: () => void): void { + let refused = false; + const gate = beforeMutation && (() => { try { beforeMutation(); } catch (error) { refused = true; throw error; } }); + try { runWinsw(["stopwait"], gate); } catch (error) { if (refused) throw error; /* classify by verified state below */ } const status = statusWinswRaw(); if (status === "stopped" || status === "nonexistent") return; if (status === "unknown") { diff --git a/src/server/port-reclaim.ts b/src/server/port-reclaim.ts index c3dcc0231bf..b436fdd738c 100644 --- a/src/server/port-reclaim.ts +++ b/src/server/port-reclaim.ts @@ -64,9 +64,16 @@ export type ReclaimListenPortOptions = WaitForPortOptions & { listListenPidsFn?: (port: number) => ListenPidScan | number[]; isAliveFn?: (pid: number) => boolean; verifyOcxFn?: (pid: number) => number | null; - killFn?: (pid: number) => void; - dropTcpFn?: (port: number) => number | { dropped: number; skippedIpv6: number }; + killFn?: (pid: number, mayMutate?: () => boolean) => void; + dropTcpFn?: (port: number, mayMutate?: () => boolean) => number | { dropped: number; skippedIpv6: number }; isAvailableFn?: (port: number, hostname?: string) => Promise; + /** + * Synchronous authority gate re-checked after each availability await and + * immediately before every destructive action (killFn / dropTcpFn). Returning + * false denies the mutation and ends the reclaim: the port is reported + * not-freed without touching processes or TCP rows. A throwing gate denies too. + */ + mayMutateFn?: () => boolean; sleepMs?: (ms: number) => Promise; }; @@ -516,6 +523,8 @@ export async function ownsIpv4LoopbackListener( * revalidates immediately before each kill. * Never overrides a rejected ocx verifier result. Never drops TCP rows while a * rejected live or protected ocx listener owns the port, or when the scan failed. + * A denied `mayMutateFn` ends the wait: no kill or TCP-row drop may run under + * an authority that no longer holds. */ export async function reclaimListenPort( port: number, @@ -539,13 +548,21 @@ export async function reclaimListenPort( const dropTcpFn = opts.dropTcpFn ?? dropWindowsTcpRowsForLocalPort; const isAvailableFn = opts.isAvailableFn ?? isPortAvailable; const sleep = opts.sleepMs ?? ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))); + let denied = false; + const mayMutate = (): boolean => { + try { denied ||= opts.mayMutateFn !== undefined && opts.mayMutateFn() !== true; } catch { denied = true; } + return !denied; + }; const deadline = Date.now() + timeoutMs; let lastScan = 0; const killed = new Set(); for (;;) { - if (await isAvailableFn(port, hostname)) return true; + const available = await isAvailableFn(port, hostname); + // The authority may have changed while the availability probe was in flight. + if (!mayMutate()) return false; + if (available) return true; if (Date.now() >= deadline) return false; if (Date.now() - lastScan >= scanIntervalMs) { @@ -586,10 +603,13 @@ export async function reclaimListenPort( if (!killed.has(pid)) { // Revalidate immediately before termination. if (isAliveFn(pid) && verifyOcxFn(pid) === pid && mayKillThis) { + if (!mayMutate()) return false; try { - killFn(pid); + if (opts.mayMutateFn) killFn(pid, mayMutate); + else killFn(pid); killed.add(pid); } catch { + if (denied) return false; // Kill failed: keep waiting and never reset this listener's TCP rows. protectedOcxListener = true; } @@ -619,9 +639,13 @@ export async function reclaimListenPort( // Reset those IPv4 TCBs (and ghost LISTEN rows) so the configured port can bind again — // without killing the browser process. Only safe when no live foreign/protected listener remains. if (dropTcpRows) { + // Same gate as the kill path: denied authority never runs SetTcpEntry. + if (!mayMutate()) return false; try { - dropTcpFn(port); + if (opts.mayMutateFn) dropTcpFn(port, mayMutate); + else dropTcpFn(port); } catch { + if (denied) return false; /* access denied / unsupported — keep waiting */ } } diff --git a/src/server/windows-tcp-drop.ts b/src/server/windows-tcp-drop.ts index 80ff9712c92..518aae24904 100644 --- a/src/server/windows-tcp-drop.ts +++ b/src/server/windows-tcp-drop.ts @@ -137,7 +137,7 @@ function readNetstatAno(): string { * Bare IPv6 rows (`::1`, `::`, etc.) are skipped (not coerced into IPv4 wildcards); * IPv6 TCB reclamation is unsupported on this path. */ -export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResult { +export function dropWindowsTcpRowsForLocalPort(port: number, mayMutate?: () => boolean): WindowsTcpDropResult { if (process.platform !== "win32" || !Number.isFinite(port) || port <= 0) { return { dropped: 0, skippedIpv6: 0, accessDenied: 0 }; } @@ -171,8 +171,10 @@ export function dropWindowsTcpRowsForLocalPort(port: number): WindowsTcpDropResu view.setUint32(8, htons(row.localPort), true); view.setUint32(12, remoteDw, true); view.setUint32(16, htons(row.remotePort), true); + const pointer = ptr(buf); + if (mayMutate && mayMutate() !== true) throw Object.assign(new Error("TCP deletion refused"), { code: "EUPDATEAUTHORITY" }); try { - const rc = setTcpEntry(ptr(buf)); + const rc = setTcpEntry(pointer); if (rc === 0) dropped += 1; // 5 = ERROR_ACCESS_DENIED, 317 = non-elevated SetTcpEntry (MSDN). else if (rc === 5 || rc === 317) accessDenied += 1; diff --git a/src/service/windows-ops.ts b/src/service/windows-ops.ts index cc328a6f302..440b267726b 100644 --- a/src/service/windows-ops.ts +++ b/src/service/windows-ops.ts @@ -840,10 +840,13 @@ export function isWindowsSchedulerEndBenign(error: unknown): boolean { * that *succeeds* while the wrapper survives and respawns. That verification lives * on the stop-verification path (poll across the restart window), not here. */ -export function stopWindows(): void { +export function stopWindows(beforeMutation?: () => void): void { + let refused = false; + const gate = beforeMutation && (() => { try { beforeMutation(); } catch (error) { refused = true; throw error; } }); try { - schtasks(["/end", "/tn", TASK]); + schtasks(["/end", "/tn", TASK], gate); } catch (error) { + if (refused) throw error; if (isWindowsSchedulerEndBenign(error)) return; } } diff --git a/src/service/windows-scheduler.ts b/src/service/windows-scheduler.ts index 944b61f587e..f04f8c62660 100644 --- a/src/service/windows-scheduler.ts +++ b/src/service/windows-scheduler.ts @@ -71,7 +71,7 @@ function windowsSchtasks(): string { let querySchtasksForTests: ((args: string[]) => string) | null = null; -export function querySchtasks(args: string[]): string { +export function querySchtasks(args: string[], beforeMutation?: () => void): string { // The repository preload isolates HOME and OPENCODEX_HOME, but Task Scheduler is // machine-global. A partially-faked service test once fell through here and replaced the // user's real `opencodex-proxy` task with a launcher inside its temporary test home; the @@ -86,8 +86,10 @@ export function querySchtasks(args: string[]): string { + "inject the scheduler operation instead of calling the live manager.", ); } - if (querySchtasksForTests) return querySchtasksForTests(args); - return runFile(windowsSchtasks(), args); + if (querySchtasksForTests) { beforeMutation?.(); return querySchtasksForTests(args); } + const file = windowsSchtasks(); + beforeMutation?.(); + return runFile(file, args); } /** Test-only seam for Task Scheduler query used by presence probes. */ @@ -95,13 +97,18 @@ export function setQuerySchtasksForTests(next: ((args: string[]) => string) | nu querySchtasksForTests = next; } -export function schtasks(args: string[], io: { +export function schtasks(args: string[], control: (() => void) | { run?: (args: string[]) => string; probeTask?: () => WindowsSchedulerTaskProbe; } = {}): string { + const beforeMutation = typeof control === "function" ? control : undefined; + const io = typeof control === "function" ? {} : control; + let refused = false; + const gate = beforeMutation && (() => { try { beforeMutation(); } catch (error) { refused = true; throw error; } }); try { - return (io.run ?? querySchtasks)(args); + return io.run ? io.run(args) : querySchtasks(args, gate); } catch (error) { + if (refused) throw error; let taskPresent = false; const normalized = args.map(arg => arg.toLowerCase()); const taskIndex = normalized.indexOf("/tn"); diff --git a/src/tray/windows.ts b/src/tray/windows.ts index 79e004ac859..08e967430e4 100644 --- a/src/tray/windows.ts +++ b/src/tray/windows.ts @@ -622,11 +622,13 @@ export async function runWindowsTrayHost(): Promise { }); } -function signalTrayStop(): ReturnType { +function signalTrayStop(beforeMutation?: () => void): ReturnType { const previous = readHeartbeat(); // The stop event name depends only on the current home. Never execute paths // recovered from tray-state.json while attempting cleanup or repair. - execFileSync(windowsPowerShellPath(), windowsTrayProcessArgs(currentEntry(), "Stop"), { + const file = windowsPowerShellPath(), args = windowsTrayProcessArgs(currentEntry(), "Stop"); + beforeMutation?.(); + execFileSync(file, args, { stdio: "ignore", windowsHide: true, timeout: 15_000, @@ -743,11 +745,11 @@ export function startWindowsTray(): WindowsTrayStatus { return getWindowsTrayStatus(); } -export function stopWindowsTray(): WindowsTrayStatus { +export function stopWindowsTray(beforeMutation?: () => void): WindowsTrayStatus { assertWindows(); let previous = readHeartbeat(); if (previous) { - previous = signalTrayStop(); + previous = signalTrayStop(beforeMutation); } if (!waitForTrayExit(previous)) throw new Error("The tray did not exit after the stop signal. Its login registration was preserved."); return getWindowsTrayStatus(); diff --git a/src/update/async-check.ts b/src/update/async-check.ts index 70ec269b7a2..4044030ddcb 100644 --- a/src/update/async-check.ts +++ b/src/update/async-check.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { probeSpawn } from "./mutation-guard.mjs"; import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs"; import { PKG, registrySpawnTarget, type Channel, type Installer } from "./index"; import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs"; @@ -40,11 +41,11 @@ export async function pnpmOwner(deps: PnpmOwnerDeps = {}): Promise Promise; - spawnFn: typeof spawn; + spawnFn: typeof probeSpawn; deadlineMs?: number; } -const defaultDeps: AsyncLookupDeps = { ownerFn: pnpmOwner, spawnFn: spawn }; +const defaultDeps: AsyncLookupDeps = { ownerFn: pnpmOwner, spawnFn: probeSpawn }; export async function latestVersionAsync( channel: Channel, diff --git a/src/update/bind-probe.ts b/src/update/bind-probe.ts index 016402abb9f..7360072fa3e 100644 --- a/src/update/bind-probe.ts +++ b/src/update/bind-probe.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { probeSpawnSync } from "./mutation-guard.mjs"; /** * Strict bind script: exit 0 only after listen+close. Any listen error (including @@ -17,7 +17,7 @@ export function strictBindProbeScript(port: number, hostname: string): string { export function spawnBindProbe(bin: string, script: string): boolean { try { - const r = spawnSync(bin, ["-e", script], { + const r = probeSpawnSync(bin, ["-e", script], { windowsHide: true, timeout: 4000, stdio: "ignore", diff --git a/src/update/guarded-port-reclaim.ts b/src/update/guarded-port-reclaim.ts new file mode 100644 index 00000000000..ee202b0728b --- /dev/null +++ b/src/update/guarded-port-reclaim.ts @@ -0,0 +1,14 @@ +import { reclaimListenPort, type ReclaimListenPortOptions } from "../server/port-reclaim"; + +export type UpdateReclaimOptions = ReclaimListenPortOptions & { mayMutateFn: () => boolean }; + +/** Update callers must supply authority; the shared helper keeps its ordinary defaults. */ +export async function guardedReclaimListenPort(port: number, hostname: string | undefined, + options: UpdateReclaimOptions): Promise { + // Finish option accessors before capturing the gate. Inherited/omitted gates + // are absent from this copy and cannot select the shared helper's permissive default. + const prepared = { ...options }; + if (typeof prepared.mayMutateFn !== "function") return false; + try { if (prepared.mayMutateFn() !== true) return false; } catch { return false; } + return reclaimListenPort(port, hostname, prepared); +} diff --git a/src/update/guarded-runtime-ops.ts b/src/update/guarded-runtime-ops.ts new file mode 100644 index 00000000000..289f801e17e --- /dev/null +++ b/src/update/guarded-runtime-ops.ts @@ -0,0 +1,58 @@ +import { isProcessAlive, waitForExit } from "../lib/process-control"; +import type { killProxy } from "../lib/process-control"; +import { atomicWriteFile } from "../config/atomic-write"; +import { removePid, removeRuntimePort } from "../config/process-state"; +import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop"; +import { stopWinswService } from "../lib/winsw"; +import { stopWindows } from "../service"; +import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers"; +import { guardedRun, guardedKill, guardedExecFileSync, materializeMutationData, UpdateMutationRefusedError, type UpdateMutationAuthorizer } from "./mutation-guard.mjs"; + +export type KillProxy = typeof killProxy; +function mutationGate[0]>(authorize: (kind: K) => boolean, kind: K) { + return () => { if (authorize(kind) !== true) throw new UpdateMutationRefusedError(); }; +} +export function guardedKillProxy(authorize: (kind: K) => boolean, kind: K, pid: number, native?: KillProxy) { + if (native) return guardedRun(authorize, kind, native, [pid]); // trusted external-I/O test seam + const target = materializeMutationData(pid); + if (!isProcessAlive(target)) return; + if (process.platform === "win32") { + const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; + try { guardedExecFileSync(authorize, kind, taskkill, ["/PID", String(target), "/T", "/F"], { stdio: "pipe", windowsHide: true }); } + catch (error) { if (error instanceof UpdateMutationRefusedError || isProcessAlive(target)) throw error; } + } else { + guardedKill(authorize, kind, target, "SIGTERM"); + if (!waitForExit(target, 5000)) guardedKill(authorize, kind, target, "SIGKILL"); + } + if (!waitForExit(target, 5000)) throw new Error(`process ${target} did not exit`); +} +export function guardedPublishJob(authorize: UpdateMutationAuthorizer, kind: "publish" | "package-publish", + target: string, text: string, native: typeof atomicWriteFile = atomicWriteFile): void { + const [path, payload] = materializeMutationData([target, text]); + const validateBeforeRename = () => { if (authorize(kind) !== true) throw new UpdateMutationRefusedError(); }; + validateBeforeRename(); + // The atomic writer carries this gate through temp preparation AND every rename retry. + native(path, payload, undefined, { validateBeforeRename }); +} +export function guardedRemovePid(authorize: (kind: "runtime") => boolean, pid?: number) { + return removePid(materializeMutationData(pid), mutationGate(authorize, "runtime")); +} +export function guardedRemoveRuntimePort(authorize: (kind: "runtime") => boolean) { + return removeRuntimePort(undefined, mutationGate(authorize, "runtime")); +} +export function guardedDropWindowsTcp(authorize: (kind: "runtime") => boolean, port: number) { + const target = materializeMutationData(port); + return dropWindowsTcpRowsForLocalPort(target, () => { + if (authorize("runtime") !== true) throw new UpdateMutationRefusedError(); + return true; + }); +} +export function guardedStopWinsw(authorize: UpdateMutationAuthorizer) { + return stopWinswService(mutationGate(authorize, "service")); +} +export function guardedStopWindows(authorize: UpdateMutationAuthorizer) { + return stopWindows(mutationGate(authorize, "service")); +} +export function guardedKillWindowsWrappers(authorize: UpdateMutationAuthorizer, paths: Parameters[0]) { + return killWindowsSchedulerWrappers(materializeMutationData(paths), mutationGate(authorize, "service")); +} diff --git a/src/update/guarded-tray-ops.ts b/src/update/guarded-tray-ops.ts new file mode 100644 index 00000000000..cc53f481f86 --- /dev/null +++ b/src/update/guarded-tray-ops.ts @@ -0,0 +1,9 @@ +import { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } from "../tray/windows"; +import { guardedRun, UpdateMutationRefusedError, type UpdateMutationAuthorizer } from "./mutation-guard.mjs"; +export { getWindowsTrayStatus }; +export function guardedStartWindowsTray(authorize: UpdateMutationAuthorizer) { + return guardedRun(authorize, "own-tray", startWindowsTray, []); +} +export function guardedStopWindowsTray(authorize: UpdateMutationAuthorizer) { + return stopWindowsTray(() => { if (authorize("tray") !== true) throw new UpdateMutationRefusedError(); }); +} diff --git a/src/update/index.ts b/src/update/index.ts index 6723365ab68..c47eb6556ec 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -1,4 +1,5 @@ -import { spawn, spawnSync } from "node:child_process"; +import { guardedRun, guardedSpawn, guardedSpawnSync } from "./mutation-guard.mjs"; +import { probeSpawn, probeSpawnSync } from "./mutation-guard.mjs"; import { createSupervisionLatch, inspectDesktopSupervision } from "../service/desktop-supervision.mjs"; import { manualUpdateFailureGuidance } from "./update-failure-guidance.mjs"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs"; @@ -15,6 +16,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, parseUpdateWorkerHome, 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"; @@ -106,11 +108,11 @@ function runPnpmCandidate( commandPath: string, args: readonly string[], capture = false, - spawn: typeof spawnSync = spawnSync, + probeSpawn: typeof probeSpawnSync = probeSpawnSync, ): { status: number | null; stdout?: string | null; stderr?: string | null } { const invocation = pnpmInvocationForPath(commandPath, args); if (!invocation) return { status: 1 }; - return spawn(invocation.file, invocation.args, { + return probeSpawn(invocation.file, invocation.args, { stdio: capture ? "pipe" : "ignore", encoding: "utf8", timeout: 20_000, @@ -124,15 +126,15 @@ function runPnpmCandidate( /** Resolve the exact pnpm executable/group/bin that own this package. */ export function resolveCurrentPnpmGlobalOwner( invoked = process.argv[1], - deps: { commandPaths?: readonly string[]; spawn?: typeof spawnSync } = {}, + deps: { commandPaths?: readonly string[]; spawn?: typeof probeSpawnSync } = {}, ): PnpmGlobalOwnerResult { - const spawn = deps.spawn ?? spawnSync; + const probeSpawn = deps.spawn ?? probeSpawnSync; return resolvePnpmGlobalOwner({ packageName: PKG, packagePath: packageRoot(), commandPaths: deps.commandPaths ?? resolvePnpmCommands(), runningShimPath: runningPnpmShimPath(invoked), - runPnpm: (commandPath, args, capture) => runPnpmCandidate(commandPath, args, capture, spawn), + runPnpm: (commandPath, args, capture) => runPnpmCandidate(commandPath, args, capture, probeSpawn), }); } @@ -155,11 +157,12 @@ export function runOwnedPnpm( args: readonly string[], capture: boolean, stdio: "inherit" | "pipe" | "ignore" = capture ? "pipe" : "inherit", - spawn: typeof spawnSync = spawnSync, + probeSpawn: typeof probeSpawnSync = probeSpawnSync, + authorizeMutation: (kind: "package") => boolean = () => ["root", "bin", "list", "config", "view", "--version"].includes(args[0] ?? ""), ): { status: number | null; stdout?: string | null; stderr?: string | null } { const target = ownerPnpmTarget(owner, args); if (!target) return { status: 1 }; - return withPnpmCommandCwd(args, cwd => spawn(target.bin, target.args, { + return withPnpmCommandCwd(args, cwd => authorizeMutation("package") ? guardedSpawnSync(authorizeMutation, "package", target.bin, target.args, { stdio, encoding: "utf8", timeout: 180_000, @@ -169,7 +172,7 @@ export function runOwnedPnpm( cwd, env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(target.env)), ...target.options, - })); + }, probeSpawn) : { status: 1 }); } /** Re-read the owning group's active package and return its verified launcher. */ @@ -295,13 +298,13 @@ export function latestVersion( tag: string, installer: Installer = detectInstall(), owner?: PnpmGlobalOwner, - spawn: typeof spawnSync = spawnSync, + probeSpawn: typeof probeSpawnSync = probeSpawnSync, ): string | null { const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined; if (installer === "pnpm" && !resolvedOwner) return null; const manager = registrySpawnTarget(installer, ["view", `${PKG}@${tag}`, "version"], resolvedOwner); if (!manager) return null; - const r = spawn(manager.bin, manager.args, { + const r = probeSpawn(manager.bin, manager.args, { encoding: "utf8", timeout: 12000, windowsHide: true, @@ -349,7 +352,7 @@ export function updateCommandStr(installer: Installer, tag: Channel, resolvedVer */ export function checkUpdatePackageIntegrity( version: string | null, - spawn: typeof spawnSync = spawnSync, + probeSpawn: typeof probeSpawnSync = probeSpawnSync, installer: Installer = detectInstall(), owner?: PnpmGlobalOwner, ): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } { @@ -362,7 +365,7 @@ export function checkUpdatePackageIntegrity( const result = checkRegistryPackageIntegrity(PKG, version, args => { const target = registrySpawnTarget(installer, args, resolvedOwner); if (!target) return { status: 1 }; - return spawn(target.bin, target.args, { + return probeSpawn(target.bin, target.args, { encoding: "utf8", timeout: 12000, windowsHide: true, @@ -459,7 +462,7 @@ export async function runUpdate(): Promise { // Pre-flight integrity metadata check — runs BEFORE the proxy is stopped so an // anomalous registry entry aborts without unloading the running service. - const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner); + const integrity = checkUpdatePackageIntegrity(latest, probeSpawnSync, installer, owner); if (integrity.ok === false) { console.error(`⚠️ ${integrity.reason} — aborting the update before stopping the proxy.`); process.exit(1); @@ -489,12 +492,17 @@ export async function runUpdate(): Promise { const { serviceStatePaths } = await import("../service"); const updateExitCode = await withUpdateOwnershipLease(serviceStatePaths(), async mutation => { + // Ownership inspection imports the service CLI, which reaches this update entry again. + // Load it only after entering the lease; the policy itself stays a dependency-free import. + const { inspectUpdateServiceHome } = await import("./service-home-authority"); + const homePolicy = createUpdateServiceHomePolicy(inspectUpdateServiceHome, message => console.warn(message), process.argv.includes("--update-package-only"), parseUpdateWorkerHome(process.argv)); + if (!homePolicy.authorizeMutation("package")) return 1; // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must repair/restart it afterwards. let serviceWasInstalled = false; try { const { isServiceInstalled } = await import("../service"); - serviceWasInstalled = isServiceInstalled(); + serviceWasInstalled = homePolicy.serviceAllowed && isServiceInstalled(); } catch { /* best-effort */ } // What this update may do to the runtime. A desktop takeover vetoes both the stop and the // service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong. @@ -513,16 +521,20 @@ 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 { getWindowsTrayStatus, guardedStartWindowsTray, guardedStopWindowsTray } = await import("./guarded-tray-ops"); const tray = getWindowsTrayStatus(); + if (!homePolicy.authorizeMutation("tray")) return 1; + let trayStopAttempted = false; const trayPlan = handoffWindowsTrayForUpdate(tray, { stop: () => { - const stopped = stopWindowsTray(); + if (!homePolicy.authorizeMutation("tray")) throw new Error("Automatic update tray handoff refused; check 'ocx service status'."); + trayStopAttempted = true; + const stopped = guardedStopWindowsTray(homePolicy.authorizeMutation); return { exitStatus: 0, running: stopped.running }; }, - start: () => startWindowsTray(), + start: () => { if (trayStopAttempted && homePolicy.authorizeMutation("own-tray")) guardedStartWindowsTray(homePolicy.authorizeMutation); }, }); trayWasInstalled = trayPlan.refreshAfterReplacement; trayWasRunning = trayPlan.restoreOnFailure; @@ -565,22 +577,31 @@ export async function runUpdate(): Promise { && before?.consentGeneration === after?.consentGeneration; }; /** `"vetoed"`: another owner holds the runtime, so not starting is the correct outcome. */ - const startProxyDirectly = async (): Promise => { + const startProxyDirectly = async (): Promise => { + // Every caller, including error recovery, owns the lock and fresh home proof here. + try { mutation.reacquire(); } catch { + console.warn("⚠️ Recovery could not acquire runtime ownership; run 'ocx service repair' or 'ocx start' manually after the other operation finishes."); + return false; + } + if (!homePolicy.authorizeMutation("runtime")) return "refused"; const ownership = await resolvedRuntimeOwnership(); const supervision = observeSupervision(); const plan = planUpdateRuntimeHandling({ ...ownership, serviceInstalled: false, supervision }); - if (!plan.mayStopRuntime) { + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + if (!sameOwner(ownership) || !plan.mayStopRuntime) { console.warn(supervision ? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored." : plan.notice); - return "vetoed"; + return homePolicy.authorizeMutation("publish") ? "vetoed" : "refused"; } if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) return false; // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. const env = mutation.controlEnvironment(withoutSiblingMarker(process.env)); delete env.OCX_SERVICE; - const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { - detached: true, stdio: "ignore", windowsHide: true, env: withProcessRuntimeProvenance(env), + const runtimeEnv = withProcessRuntimeProvenance(env); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + const child = guardedSpawn(homePolicy.authorizeMutation, "runtime", process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { + detached: true, stdio: "ignore", windowsHide: true, env: runtimeEnv, }); let failed = false; child.once("error", () => { failed = true; }); @@ -590,7 +611,7 @@ export async function runUpdate(): Promise { const current = readUpdateRuntimeTarget(getRuntimePortPath(), capturedListen.hostname); if (current.kind === "target" && current.target.port === capturedListen.port && await proxyIdentityAt(current.target.port, { hostname: current.target.hostname, expectedPid: current.target.pid }, - { timeoutMs: 1500, deadlineAt: deadline, attempts: 1 })) return true; + { timeoutMs: 1500, deadlineAt: deadline, attempts: 1 })) return homePolicy.authorizeMutation("publish") ? true : "refused"; await Bun.sleep(Math.min(100, Math.max(0, deadline - Date.now()))); } console.warn("⚠️ Recovery did not report a healthy proxy before its deadline; run 'ocx service repair' or 'ocx start'."); @@ -600,12 +621,20 @@ 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" | "vetoed"> => { if (!hasRestartableServiceManager()) return "failed"; + mutation.release(); + const owner = await resolvedRuntimeOwnership(); + if (!homePolicy.authorizeMutation("runtime")) return "refused"; + if (!homePolicy.serviceAllowed) return "failed"; + if (!sameOwner(owner) || !planUpdateRuntimeHandling({ ...owner, serviceInstalled: true, + supervision: observeSupervision() }).mayRestoreService) return homePolicy.authorizeMutation("publish") ? "vetoed" : "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; - const restarted = restartServiceManagerJob(); + if (!homePolicy.authorizeMutation("service")) return homePolicy.authorizeMutation("runtime") ? "failed" : "refused"; + const restarted = restartServiceManagerJob({}, homePolicy.authorizeMutation); + if (!restarted.ok && restarted.refused) return "refused"; const unsettled = !restarted.ok && restarted.unsettled === true; let verdict: ReturnType | { kind: "failed"; reason: string }; if (!restarted.ok && !unsettled) { @@ -624,7 +653,8 @@ export async function runUpdate(): Promise { verdict = supervisionVerdict({ servingPid, managerPid: readServiceManagerPid(), parentPid: pid => readParentPid(pid) }); } if (verdict.kind === "supervised") { - console.log(`✅ Proxy is healthy on port ${capturedListen.port} under the service manager (pid ${verdict.managerPid}).`); + if (!homePolicy.authorizeMutation("publish")) return "refused"; + guardedRun(homePolicy.authorizeMutation, "publish", console.log, [`✅ Proxy is healthy on port ${capturedListen.port} under the service manager (pid ${verdict.managerPid}).`]); return "supervised"; } if (unsettled) { @@ -635,9 +665,9 @@ export async function runUpdate(): Promise { return "failed"; }; /** Direct start as the last resort for a managed install: never reported as full recovery. */ - const startUnsupervisedFallback = async (): Promise<"vetoed" | "started" | "failed"> => { + const startUnsupervisedFallback = async (): Promise<"vetoed" | "refused" | "started" | "failed"> => { const started = await startProxyDirectly(); - if (started === "vetoed") return "vetoed"; + if (started === "vetoed" || started === "refused") return started; if (started) console.warn(unsupervisedRuntimeWarning(capturedListen.port)); return started ? "started" : "failed"; }; @@ -652,12 +682,14 @@ export async function runUpdate(): Promise { }); }; let recovery = await planRecovery(); + if (!homePolicy.authorizeMutation("runtime")) 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.authorizeMutation("runtime")) return; recovery = await planRecovery(); } if (recovery.reason === "desktop-supervised") { @@ -666,19 +698,27 @@ 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.authorizeMutation("runtime")) 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."); return; } - const service = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], { + if (!homePolicy.authorizeMutation("runtime")) return; + if (!homePolicy.serviceAllowed) { await startProxyDirectly(); return; } + if (!homePolicy.authorizeMutation("service")) { if (homePolicy.authorizeMutation("runtime")) await startProxyDirectly(); return; } + const service = guardedSpawnSync(homePolicy.authorizeMutation, "service", process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], { stdio: updateChildStdio(), windowsHide: true, env: mutation.controlEnvironment({ ...process.env, OCX_BAKE_PORT: String(capturedListen.port) }), }); + if (!homePolicy.authorizeMutation("runtime")) 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. if ((await planRecovery()).action === "service") { + if (!homePolicy.authorizeMutation("runtime")) return; const managed = await restartThroughServiceManager(); if (managed !== "failed") return; } @@ -716,7 +756,12 @@ export async function runUpdate(): Promise { // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). - if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { + if (homePolicy.packageOnly && pendingTeardownOutstanding()) { + console.error("Pending teardown requires manual recovery before package replacement; run 'ocx stop' from the owning installation."); + return 1; + } + if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { + if (!homePolicy.authorizeMutation("runtime")) return 1; const preStopPlan = planUpdateRuntimeHandling({ ...(await resolvedRuntimeOwnership()), serviceInstalled: serviceWasInstalled, @@ -729,7 +774,13 @@ export async function runUpdate(): Promise { stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); - const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), { + if (!homePolicy.authorizeMutation("runtime")) return 1; + if (serviceWasInstalled && !homePolicy.serviceAllowed) { + console.warn("Service authority changed before proxy stop; check 'ocx service status' before retrying the update."); + return 1; + } + if (!homePolicy.authorizeMutation(serviceWasInstalled ? "service" : "runtime")) return 1; + const stop = guardedSpawnSync(homePolicy.authorizeMutation, serviceWasInstalled ? "service" : "runtime", process.execPath, selfLaunchArgv(["stop"]), { env: mutation.controlEnvironment(), stdio: stopStdio, encoding: stopStdio === "pipe" ? "utf8" : undefined, @@ -754,8 +805,8 @@ export async function runUpdate(): Promise { if (!decision.proceed) { if (trayWasRunning) { try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); + const { guardedStartWindowsTray } = await import("./guarded-tray-ops"); + if (homePolicy.authorizeMutation("own-tray")) guardedStartWindowsTray(homePolicy.authorizeMutation); } catch { /* preserve the proxy stop failure */ } } if (decision.reason === "teardown-outstanding") { @@ -805,7 +856,7 @@ export async function runUpdate(): Promise { supervision: observeSupervision(), }); const replacementLiveness = currentPackageRuntimeLiveness(); - if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + if (!homePolicy.authorizeMutation("package") || replacementOwnership.subjectToken !== initialOwnership.subjectToken || !replacementPlan.mayReplacePackage || replacementLiveness !== "dead") { replacementRefusal = replacementPlan.notice @@ -819,6 +870,7 @@ export async function runUpdate(): Promise { if (installer === "pnpm") { let update: ReturnType; try { + if (!homePolicy.authorizeMutation("package")) return 1; update = runPnpmGlobalUpdate({ packageName: PKG, currentVersion: current, @@ -826,12 +878,13 @@ export async function runUpdate(): Promise { tag, owner: owner!, runningPackagePath: packageRoot(), - runPnpm: (args, capture = false) => runOwnedPnpm( + runPnpm: (args, capture = false) => homePolicy.authorizeMutation("package") ? runOwnedPnpm( owner!, args, capture, capture ? "pipe" : installStdio, - ), + probeSpawnSync, homePolicy.authorizeMutation, + ) : { status: 1, stdout: "", stderr: "" }, log: line => console.log(line), }); } catch { @@ -855,7 +908,8 @@ export async function runUpdate(): Promise { r = { status: 1, signal: null, stdout: "", stderr: "" }; } } else { - r = spawnSync(target.bin, target.args, { + if (!homePolicy.authorizeMutation("package")) return 1; + r = guardedSpawnSync(homePolicy.authorizeMutation, "package", target.bin, target.args, { stdio: installStdio, encoding: installStdio === "pipe" ? "utf8" : undefined, timeout: 180000, @@ -870,8 +924,8 @@ export async function runUpdate(): Promise { await recoverStoppedRuntime("package replacement was refused"); if (trayWasRunning) { try { - const { startWindowsTray } = await import("../tray/windows"); - startWindowsTray(); + const { guardedStartWindowsTray } = await import("./guarded-tray-ops"); + if (homePolicy.authorizeMutation("own-tray")) guardedStartWindowsTray(homePolicy.authorizeMutation); } catch { /* preserve the ownership refusal */ } } console.error(replacementRefusal); @@ -884,15 +938,54 @@ export async function runUpdate(): Promise { supervision: observeSupervision(), }); if (r.status === 0) { - console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`); + if (homePolicy.packageOnly) { + if (!homePolicy.authorizeMutation("package-publish")) return 1; + guardedRun(homePolicy.authorizeMutation, "package-publish", console.log, [`\n✅ Updated${latest ? ` to v${latest}` : ""}.`]); + homePolicy.check(homePolicy.initial); + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); + if (!homePolicy.authorizeMutation("package-publish")) return 1; + return process.argv.includes("--update-worker-result") ? UPDATE_PACKAGE_ONLY_EXIT_CODE : undefined; + } + // Authority gates every post-replacement control action (shim, tray, service), like + // the Node launcher; refusal may only restore the own tray stopped before replacement. + const restoreOwnStoppedTray = () => { + if (trayWasRunning && postUpdateLauncherUsable && existsSync(postUpdateLauncher)) { + try { + if (!homePolicy.authorizeMutation("own-tray")) return; + guardedSpawnSync(homePolicy.authorizeMutation, "own-tray", process.execPath, [postUpdateLauncher, "tray", "start"], { + stdio: "ignore", windowsHide: true, timeout: 15_000, env: mutation.unprivilegedEnvironment(), + }); + } catch { /* preserve the authority refusal */ } + } + }; + if (!homePolicy.authorizeMutation("publish")) { + restoreOwnStoppedTray(); + return 1; + } + guardedRun(homePolicy.authorizeMutation, "publish", console.log, [`\n✅ Updated${latest ? ` to v${latest}` : ""}.`]); 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 // pnpm selected, including when the update changed the global link target. + let isCodexShimInstalled: (() => boolean) | undefined; try { - const { isCodexShimInstalled } = await import("../codex/shim"); - if (isCodexShimInstalled()) { - const shim = spawnSync(process.execPath, [postUpdateLauncher, "codex-shim", "install"], { + ({ isCodexShimInstalled } = await import("../codex/shim")); + } catch { + console.warn("⚠️ Shim repair skipped; run 'ocx codex-shim install'."); + } + // The module load above is the last await before the control actions below; a home + // change landing while it was in flight must veto the shim rewrite and the tray + // refresh, not only the later service checks. + if (!homePolicy.authorizeMutation("runtime")) { + restoreOwnStoppedTray(); + return 1; + } + // The filesystem probe is part of the guarded recovery: a throw after package + // replacement degrades to the warning below instead of escaping the update. + try { + if (isCodexShimInstalled?.()) { + if (!homePolicy.authorizeMutation("shim")) { restoreOwnStoppedTray(); return 1; } + const shim = guardedSpawnSync(homePolicy.authorizeMutation, "shim", process.execPath, [postUpdateLauncher, "codex-shim", "install"], { stdio: "inherit", windowsHide: true, env: mutation.unprivilegedEnvironment(), @@ -904,38 +997,63 @@ export async function runUpdate(): Promise { } if (trayWasInstalled) { const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs; - const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayArgs], { stdio: "inherit", windowsHide: true, env: mutation.unprivilegedEnvironment() }); + if (!homePolicy.authorizeMutation("tray")) { restoreOwnStoppedTray(); return 1; } + const tray = guardedSpawnSync(homePolicy.authorizeMutation, "tray", process.execPath, [postUpdateLauncher, ...trayArgs], { stdio: "inherit", windowsHide: true, env: mutation.unprivilegedEnvironment() }); if (tray.status === 0) { console.log("🔧 Refreshed Windows tray startup paths."); } else { console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'."); - if (trayWasRunning) spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true, env: mutation.unprivilegedEnvironment() }); + if (trayWasRunning && homePolicy.authorizeMutation("own-tray")) guardedSpawnSync(homePolicy.authorizeMutation, "own-tray", process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true, env: mutation.unprivilegedEnvironment() }); } } + if (!homePolicy.authorizeMutation("publish")) { restoreOwnStoppedTray(); return 1; } // 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.serviceAllowed && postInstallPlan.mayStopRuntime) { + if (stopAttempted) return [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? undefined : 1; + console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); + return homePolicy.authorizeMutation("publish") ? undefined : 1; + } if (postInstallPlan.mayRestoreService) { + let homeRefused = false; const mayRefresh = async () => { + if (!homePolicy.authorizeMutation("runtime")) { homeRefused = true; return false; } const ownership = await resolvedRuntimeOwnership(); const supervision = observeSupervision(); const plan = planUpdateRuntimeHandling({ ...ownership, serviceInstalled: true, supervision }); + if (!homePolicy.authorizeMutation("runtime")) { homeRefused = true; return false; } 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()) { + if (homeRefused) return 1; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? undefined : 1; + return homePolicy.authorizeMutation("publish") ? undefined : 1; + } console.log("🔁 Refreshing the background service with the updated files..."); const { serviceReinstallArgs } = await import("../service"); - const { reclaimListenPort } = await import("../server/port-reclaim"); + const { guardedReclaimListenPort: reclaimListenPort } = await import("./guarded-port-reclaim"); + if (!homePolicy.authorizeMutation("runtime")) return 1; + // Latched reclaim authority: once a mid-wait probe denies, the latch stays + // closed — a transient re-match must not reopen kill/drop or the busy-port path. + let reclaimDenied = false; + const reclaimGate = () => { + try { reclaimDenied ||= !homePolicy.authorizeMutation("runtime"); } catch { reclaimDenied = true; } + return !reclaimDenied; + }; const freed = await reclaimListenPort(capturedListen.port, capturedListen.hostname, { timeoutMs: 30_000, intervalMs: 100, scanIntervalMs: 500, killOcxHolders: capturedListen.oldPid != null, onlyKillPids: capturedListen.oldPid != null ? [capturedListen.oldPid] : [], + // The wait's internal awaits can span a home change; gate each kill/drop. + mayMutateFn: reclaimGate, }); + if (!freed && !reclaimGate()) return 1; // a latched denial already refused if (!freed) { console.warn(`⚠️ Port ${capturedListen.port} still busy after 30s; repairing service with pinned --port ${capturedListen.port} anyway (refusing to hop).`); } @@ -944,17 +1062,28 @@ 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.authorizeMutation("runtime")) return 1; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? 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()) { + if (homeRefused) return 1; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? undefined : 1; + return homePolicy.authorizeMutation("publish") ? undefined : 1; + } const svcStdio = updateChildStdio(); - const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], { + if (!homePolicy.authorizeMutation("runtime")) return 1; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? undefined : 1; + if (!homePolicy.authorizeMutation("service")) return homePolicy.authorizeMutation("runtime") && [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? undefined : 1; + const svc = guardedSpawnSync(homePolicy.authorizeMutation, "service", process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], { env: mutation.controlEnvironment(), stdio: svcStdio, encoding: svcStdio === "pipe" ? "utf8" : undefined, windowsHide: true, }); + if (!homePolicy.authorizeMutation("runtime")) return 1; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? undefined : 1; if (svcStdio === "pipe") logSpawnOutput("", svc); const serviceRefreshed = svc.status === 0; let serviceViable = serviceRefreshed; @@ -969,10 +1098,16 @@ 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()) { + if (homeRefused) return 1; + if (!homePolicy.serviceAllowed) return [true, "vetoed"].includes(await startProxyDirectly()) && homePolicy.authorizeMutation("publish") ? undefined : 1; + return homePolicy.authorizeMutation("publish") ? undefined : 1; + } + if (!homePolicy.authorizeMutation("runtime")) return 1; const managed = await restartThroughServiceManager(); - if (managed === "supervised") return; + if (managed === "supervised" || managed === "vetoed") return homePolicy.authorizeMutation("publish") ? undefined : 1; 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 @@ -1010,11 +1145,12 @@ export async function runUpdate(): Promise { serviceInstalled: true, supervision, }); + if (!homePolicy.authorizeMutation("runtime")) return 1; if (!nowOwned.mayStopRuntime) { console.warn(supervision ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." : nowOwned.notice ?? "⚠️ The background runtime is owned elsewhere; not starting a second proxy."); - return; + return homePolicy.authorizeMutation("publish") ? undefined : 1; } console.warn( serviceRefreshed @@ -1028,7 +1164,7 @@ export async function runUpdate(): Promise { ? " Run 'ocx service repair' to refresh the background service." : " Run 'ocx service repair' to refresh the background service and see why it failed."); // An ownership veto is the correct outcome, not a degraded one. - if (await startUnsupervisedFallback() === "vetoed") return; + if (await startUnsupervisedFallback() === "vetoed") return homePolicy.authorizeMutation("publish") ? undefined : 1; return 1; } } @@ -1039,9 +1175,11 @@ export async function runUpdate(): Promise { } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); } + if (!homePolicy.authorizeMutation("publish")) return 1; } else { if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) { - spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true, env: mutation.unprivilegedEnvironment() }); + if (homePolicy.authorizeMutation("own-tray")) + guardedSpawnSync(homePolicy.authorizeMutation, "own-tray", process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true, env: mutation.unprivilegedEnvironment() }); } await recoverStoppedRuntime("package update failed"); console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). ${manualUpdateFailureGuidance({ bin, args: cmdArgs, owner }).join("\n")}`); @@ -1050,7 +1188,7 @@ export async function runUpdate(): Promise { } catch (error) { await recoverStoppedRuntime("update failed unexpectedly"); if (trayWasRunning) { - try { const { startWindowsTray } = await import("../tray/windows"); startWindowsTray(); } catch { /* retain original failure */ } + try { const { guardedStartWindowsTray } = await import("./guarded-tray-ops"); if (homePolicy.authorizeMutation("own-tray")) guardedStartWindowsTray(homePolicy.authorizeMutation); } catch { /* retain original failure */ } } throw error; } diff --git a/src/update/job-pinned-start.ts b/src/update/job-pinned-start.ts new file mode 100644 index 00000000000..4dfd3037c77 --- /dev/null +++ b/src/update/job-pinned-start.ts @@ -0,0 +1,53 @@ +import { guardedSpawn } from "./mutation-guard.mjs"; +import type { ChildProcess } from "node:child_process"; +import { closeSync, mkdirSync, openSync, writeSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config"; +import { withoutSiblingMarker } from "../codex/sibling-start"; +import type { UpdateMutationAuthorizer } from "./mutation-guard.mjs"; + +/** The guard runs after log-file setup, immediately before creating the pinned runtime. */ +export function spawnWorkerPinnedStart(cmd: { bin: string; args: string[]; display: string }, + authorizeMutation: UpdateMutationAuthorizer, report: (line: string) => void, formatError: (error: unknown) => string): ChildProcess { + // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. + const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env); + delete env.OCX_SERVICE; + report(`$ ${cmd.display}`); + let stdio: "ignore" | [ "ignore", number, number ] = "ignore"; + let logFd: number | undefined; + try { + const logPath = join(getConfigDir(), "update-pinned-start.log"); + mkdirSync(getConfigDir(), { recursive: true }); + logFd = openSync(logPath, "a"); + writeSync(logFd, `\n--- ${new Date().toISOString()} ---\n$ ${cmd.display}\n`); + stdio = ["ignore", logFd, logFd]; + } catch { /* fall back to ignored stdio */ } + if (!authorizeMutation("runtime")) { + if (logFd !== undefined) closeSync(logFd); + throw new Error("Automatic update restart refused; check 'ocx service status'."); + } + let child: ChildProcess; + try { child = guardedSpawn(authorizeMutation, "runtime", cmd.bin, cmd.args, { + detached: true, + stdio, + windowsHide: true, + env, + }); } finally { if (logFd !== undefined) closeSync(logFd); } + child.once("error", err => { + try { + report(`Pinned start spawn error: ${formatError(err)}`); + } catch { /* best-effort */ } + }); + // Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly + // with stdio ignored — surface that so the job log explains a silent miss. + child.once("exit", (code, signal) => { + if (code === 0 && !signal) return; + try { + report( + `Pinned start exited early (code=${code ?? "null"} signal=${signal ?? "null"}).`, + ); + } catch { /* best-effort */ } + }); + child.unref(); + return child; +} diff --git a/src/update/job-supervised-restart.ts b/src/update/job-supervised-restart.ts index 0b9c3968525..e3f14ebfbde 100644 --- a/src/update/job-supervised-restart.ts +++ b/src/update/job-supervised-restart.ts @@ -1,5 +1,8 @@ import { readRuntimePort } from "../config/process-state"; import { proxyIdentityAt } from "../server/proxy-liveness"; +import { createUpdateServiceHomePolicy, inspectUpdateServiceHome, updateServiceHomeRefusal, type UpdateServiceHomeInspector } from "./service-home-authority"; +import type { UpdateMutationAuthorizer } from "./mutation-guard.mjs"; +import { guardedRun, UpdateMutationRefusedError } from "./mutation-guard.mjs"; import { freshServingPid, hasRestartableServiceManager, readParentPid, readServiceManagerPid, restartServiceManagerJob, supervisionVerdict, unsettledManagerWarning, unsupervisedRuntimeWarning, @@ -12,11 +15,12 @@ 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 }; + restartJob?: () => { ok: true } | { ok: false; reason: string; unsettled?: true; refused?: true }; managerPid?: () => number | null; parentPid?: (pid: number) => number | null; readRecord?: () => { pid: number; port: number; hostname?: string } | null; @@ -27,7 +31,7 @@ export interface ManagerRestartDeps { } export type ManagerRestartFn = ( - target: { port: number; hostname: string; release?: () => void }, + target: { port: number; hostname: string; release?: () => void; authorizeMutation?: UpdateMutationAuthorizer }, log: (line: string) => void, ) => Promise; @@ -37,12 +41,20 @@ export type ManagerRestartFn = ( * worker reaches this point without a release when it skipped the service repair. */ export async function restartServiceManagerAfterUpdate( - target: { port: number; hostname: string; release?: () => void }, + target: { port: number; hostname: string; release?: () => void; authorizeMutation?: UpdateMutationAuthorizer }, log: (line: string) => void, deps: ManagerRestartDeps = {}, ): Promise { if (!(deps.hasManager ?? hasRestartableServiceManager)()) return "failed"; target.release?.(); + const standalone = target.authorizeMutation ? undefined : createUpdateServiceHomePolicy(deps.serviceHomeAuthorityFn ?? inspectUpdateServiceHome, log); + if (standalone && standalone.initial.kind !== "same") { + const refusal = updateServiceHomeRefusal(standalone.initial); + if (refusal) { log(refusal); return "refused"; } + return "failed"; + } + const authorizeMutation = target.authorizeMutation ?? standalone!.authorizeMutation; + if (!authorizeMutation("runtime")) return "refused"; 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) => ( @@ -51,7 +63,11 @@ export async function restartServiceManagerAfterUpdate( const now = deps.now ?? (() => Date.now()); const sleep = deps.sleepMs ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); const staleRecordPid = readRecord()?.pid ?? null; - const restarted = (deps.restartJob ?? restartServiceManagerJob)(); + if (!authorizeMutation("service")) return authorizeMutation("runtime") ? "failed" : "refused"; + let restarted: ReturnType>; + try { restarted = deps.restartJob ? guardedRun(authorizeMutation, "service", deps.restartJob, []) : restartServiceManagerJob({}, authorizeMutation); } + catch (error) { if (error instanceof UpdateMutationRefusedError) return "refused"; throw error; } + if (!restarted.ok && restarted.refused) return "refused"; const unsettled = !restarted.ok && restarted.unsettled === true; let verdict: ReturnType; if (!restarted.ok && !unsettled) { @@ -73,7 +89,8 @@ export async function restartServiceManagerAfterUpdate( }); } if (verdict.kind === "supervised") { - log(`Proxy is healthy on port ${target.port} under the service manager (pid ${verdict.managerPid}).`); + if (!authorizeMutation("publish")) return "refused"; + guardedRun(authorizeMutation, "publish", log, [`Proxy is healthy on port ${target.port} under the service manager (pid ${verdict.managerPid}).`]); return "supervised"; } if (unsettled) return "unsettled"; diff --git a/src/update/job-update-home.ts b/src/update/job-update-home.ts new file mode 100644 index 00000000000..515021be37b --- /dev/null +++ b/src/update/job-update-home.ts @@ -0,0 +1,62 @@ +import { probeSpawnSync } from "./mutation-guard.mjs"; +import { createUpdateServiceHomePolicy, inspectUpdateServiceHome, type UpdateServiceHomeInspector } from "./service-home-authority"; +import { UPDATE_PACKAGE_ONLY_EXIT_CODE, UPDATE_WORKER_HOME_FLAG, updateWorkerHomeArgv } from "./service-home-policy.mjs"; +import { currentServiceHomes } from "../service/state"; +import { probeUpdatePackageOnlyLauncher, updateHomeProbeEnvironment } from "./service-home-probe.mjs"; + +/** Refuse old launchers without executing their pre-dispatch bootstrap. */ +export function automaticUpdateLauncherSupported(launcher: string, node: string): boolean { + try { + const env = updateHomeProbeEnvironment(process.env, currentServiceHomes()); + return probeUpdatePackageOnlyLauncher(launcher, probeSpawnSync, 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; + // npm/pnpm and restricted workers delegate through the guarded Node launcher, which can + // receive argv. The raw package-manager lane (e.g. `bun add -g`) cannot: the worker + // rechecks the same baseline itself at the replacement boundary instead. + const delegated = installer === "npm" || installer === "pnpm" || policy.packageOnly; + if (delegated) { + 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"]; + // Persist the already-sanitized shape renderSafeCommand produces: a raw launcher path + // with spaces would still split on \s and be withheld, and `node.exe` is not a recognized + // tool token either. `command.bin`/`command.args` keep the real spawn target unchanged. + command.display = `node update --tag ${channel} --update-package-only`; + } + if (delegated) { + // Pin the verified baseline: without it the child inspects fresh and an unknown or + // matching read could mint authority the parent never held. v2 children refuse a + // malformed baseline and v2 parents refuse v1 children, so mixed-version delegation + // fails closed before either side runs. + const baseline = updateWorkerHomeArgv(policy.initial); + if (baseline === null) { + report("Automatic update refused: the observed service registration baseline could not be delegated."); + return null; + } + command.args.push(UPDATE_WORKER_HOME_FLAG, baseline, "--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-update-tray.ts b/src/update/job-update-tray.ts new file mode 100644 index 00000000000..a1e788b8957 --- /dev/null +++ b/src/update/job-update-tray.ts @@ -0,0 +1,29 @@ +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; +import type { UpdateMutationAuthorizer } from "./mutation-guard.mjs"; + +/** + * Production tray handoff for the GUI update worker: stop the own foreground tray before + * package replacement, restoring it best-effort when the stop cannot be confirmed. + * runGuiUpdateWorker reaches this only on Windows; focused tests inject + * GuiUpdateWorkerIo.trayHandoffFn instead. Sibling of job-update-home.ts because + * job.ts itself is at the repository file-size cap. + */ +export async function defaultWorkerTrayHandoff(capturePlan?: (plan: ReturnType) => void, + authorizeMutation: UpdateMutationAuthorizer = () => false) { + const { getWindowsTrayStatus, guardedStartWindowsTray, guardedStopWindowsTray } = await import("./guarded-tray-ops"); + const status = getWindowsTrayStatus(); + // No handoff or restoration is authorized if the import lost authority before any stop. + if (!authorizeMutation("tray")) throw new Error("Automatic update tray handoff refused; check 'ocx service status'."); + let stopAttempted = false; + return handoffWindowsTrayForUpdate(status, { + stop: () => { + if (!authorizeMutation("tray")) throw new Error("Automatic update tray handoff refused; check 'ocx service status'."); + stopAttempted = true; + try { + const stopped = guardedStopWindowsTray(authorizeMutation); + return { exitStatus: 0, running: stopped.running }; + } finally { capturePlan?.(planWindowsTrayUpdate(status)); } // An attempted stop retains its cleanup evidence. + }, + start: () => { if (stopAttempted && authorizeMutation("own-tray")) return guardedStartWindowsTray(authorizeMutation); }, + }); +} diff --git a/src/update/job-windows-restart.ts b/src/update/job-windows-restart.ts new file mode 100644 index 00000000000..89f4f45839d --- /dev/null +++ b/src/update/job-windows-restart.ts @@ -0,0 +1,39 @@ +import { join } from "node:path"; +import { getConfigDir } from "../config"; +import { guardedStopWinsw, guardedStopWindows, guardedKillWindowsWrappers } from "./guarded-runtime-ops"; +import { readServiceBackend } from "../service"; +import type { UpdateMutationAuthorizer } from "./mutation-guard.mjs"; + +/** Stop the installed Windows backend and best-effort kill surviving :loop wrappers. */ +export function stopWindowsServiceWrappersBestEffort(authorizeMutation: UpdateMutationAuthorizer): void { + if (process.platform !== "win32") return; + try { + if (readServiceBackend() === "native") { + if (!authorizeMutation("service")) return; + guardedStopWinsw(authorizeMutation); + return; + } + if (!authorizeMutation("service")) return; + guardedStopWindows(authorizeMutation); + } catch { /* already stopped */ } + if (!authorizeMutation("service")) return; + killWindowsServiceWrapperProcesses(authorizeMutation); +} + +/** + * 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(authorizeMutation: UpdateMutationAuthorizer): void { + const paths = { + scriptPath: join(getConfigDir(), "opencodex-service.cmd"), + launcherPath: join(getConfigDir(), "opencodex-service-launcher.vbs"), + }; + if (!authorizeMutation("service")) return; + guardedKillWindowsWrappers(authorizeMutation, paths); +} diff --git a/src/update/job.ts b/src/update/job.ts index 890a9ef46da..d138bd56485 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -1,19 +1,19 @@ -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { existsSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs"; +import { guardedRun, guardedSpawnSync, materializeMutationData } from "./mutation-guard.mjs"; +import type { ChildProcess } from "node:child_process"; +import { probeSpawn, probeSpawnSync } from "./mutation-guard.mjs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { atomicWriteFile, getConfigDir, loadConfig } from "../config"; -import { readPid, readRuntimePort, removePid, removeRuntimePort, verifyPidIdentity } from "../config/process-state"; -import { isProcessAlive, killProxy } from "../lib/process-control"; +import { readPid, readRuntimePort, verifyPidIdentity } from "../config/process-state"; +import { isProcessAlive } from "../lib/process-control"; import { selfLaunchArgv } from "../lib/self-launch-argv"; -import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers"; -import { - buildWindowsElevatedArgumentList, +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 { listListenPids, scanListenPids, type ListenPidScan } from "../server/port-reclaim"; +import { guardedReclaimListenPort as reclaimListenPort } from "./guarded-port-reclaim"; +import { guardedKillProxy, guardedRemovePid, guardedRemoveRuntimePort, guardedDropWindowsTcp, guardedPublishJob, type KillProxy } from "./guarded-runtime-ops"; import { isHealthzVersion, isOpencodexHealthz, @@ -21,7 +21,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 { @@ -44,7 +46,7 @@ export type { UpdateCheckDeps } from "./check-types"; import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs"; import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; -import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; +import { planWindowsTrayUpdate } from "./tray-update-plan.mjs"; import { GUI_UPDATE_FAILURE_NEXT_STEP } from "./update-failure-guidance.mjs"; import { npmCachePreflightFailureMessage, @@ -52,7 +54,10 @@ import { type NpmCachePreflightReason, } from "./npm-cache-preflight.mjs"; import { guiUpdateWorkerCommand } from "./worker-launch"; -import { withoutSiblingMarker } from "../codex/sibling-start"; +import { prepareWorkerUpdateHome, packageOnlyUpdateSucceeded } from "./job-update-home"; +import { defaultWorkerTrayHandoff } from "./job-update-tray"; +import { spawnWorkerPinnedStart } from "./job-pinned-start"; +import type { UpdateMutationAuthorizer } from "./mutation-guard.mjs"; import { spawnBindProbe, strictBindProbeScript } from "./bind-probe"; import { withoutUpdateTeardownHandover, withUpdateTeardownHandover } from "./teardown-handover"; import type { WorkerLaunchContext } from "./worker-launch"; @@ -169,11 +174,8 @@ async function strictRuntimePortAvailable(port: number, hostname = "127.0.0.1"): return spawnBindProbe(process.execPath, script); } -/** - * Wait until netstat reports no LISTEN owners on `port` AND the start runtime - * can bind. Dead PIDs still appear as holders while the ghost TCB lives; - * SetTcpEntry is a no-op without elevation (rc 317), so wait them out. - */ +/** Wait for no LISTEN owners plus a bindable port. Dead PIDs retain ghost TCBs; + * SetTcpEntry needs elevation (rc 317 otherwise), so wait them out. */ async function waitForGhostListenClear( port: number, hostname: string, @@ -181,6 +183,7 @@ async function waitForGhostListenClear( timeoutMs: number, sleep: (ms: number) => Promise, aliveFn: (pid: number) => boolean = isProcessAlive, + authorizeMutation: (kind: "runtime") => boolean = () => true, ): Promise<{ ok: boolean; accessDenied: boolean }> { const deadline = Date.now() + timeoutMs; let accessDenied = false; @@ -189,8 +192,9 @@ async function waitForGhostListenClear( const liveHolders = holders.filter(pid => aliveFn(pid)); // Never SetTcpEntry while a live process still owns the port (foreign or ocx). if (process.platform === "win32" && liveHolders.length === 0) { + if (!authorizeMutation("runtime")) return { ok: false, accessDenied }; try { - const drop = dropWindowsTcpRowsForLocalPort(port); + const drop = guardedDropWindowsTcp(authorizeMutation, port); if (drop.accessDenied > 0) accessDenied = true; } catch { /* best-effort */ } } @@ -201,7 +205,6 @@ async function waitForGhostListenClear( } return { ok: false, accessDenied }; } - function packageLauncherPath(): string { // The launcher is /bin/ocx.mjs. After `npm install -g`, import.meta.url // can still point at npm's renamed temp tree — prefer the live package path then. @@ -363,9 +366,13 @@ function sanitizePersistedUpdateJob(job: UpdateJobState): UpdateJobState { ) as UpdateJobState; } -function writeJob(job: UpdateJobState): void { +function writeJob(job: UpdateJobState, authorizeMutation: UpdateMutationAuthorizer = () => false, + kind: "publish" | "package-publish" = "publish"): void { ensureJobDir(); - atomicWriteFile(updateJobPath(), `${JSON.stringify(sanitizePersistedUpdateJob(job), null, 2)}\n`); + const data = materializeMutationData(job); + const target = updateJobPath(), text = `${JSON.stringify(sanitizePersistedUpdateJob(data), null, 2)}\n`; + if (data.status === "succeeded") guardedPublishJob(authorizeMutation, kind, target, text); + else atomicWriteFile(target, text); } export function readUpdateJob(jobId?: string | null): UpdateJobState | null { @@ -387,6 +394,9 @@ export function readUpdateJob(jobId?: string | null): UpdateJobState | null { * `withheldSummary()`, which produces a branded description WITHOUT the text itself. */ function updateJob(job: UpdateJobState, patch: Partial, logLine?: string): UpdateJobState { + const next = prepareUpdateJob(job, patch, logLine); writeJob(next); return next; +} +function prepareUpdateJob(job: UpdateJobState, patch: Partial, logLine?: string): UpdateJobState { const current = readUpdateJob(job.id) ?? job; const next = { ...current, @@ -394,7 +404,6 @@ function updateJob(job: UpdateJobState, patch: Partial, logLine? updatedAt: new Date().toISOString(), log: logLine ? [...current.log, logLine] : current.log, }; - writeJob(next); return next; } @@ -542,7 +551,7 @@ export function spawnGuiUpdateWorker( ]); if (process.platform !== "win32") { const launch = guiUpdateWorkerCommand(process.execPath, args, context); - return spawn(launch.command, launch.argv, { + return probeSpawn(launch.command, launch.argv, { detached: true, stdio: "ignore", windowsHide: true, @@ -560,7 +569,7 @@ export function spawnGuiUpdateWorker( `if (-not $p) { exit 1 }`, `Write-Output $p.Id`, ].join("; "); - const launched = spawnSync( + const launched = probeSpawnSync( resolveTrustedWindowsPowerShellExe(), ["-NoProfile", "-NoLogo", "-NonInteractive", "-Command", ps], { encoding: "utf8", windowsHide: true, timeout: 15_000 }, @@ -675,9 +684,12 @@ function runLoggedCommand( args: string[], timeout: number, env?: NodeJS.ProcessEnv, + authorizeMutation: UpdateMutationAuthorizer = () => false, + kind: Parameters[0] = "runtime", ): { status: number | null; signal: NodeJS.Signals | null; timedOut: boolean } { job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`); - const result = spawnSync(bin, args, { + if (!authorizeMutation(kind)) return { status: 1, signal: null, timedOut: false }; + const result = guardedSpawnSync(authorizeMutation, kind, bin, args, { encoding: "utf8", timeout, windowsHide: true, @@ -853,27 +865,26 @@ export function summarizeCommandOutput( return parts.join(" · "); } -/** - * Tear down anything that would make `ocx start` exit 1 with "already running" - * (service wrapper respawn, stale pidfile + live /healthz) before a pinned spawn. - */ +/** Clear wrappers, stale state and verified ocx listeners before a pinned spawn. */ function preparePortForPinnedStart( - job: UpdateJobState, - port: number, - listPids: (port: number) => number[], - aliveFn: (pid: number) => boolean, + job: UpdateJobState, port: number, + listPids: (port: number) => number[], aliveFn: (pid: number) => boolean, verifyOcx: (pid: number) => number | null = verifyPidIdentity, -): void { - stopWindowsServiceWrappersBestEffort(); + stopServiceWrappers: () => void = () => {}, + authorizeMutation: (kind: "runtime") => boolean = () => true, +): false | void { + if (!authorizeMutation("runtime")) return false; + stopServiceWrappers(); const pid = readPid(); if (pid) { updateJob(job, {}, `Clearing pre-start proxy PID ${pid} before pinned start.`); - try { killProxy(pid); } catch { /* best-effort */ } - removePid(pid); - } else { - removePid(); + if (!authorizeMutation("runtime")) return false; + try { guardedKillProxy(authorizeMutation, "runtime", pid); } catch { /* best-effort */ } } - removeRuntimePort(); + if (!authorizeMutation("runtime")) return false; + guardedRemovePid(authorizeMutation, pid ?? undefined); + if (!authorizeMutation("runtime")) return false; + guardedRemoveRuntimePort(authorizeMutation); for (const holder of listPids(port)) { if (holder === process.pid || !aliveFn(holder)) continue; if (verifyOcx(holder) !== holder) { @@ -885,60 +896,22 @@ function preparePortForPinnedStart( continue; } updateJob(job, {}, `Stopping live ocx listen holder PID ${holder} on port ${port} before pinned start.`); - try { killProxy(holder); } catch { /* best-effort */ } + if (!authorizeMutation("runtime")) return false; + try { guardedKillProxy(authorizeMutation, "runtime", holder); } catch { /* best-effort */ } } // Match reclaimListenPort: never SetTcpEntry while a live holder remains. const liveRemain = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid)); if (process.platform === "win32" && liveRemain.length === 0) { - try { dropWindowsTcpRowsForLocalPort(port); } catch { /* best-effort */ } + if (!authorizeMutation("runtime")) return false; + try { guardedDropWindowsTcp(authorizeMutation, port); } catch { /* best-effort */ } } } -function spawnDetachedStart( - job: UpdateJobState, - installer: Installer, - port?: number, - launcher = packageLauncherPath(), -): ChildProcess { - const cmd = restartCommand(false, installer, launcher, port); - // An ordinary owner: a stray sibling marker would otherwise mark it before any probe. - const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env); - delete env.OCX_SERVICE; - updateJob(job, {}, `$ ${cmd.display}`); - let stdio: "ignore" | [ "ignore", number, number ] = "ignore"; - let logFd: number | undefined; - try { - const logPath = join(getConfigDir(), "update-pinned-start.log"); - mkdirSync(getConfigDir(), { recursive: true }); - logFd = openSync(logPath, "a"); - writeSync(logFd, `\n--- ${new Date().toISOString()} ---\n$ ${cmd.display}\n`); - stdio = ["ignore", logFd, logFd]; - } catch { /* fall back to ignored stdio */ } - const child = spawn(cmd.bin, cmd.args, { - detached: true, - stdio, - windowsHide: true, - env, - }); - child.once("error", err => { - try { - updateJob(job, {}, `Pinned start spawn error: ${withheldSummary(err)}`); - } catch { /* best-effort */ } - }); - // Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly - // with stdio ignored — surface that so the job log explains a silent miss. - child.once("exit", (code, signal) => { - if (code === 0 && !signal) return; - try { - updateJob( - job, - {}, - `Pinned start exited early (code=${code ?? "null"} signal=${signal ?? "null"}).`, - ); - } catch { /* best-effort */ } - }); - child.unref(); - return child; +function spawnDetachedStart(job: UpdateJobState, installer: Installer, port: number | undefined, + launcher: string, authorizeMutation: UpdateMutationAuthorizer): ChildProcess { + if (!authorizeMutation("runtime")) throw new Error("Automatic update restart refused; check 'ocx service status'."); + return spawnWorkerPinnedStart(restartCommand(false, installer, launcher, port), authorizeMutation, + line => updateJob(job, {}, line), withheldSummary); } /** Identity snapshot used to prove a package-manager self-update replaced the pre-update process. */ @@ -957,8 +930,12 @@ export interface RestartIo { spawnDetachedStartFn?: typeof spawnDetachedStart; preparePortForPinnedStartFn?: typeof preparePortForPinnedStart; waitForGhostListenClearFn?: typeof waitForGhostListenClear; - killProxyFn?: typeof killProxy; + killProxyFn?: KillProxy; serviceInstalledFn?: () => boolean; + serviceHomeAuthorityFn?: UpdateServiceHomeInspector; + serviceHomeBaseline?: ReturnType; // Worker delegation pins its original authority. + stopServiceWrappersFn?: (authorizeMutation: UpdateMutationAuthorizer) => 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 @@ -1067,7 +1044,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); }, false, io.serviceHomeBaseline); + if (!homePolicy.check(io.serviceHomeBaseline ? undefined : 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. @@ -1089,9 +1069,8 @@ async function restartAfterUpdate( const listPids = io.listListenPidsFn ?? listListenPids; const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity; const aliveFn = io.isAliveFn ?? isProcessAlive; - // Pre-update PID plus any ocx still LISTENing on the captured port: a self-update - // often leaves a respawned bun/node child that is not the captured PID, and - // treating it as protected blocks reclaim so the direct start never binds. + // Pre-update PID plus live ocx listeners on the captured port: a self-update can + // leave a respawned child whose protected status would block reclaim and the start. const reclaimKillAllowlist = (): number[] => { const allow = new Set(); if (oldPid != null) allow.add(oldPid); @@ -1101,6 +1080,12 @@ async function restartAfterUpdate( } return [...allow]; }; + // A reclaim denial stays latched across preparation and this job's waits. + let reclaimDenied = false; + const reclaimGate = () => { + try { reclaimDenied ||= !homePolicy.authorizeMutation("runtime"); } catch { reclaimDenied = true; } + return !reclaimDenied; + }; const reclaimOptsFor = (onlyKillPids: number[]) => ({ timeoutMs: RESTART_PORT_RECLAIM_MS, intervalMs: 100, @@ -1110,20 +1095,19 @@ async function restartAfterUpdate( // listener, not only the pre-wait snapshot; foreign claimants stay protected. killAllOcxOnPort: true, onlyKillPids, + mayMutateFn: reclaimGate, // the wait's awaits can span a home change; gate each kill/drop }); - if (serviceInstalled) { - // schtasks /end often leaves the hidden cmd/wscript wrapper alive; its :loop - // 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(); + if (!homePolicy.authorizeMutation("runtime")) return false; // a home change during the service-args import refuses before stop/reclaim + // schtasks /end often leaves the hidden wrapper alive; its :loop respawns + // `ocx start` and races reclaim, so end wrappers before the socket. + if (homePolicy.authorizeMutation("service")) (io.stopServiceWrappersFn ?? stopWindowsServiceWrappersBestEffort)(homePolicy.authorizeMutation); const preServiceAllow = reclaimKillAllowlist(); const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow)); + if (!freed && !reclaimGate()) return false; // a latched denial already refused the job let skipServiceInstall = false; - // `service repair` reuses the live task and refreshes a stale definition through - // its guarded create/elevation path, so the old install-only skip no longer - // applies; only a caller that still passes install argv keeps that behavior. + // `service repair` reuses the live task and refreshes stale definitions via its + // guarded path; only a caller still passing install argv keeps the old skip. const refreshRegisters = (svcArgs ?? []).includes("install"); if ((io.platform ?? process.platform) === "win32" && process.env.OCX_SERVICE === "1" && refreshRegisters) { updateJob(job, {}, "Skipping service re-registration from the non-elevated update worker; falling back to a direct proxy start."); @@ -1153,10 +1137,10 @@ async function restartAfterUpdate( } } if (!skipServiceInstall) { - // `service repair` re-activates a manager whose `ocx start` child is not a - // process descendant and cannot join this lease; held through its serving - // wait, the lease makes that child die at the acquire deadline (#5760). + // `service repair` re-activates a manager whose `ocx start` child cannot join + // this lease; holding it through the serving wait kills that child at the deadline (#5760). io.releaseForServiceManagerFn?.(); + if (!homePolicy.authorizeMutation("runtime")) return false; const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(Math.trunc(port)); let serviceOk = false; @@ -1164,10 +1148,10 @@ async function restartAfterUpdate( const repairTimeoutMs = (io.platform ?? process.platform) === "win32" ? 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 run = io.runService ?? ((j, bin, args, timeoutMs) => runLoggedCommand(j, bin, args, timeoutMs, undefined, homePolicy.authorizeMutation, "service")); + const result = homePolicy.authorizeMutation("service") ? 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 @@ -1192,14 +1176,14 @@ async function restartAfterUpdate( if (prevBake === undefined) delete process.env.OCX_BAKE_PORT; else process.env.OCX_BAKE_PORT = prevBake; } - if (serviceOk) { + if (!homePolicy.authorizeMutation("runtime")) 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 - // satisfy one probe — so ask the port before skipping the fallback. + // registered and bound nothing — `launchctl list`/`schtasks` report both, and + // a flapping supervisor can satisfy one probe — so ask the port first. const viable = (io.serviceViableFn ?? isServiceViable)(); if (viable) { - if (await serviceRestartServed(job, port, hostname, io)) return; + if (await serviceRestartServed(job, port, hostname, io)) return homePolicy.authorizeMutation("publish") ? undefined : false; updateJob( job, {}, @@ -1219,33 +1203,37 @@ 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)( - { port, hostname, release: io.releaseForServiceManagerFn }, line => updateJob(job, {}, line)); - if (managed === "supervised") return; + if (!homePolicy.authorizeMutation("runtime")) return false; + const managed = !serviceInstalled || !homePolicy.authorizeMutation("service") ? "failed" : await (io.restartServiceManagerFn ?? restartServiceManagerAfterUpdate)( + { port, hostname, release: io.releaseForServiceManagerFn, authorizeMutation: homePolicy.authorizeMutation }, line => updateJob(job, {}, line)); + if (managed === "supervised") return homePolicy.authorizeMutation("publish") ? undefined : false; + 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. const restartVeto = io.reacquireForDirectStartFn?.(); + if (!homePolicy.authorizeMutation("runtime")) return false; if (restartVeto) { - updateJob(job, restartVeto.failed ? { status: "failed", restarted: false, error: restartVeto.notice } : { status: "succeeded", restarted: false }, restartVeto.notice); + if (!homePolicy.authorizeMutation("publish")) return false; + writeJob(prepareUpdateJob(job, restartVeto.failed ? { status: "failed", restarted: false, error: restartVeto.notice } : { status: "succeeded", restarted: false }, restartVeto.notice), homePolicy.authorizeMutation, "publish"); return false; } - - const pid = readPid(); + const pid = (io.readPidFn ?? readPid)(); if (pid) { updateJob(job, {}, `Stopping current proxy PID ${pid}.`); try { - killProxy(pid); + if (!homePolicy.authorizeMutation("runtime")) return false; + guardedKillProxy(homePolicy.authorizeMutation, "runtime", pid, io.killProxyFn); } 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(); - // Reclaim before the pinned start: spawning `--port` while the old socket is busy - // is how updates used to fail health checks or hop ports. + if (serviceInstalled && homePolicy.authorizeMutation("service")) (io.stopServiceWrappersFn ?? stopWindowsServiceWrappersBestEffort)(homePolicy.authorizeMutation); + // Reclaim before the pinned start: a busy old socket used to hop ports or fail health. const directAllow = reclaimKillAllowlist(); const freed = await waitFn(port, hostname, reclaimOptsFor(directAllow)); + if (!freed && !reclaimGate()) return false; if (!freed) { const liveHolders = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid)); updateJob( @@ -1258,8 +1246,7 @@ async function restartAfterUpdate( updateJob(job, {}, `Live holder(s) remain on port ${port}; not starting on another port. Retry 'ocx start --port ${port}'.`); return fallback; } - // Dead PIDs can still own LISTEN rows. SetTcpEntry needs elevation (rc 317 on a - // normal update worker), so poll until netstat is empty and the start runtime can bind. + // Dead PIDs can still own LISTEN rows (SetTcpEntry needs elevation); poll netstat until bindable. updateJob( job, {}, @@ -1268,7 +1255,8 @@ async function restartAfterUpdate( // Injected spawnStart is the unit-test seam — skip the long OS wait. if (!io.spawnStart) { const sleep = io.sleepMs ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); - const cleared = await waitForGhostListenClear(port, hostname, listPids, 90_000, sleep); + const cleared = await waitForGhostListenClear(port, hostname, listPids, 90_000, sleep, aliveFn, reclaimGate); + if (!reclaimGate()) return false; if (cleared.accessDenied) { updateJob(job, {}, "SetTcpEntry is non-elevated (access denied); relying on OS ghost-LISTEN expiry."); } @@ -1287,6 +1275,7 @@ async function restartAfterUpdate( // Injected spawnStart keeps unit tests deterministic (one call). Production path // retries on missing /healthz after prepare + ghost-LISTEN clear. if (io.spawnStart) { + if (!homePolicy.authorizeMutation("runtime")) return false; io.spawnStart(job, job.installer, port, launcher); return fallback; } @@ -1323,7 +1312,8 @@ async function restartAfterUpdate( // A numeric PID can be reused once this particular child has exited. if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return; if (aliveFn(child.pid)) { - try { (io.killProxyFn ?? killProxy)(child.pid); } catch { /* best-effort */ } + if (!homePolicy.authorizeMutation("own-child")) return; + try { guardedKillProxy(homePolicy.authorizeMutation, "own-child", child.pid, io.killProxyFn); } catch { /* best-effort */ } } }; for (let attempt = 1; attempt <= attempts; attempt++) { @@ -1337,14 +1327,16 @@ async function restartAfterUpdate( killSpawnAttempt(lastChild); lastChild = null; } - preparePort(job, port, listPids, aliveFn, verifyOcx); + if (!homePolicy.authorizeMutation("runtime")) return false; + if (preparePort(job, port, listPids, aliveFn, verifyOcx, () => { if (serviceInstalled && homePolicy.authorizeMutation("service")) (io.stopServiceWrappersFn ?? stopWindowsServiceWrappersBestEffort)(homePolicy.authorizeMutation); }, reclaimGate) === false) return false; const ready = await waitForGhost( port, hostname, listPids, attempt === 1 ? (freed ? 15_000 : 5_000) : 30_000, - sleep, + sleep, aliveFn, reclaimGate, ); + if (!reclaimGate()) return false; if (!ready.ok) { updateJob( job, @@ -1354,7 +1346,8 @@ async function restartAfterUpdate( ); continue; } - const child = spawnPinnedStart(job, job.installer, port, launcher); + if (!homePolicy.authorizeMutation("runtime")) return false; + const child = spawnPinnedStart(job, job.installer, port, launcher, homePolicy.authorizeMutation); lastChild = child; const retireChild = () => { if (lastChild === child) lastChild = null; @@ -1395,35 +1388,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, @@ -1518,7 +1482,7 @@ async function confirmRestartedProxy( ): Promise { /* [Decision Log] - 목적과 의도: GUI update job이 detached restart 요청만 보고 성공 처리하지 않도록, 실제 프록시 복귀 여부를 확인한다. - - 기존 구현 및 제약 조건: update-job.json은 spawn/service reinstall 직후 `succeeded`로 끝났고, Windows npm/Bun 교체 실패처럼 몇 초 후 죽는 재시작을 잡지 못했다. + - 기존 구현 및 제약 조건: update-job.json은 probeSpawn/service reinstall 직후 `succeeded`로 끝났고, Windows npm/Bun 교체 실패처럼 몇 초 후 죽는 재시작을 잡지 못했다. - 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다. - 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다. - 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다. @@ -1781,8 +1745,14 @@ 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; + platform?: NodeJS.Platform; // Exercise the default Windows handoff without changing the host platform. + /** Isolated tray handoff plan for focused worker tests (default: the win32 tray handoff). */ + trayHandoffFn?: (capturePlan?: Parameters[0], authorizeMutation?: UpdateMutationAuthorizer) => ReturnType | Promise>; + /** Isolated tray command runner for focused worker tests (default: runLoggedCommand). */ + trayCommandFn?: GuiUpdateWorkerIo["runCommandFn"]; runCommandFn?: ( job: UpdateJobState, bin: string, @@ -1791,7 +1761,6 @@ export interface GuiUpdateWorkerIo { env?: NodeJS.ProcessEnv, ) => { status: number | null; signal: NodeJS.Signals | null }; } - export async function runGuiUpdateWorker( jobId: string, channel: Channel, @@ -1821,6 +1790,12 @@ export async function runGuiUpdateWorker( let activeLauncher = packageLauncherPath(); let activeLauncherVerified = true; let pnpmOwner: PnpmGlobalOwner | undefined; + let workerAuthorizeMutation: UpdateMutationAuthorizer = () => false; + const trayRun = (args: string[], timeout: number) => { + if (!workerAuthorizeMutation(args[1] === "start" ? "own-tray" : "tray")) return { status: 1, signal: null }; + return io.trayCommandFn ? io.trayCommandFn(job!, process.execPath, [activeLauncher, ...args], timeout) + : runLoggedCommand(job!, process.execPath, [activeLauncher, ...args], timeout, undefined, workerAuthorizeMutation, args[1] === "start" ? "own-tray" : "tray"); + }; if (!job) { job = { id: jobId, @@ -1838,7 +1813,6 @@ export async function runGuiUpdateWorker( }; writeJob(job); } - try { if (!check.canUpdate) { throw new Error(check.reason ?? "No update is available"); @@ -1855,7 +1829,7 @@ export async function runGuiUpdateWorker( // proxy is stopped; transient registry failure degrades to a logged skip. const integrity = io.integrityFn ? io.integrityFn(check.latestVersion) - : checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner); + : checkUpdatePackageIntegrity(check.latestVersion, probeSpawnSync, check.installer, pnpmOwner); if (integrity.ok === false) { updateJob(job, { status: "failed", error: integrity.reason }); return; @@ -1883,28 +1857,31 @@ export async function runGuiUpdateWorker( } } - if (process.platform === "win32") { + let homeRefusal: string | undefined; + const homePolicy = prepareWorkerUpdateHome(cmd, check.installer, activeLauncher, nodeBin(), channel, + message => { homeRefusal = message; job = updateJob(job!, {}, message); }, io.serviceHomeAuthorityFn, io.launcherSupportsUpdateHomeFn); + if (!homePolicy) { updateJob(job, { status: "failed", restarted: false, error: homeRefusal }); return; } + workerAuthorizeMutation = homePolicy.authorizeMutation; + job = updateJob(job, { command: cmd.display }); + const refuseWorkerHome = () => { + if (trayWasRunning && activeLauncherVerified && homePolicy.authorizeMutation("own-tray")) trayRun(["tray", "start"], 15_000); + updateJob(job!, { status: "failed", restarted: false, error: homeRefusal }); + }; + if (!homePolicy.packageOnly && (io.trayHandoffFn || (io.platform ?? process.platform) === "win32")) { try { - const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows"); - const tray = getWindowsTrayStatus(); - const trayPlan = handoffWindowsTrayForUpdate(tray, { - stop: () => { - const stopped = stopWindowsTray(); - return { exitStatus: 0, running: stopped.running }; - }, - start: () => startWindowsTray(), - }); + if (!homePolicy.authorizeMutation("tray")) { refuseWorkerHome(); return; } + const trayPlan = await (io.trayHandoffFn ?? defaultWorkerTrayHandoff)(plan => { trayWasInstalled = plan.refreshAfterReplacement; trayWasRunning = plan.restoreOnFailure; }, homePolicy.authorizeMutation); trayWasInstalled = trayPlan.refreshAfterReplacement; trayWasRunning = trayPlan.restoreOnFailure; } catch (error) { + if (trayWasRunning && activeLauncherVerified && homePolicy.authorizeMutation("own-tray")) { try { trayRun(["tray", "start"], 15_000); } catch { /* preserve the handoff failure */ } } updateJob(job, { - status: "failed", - error: `Could not stop the Windows tray; aborting before package replacement: ${withheldSummary(error)}`, + status: "failed", restarted: false, + error: homeRefusal ?? `Could not stop the Windows tray; aborting before package replacement: ${withheldSummary(error)}`, }); return; } } - /* [Decision Log] - 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다. - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능. @@ -1914,7 +1891,15 @@ export async function runGuiUpdateWorker( // and the routed catalog to the replacement instead of restoring native Codex for the // seconds in between. Without a restart the stop restores, even under an inherited marker. const updateEnv = restart ? withUpdateTeardownHandover(process.env) : withoutUpdateTeardownHandover(process.env); - const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS, updateEnv); + // Raw package-manager lanes cannot carry the baseline argv; the parent rechecks it at the boundary. + if (!homePolicy.authorizeMutation("package")) { refuseWorkerHome(); return; } + const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS, updateEnv, homePolicy.authorizeMutation, "package"); + if (packageOnlyUpdateSucceeded(cmd, result.status, homePolicy.packageOnly)) { + homePolicy.check({ kind: "unknown" }); + if (!homePolicy.authorizeMutation("package-publish")) { refuseWorkerHome(); return; } + writeJob(prepareUpdateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy manually after checking 'ocx service status'."), homePolicy.authorizeMutation, "package-publish"); + return; + } if (result.status !== 0) { if (check.installer === "pnpm") { const verifiedLauncher = pnpmOwner @@ -1923,9 +1908,7 @@ export async function runGuiUpdateWorker( if (verifiedLauncher) activeLauncher = verifiedLauncher; else activeLauncherVerified = false; } - if (trayWasRunning && activeLauncherVerified) { - runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); - } + if (trayWasRunning && activeLauncherVerified && homePolicy.authorizeMutation("own-tray")) trayRun(["tray", "start"], 15_000); updateJob(job, { status: "failed", exitCode: result.status, @@ -1941,37 +1924,40 @@ export async function runGuiUpdateWorker( activeLauncher = verifiedLauncher; } + // Authority gates every post-replacement control action; refusal may only restore the + // own tray stopped before replacement, never install/refresh a new artifact. + if (!homePolicy.authorizeMutation("runtime")) { refuseWorkerHome(); return; } if (trayWasInstalled) { const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs; - const tray = runLoggedCommand(job, process.execPath, [activeLauncher, ...trayArgs], 20_000); + if (!homePolicy.authorizeMutation("tray")) { refuseWorkerHome(); return; } + const tray = trayRun(trayArgs, 20_000); if (tray.status !== 0) { updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'."); - if (trayWasRunning) runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); + if (trayWasRunning && homePolicy.authorizeMutation("own-tray")) trayRun(["tray", "start"], 15_000); } } - if (restart) { const outcome = await runUpdateRestartWithOwnershipLease(io.resolveOwnershipFn, async lease => { job = updateJob(job!, { status: "restarting" }, "Update installed. Restarting proxy..."); return finishGuiUpdateRestart(job!, captured, check.installer, { ...io.restartIo, + serviceHomeAuthorityFn: io.restartIo?.serviceHomeAuthorityFn ?? io.serviceHomeAuthorityFn, serviceHomeBaseline: homePolicy.initial, releaseForServiceManagerFn: lease.releaseForServiceManager, reacquireForDirectStartFn: lease.reacquireForDirectStart, packageLauncherPathFn: () => activeLauncher, }); }); - if (outcome.kind === "veto") { updateJob(job, { status: "succeeded", restarted: false }, outcome.notice); return; } + if (outcome.kind === "veto") { if (!homePolicy.authorizeMutation("publish")) { refuseWorkerHome(); return; } writeJob(prepareUpdateJob(job, { status: "succeeded", restarted: false }, outcome.notice), homePolicy.authorizeMutation, "publish"); return; } if (!outcome.value) return; - updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy."); + if (!homePolicy.authorizeMutation("publish")) { refuseWorkerHome(); return; } + writeJob(prepareUpdateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy."), homePolicy.authorizeMutation, "publish"); return; } - - updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version."); + if (!homePolicy.authorizeMutation("publish")) { refuseWorkerHome(); return; } + writeJob(prepareUpdateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version."), homePolicy.authorizeMutation, "publish"); } catch (err) { if (check.installer === "pnpm") activeLauncherVerified = false; - if (trayWasRunning && activeLauncherVerified) { - runLoggedCommand(job, process.execPath, [activeLauncher, "tray", "start"], 15_000); - } + if (trayWasRunning && activeLauncherVerified && workerAuthorizeMutation("own-tray")) trayRun(["tray", "start"], 15_000); updateJob(job, { status: "failed", error: withheldSummary(err), diff --git a/src/update/mutation-guard.d.mts b/src/update/mutation-guard.d.mts new file mode 100644 index 00000000000..dde8347238b --- /dev/null +++ b/src/update/mutation-guard.d.mts @@ -0,0 +1,30 @@ +export type UpdateMutationKind = "runtime" | "service" | "tray" | "shim" | "package" | "publish" | "package-publish" | "own-child" | "own-tray"; +export type UpdateMutationAuthorizer = (kind: UpdateMutationKind) => boolean; +export declare function createMutationAuthorizer(policy: { + check(): boolean; checkPackage(): boolean; readonly serviceAllowed: boolean; +}): UpdateMutationAuthorizer; +export declare function materializeMutationData(value: T): T; +export declare class UpdateMutationRefusedError extends Error { code: "EUPDATEAUTHORITY"; } +export declare function guardedRun(authorize: (kind: K) => boolean, kind: K, + operation: (...args: A) => R, args: A): R; +export declare const guardedSpawn: (authorize: UpdateMutationAuthorizer, kind: UpdateMutationKind, + ...args: Parameters) => import("node:child_process").ChildProcess; +export declare function guardedSpawnSync(authorize: (kind: K) => boolean, kind: K, + file: string, args: readonly string[], options: import("node:child_process").SpawnSyncOptionsWithStringEncoding, + native?: typeof import("node:child_process").spawnSync): import("node:child_process").SpawnSyncReturns; +export declare function guardedSpawnSync(authorize: (kind: K) => boolean, kind: K, + file: string, args: readonly string[], options?: import("node:child_process").SpawnSyncOptions, + native?: typeof import("node:child_process").spawnSync): import("node:child_process").SpawnSyncReturns; +export declare const guardedExecFile: (authorize: UpdateMutationAuthorizer, kind: UpdateMutationKind, + file: string, args: readonly string[], options: import("node:child_process").ExecFileOptions, + callback?: (...args: any[]) => void) => import("node:child_process").ChildProcess; +export declare const guardedExecFileSync: (authorize: (kind: K) => boolean, kind: K, + ...args: Parameters) => string | Buffer; +export declare function guardedKill(authorize: (kind: K) => boolean, kind: K, pid: number, signal?: NodeJS.Signals | number): boolean; +export declare function guardedRenameSync(authorize: (kind: "package") => boolean, from: string, to: string, native?: typeof import("node:fs").renameSync): void; +export declare function ownedArtifactRm(target: string, options: import("node:fs").RmOptions, native?: typeof import("node:fs").rmSync): void; +export declare function ownedArtifactUnlink(target: string): void; +export declare function probeProcessAlive(pid: number): true; +export declare function relayOwnSignal(signal: NodeJS.Signals): true; +export declare function guardedExit(authorize: UpdateMutationAuthorizer, kind: "publish" | "package-publish", code: number): never; +export { spawn as probeSpawn, spawnSync as probeSpawnSync } from "node:child_process"; diff --git a/src/update/mutation-guard.mjs b/src/update/mutation-guard.mjs new file mode 100644 index 00000000000..1a808e55e9c --- /dev/null +++ b/src/update/mutation-guard.mjs @@ -0,0 +1,103 @@ +import { spawn, spawnSync, execFile, execFileSync } from "node:child_process"; +import { renameSync, rmSync, unlinkSync } from "node:fs"; + +/** + * One synchronous authority gate shared by Node, Bun and worker lanes. A home refusal + * (including a throwing inspector) is permanent for this attempt. Service absence is + * a skipped service action, not a refusal of authorized foreground runtime cleanup. + * Package-only publication grants no runtime authority. Cleanup kinds are deliberately + * narrow: callers must retain the exact live ChildProcess or their own pre-stop tray. + * @param {{ check(): boolean, checkPackage(): boolean, readonly serviceAllowed: boolean }} policy + */ +export function createMutationAuthorizer(policy) { + let refused = false; + /** @param {"runtime" | "service" | "tray" | "shim" | "package" | "publish" | "package-publish" | "own-child" | "own-tray"} kind */ + return function authorizeMutation(kind) { + // These complete an already authorized operation; they never discover a new target, + // install/refresh a tray, or start/restart a runtime or a service. + if (kind === "own-child" || kind === "own-tray") return true; + if (refused) return false; + try { + const allowed = (kind === "package" || kind === "package-publish" ? policy.checkPackage() : policy.check()) === true; + refused = !allowed; + return allowed && (kind !== "service" || policy.serviceAllowed === true); + } catch { + refused = true; + return false; + } + }; +} + +/** Copy data before the final gate. Never carry a caller's prototype, getter, + * iterator, coercion hook or Proxy into the primitive. Functions are not data. + * Cycles are rejected; caller prototypes are discarded rather than passed to native coercion. */ +export function materializeMutationData(value, ancestors = new Set()) { + if (value === null || ["undefined", "string", "number", "boolean", "bigint"].includes(typeof value)) return value; + if (typeof value !== "object") throw new TypeError("Update mutation arguments must be plain data"); + if (ancestors.has(value)) throw new TypeError("Cyclic update mutation data"); + ancestors.add(value); + try { + if (Array.isArray(value)) { + const length = value.length, copy = []; + for (let i = 0; i < length; i++) copy.push(materializeMutationData(value[i], ancestors)); + return copy; + } + const copy = Object.create(null); + for (const key of Reflect.ownKeys(value)) { + if (Object.prototype.propertyIsEnumerable.call(value, key)) copy[key] = materializeMutationData(value[key], ancestors); + } + return copy; + } finally { ancestors.delete(value); } +} + +export class UpdateMutationRefusedError extends Error { + constructor() { super("Automatic update mutation refused; check 'ocx service status'."); this.code = "EUPDATEAUTHORITY"; } +} + +/** The operation slot is a trusted, thunk-free primitive adapter. It must consume + * only its arguments, never close over unmaterialized caller data or do preparation. + * No callback slots are supported here: helpers with synchronous callbacks must + * guard their actual primitive after those callbacks, not wrap the orchestration. */ +export function guardedRun(authorizeMutation, kind, operation, args) { + const data = materializeMutationData(args); + if (authorizeMutation(kind) !== true) throw new UpdateMutationRefusedError(); + return operation(...data); +} + +export function guardedSpawn(authorize, kind, file, args, options) { + return guardedRun(authorize, kind, spawn, [file, args, options]); +} +export function guardedSpawnSync(authorize, kind, file, args, options, native = spawnSync) { + return guardedRun(authorize, kind, native, [file, args, options]); +} +// execFile's final callback is an explicitly documented native callback slot: it +// runs on later completion/error events, never synchronously in native dispatch. +export function guardedExecFile(authorize, kind, file, args, options, callback) { + const data = materializeMutationData([file, args, options]); + if (callback !== undefined && typeof callback !== "function") throw new TypeError("Invalid execFile callback"); + if (authorize(kind) !== true) throw new UpdateMutationRefusedError(); + return execFile(...data, callback); +} +export function guardedExecFileSync(authorize, kind, file, args, options) { + return guardedRun(authorize, kind, execFileSync, [file, args, options]); +} +export function guardedKill(authorize, kind, pid, signal) { + return guardedRun(authorize, kind, process.kill.bind(process), [pid, signal]); +} +export function guardedRenameSync(authorize, from, to, native = renameSync) { + return guardedRun(authorize, "package", native, [from, to]); +} +/** Only private transaction artifacts or boot recovery's verified dead package. + * These bounded completions do not acquire runtime/service/home authority. */ +export function ownedArtifactRm(target, options, native = rmSync) { + const data = materializeMutationData([target, options]); + return native(...data); +} +export function ownedArtifactUnlink(target) { return unlinkSync(materializeMutationData(target)); } +export function probeProcessAlive(pid) { return process.kill(materializeMutationData(pid), 0); } +export function relayOwnSignal(signal) { return process.kill(process.pid, materializeMutationData(signal)); } +export function guardedExit(authorize, kind, code) { return guardedRun(authorize, kind, process.exit.bind(process), [code]); } + +// Explicit probe/ordinary-launch adapters. These do not convey update authority; +// callers are pinned by the inventory, and cannot use them for control commands. +export { spawn as probeSpawn, spawnSync as probeSpawnSync }; diff --git a/src/update/notify.ts b/src/update/notify.ts index 08d2a8e49a7..458ac64c0f0 100644 --- a/src/update/notify.ts +++ b/src/update/notify.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { probeSpawn } from "./mutation-guard.mjs"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isatty } from "node:tty"; @@ -189,7 +189,7 @@ export function triggerBackgroundRefreshIfStale(channel: Channel, cache: Version const commandArgs = ["__refresh-version", channel]; const args = selfLaunchArgv(commandArgs); if (args.length > commandArgs.length && (!args[0] || !existsSync(args[0]))) return; - const child = spawn(process.execPath, args, { + const child = probeSpawn(process.execPath, args, { detached: true, stdio: "ignore", windowsHide: true, diff --git a/src/update/npm-cache-preflight.mjs b/src/update/npm-cache-preflight.mjs index 4676e6c9712..116d752efe0 100644 --- a/src/update/npm-cache-preflight.mjs +++ b/src/update/npm-cache-preflight.mjs @@ -1,5 +1,5 @@ import { lstatSync, readdirSync, realpathSync, statSync } from "node:fs"; -import { spawnSync } from "node:child_process"; +import { probeSpawnSync } from "./mutation-guard.mjs"; import { homedir } from "node:os"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -194,7 +194,7 @@ export function resolveNpmCachePath(options = {}) { // able to choose the cache that install is pinned to. const invocation = (options.invocationFn ?? npmInvocation)(["config", "get", "cache", "--global"], platform, env); if (!invocation) return { ok: false, reason: "npm_unavailable" }; - const npm = (options.spawnSyncFn ?? spawnSync)(invocation.file, invocation.args, { + const npm = (options.spawnSyncFn ?? probeSpawnSync)(invocation.file, invocation.args, { encoding: "utf8", timeout: options.timeoutMs ?? NPM_CONFIG_TIMEOUT_MS, windowsHide: true, @@ -273,8 +273,8 @@ function runPreflightWorker(options) { const workerArgs = [fileURLToPath(import.meta.url), WORKER_ARG]; if ((options.platform ?? process.platform) === "win32") workerArgs.push(ROOT_ONLY_ARG); if (options.cachePath !== undefined) workerArgs.push(options.cachePath); - const spawn = options.spawnSyncFn ?? spawnSync; - const result = spawn( + const probeSpawn = options.spawnSyncFn ?? probeSpawnSync; + const result = probeSpawn( options.execPath ?? process.execPath, workerArgs, { diff --git a/src/update/npm-install.mjs b/src/update/npm-install.mjs index 28424dbf5c5..b1ff4ff3d48 100644 --- a/src/update/npm-install.mjs +++ b/src/update/npm-install.mjs @@ -1,6 +1,6 @@ // Windows Node-launcher compatibility code. The install owns its entire process tree until // exit is proved; a timeout signal alone never authorizes stage removal. -import { spawn } from "node:child_process"; +import { guardedSpawn, materializeMutationData } from "./mutation-guard.mjs"; import { dirname, join, win32 } from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,7 +11,9 @@ function quoteWindowsArg(value) { return '"' + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1") + '"'; } -export async function runNpmInstall(invocation, { env = process.env, timeoutMs = 180000 } = {}) { +export async function runNpmInstall(invocation, { env = process.env, timeoutMs = 180000, authorizeMutation = () => false } = {}) { + invocation = materializeMutationData(invocation); + env = materializeMutationData(env); if (!invocation) return { status: 1, processTreeExited: true, error: { code: "ENOENT", message: "npm invocation unavailable" } }; if (process.platform === "win32") { // Resolve from the same absolute system directory as the hardened cmd hop. @@ -22,7 +24,7 @@ export async function runNpmInstall(invocation, { env = process.env, timeoutMs = const supervisor = win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); const helper = join(dirname(fileURLToPath(import.meta.url)), "npm-install-job.ps1"); return new Promise(resolve => { - const child = spawn(supervisor, ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", helper], { + const child = guardedSpawn(authorizeMutation, "package", supervisor, ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", helper], { env, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], }); let output = ""; diff --git a/src/update/pnpm-read-policy.mjs b/src/update/pnpm-read-policy.mjs index 97940fa1cf2..d77652e5d91 100644 --- a/src/update/pnpm-read-policy.mjs +++ b/src/update/pnpm-read-policy.mjs @@ -1,4 +1,5 @@ -import { mkdtempSync, lstatSync, writeFileSync, unlinkSync, rmdirSync } from "node:fs"; +import { ownedArtifactUnlink } from "./mutation-guard.mjs"; +import { mkdtempSync, lstatSync, writeFileSync, rmdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -26,7 +27,7 @@ export function withPnpmCommandCwd(args, run) { } // Never recursively remove pnpm-created or replacement contents. for (const name of files) { - try { unlinkSync(join(cwd, name)); } + try { ownedArtifactUnlink(join(cwd, name)); } catch (error) { if (error?.code !== "ENOENT") throw error; } } rmdirSync(cwd); diff --git a/src/update/proxy-liveness-probe.mjs b/src/update/proxy-liveness-probe.mjs index 8316cbc222d..3d616c17679 100644 --- a/src/update/proxy-liveness-probe.mjs +++ b/src/update/proxy-liveness-probe.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { probeSpawnSync } from "./mutation-guard.mjs"; /** * Is something still answering `/healthz` as an opencodex proxy on this endpoint? @@ -97,7 +97,7 @@ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 150 "});", ].join("\n"); try { - const probe = spawnSync( + const probe = probeSpawnSync( process.execPath, ["-e", script, host, String(port), String(timeoutMs)], { encoding: "utf8", timeout: timeoutMs + 1500, windowsHide: true }, diff --git a/src/update/service-home-authority.ts b/src/update/service-home-authority.ts new file mode 100644 index 00000000000..1b7148e6106 --- /dev/null +++ b/src/update/service-home-authority.ts @@ -0,0 +1,35 @@ +import { isDeepStrictEqual } from "node:util"; +import { createHash } from "node:crypto"; +import { resolveCodexSqliteHome } from "../codex/paths"; +import { inspectNativeCodexOwnership, type OwnershipDeps } from "../integrations/native/ownership-preflight"; +import { compareServicePathToInstall, currentServiceHomes, inspectServiceStateEvidence, resolveServiceState, serviceStatePaths } from "../service/state"; + +export { createUpdateServiceHomePolicy, updateServiceHomeRefusal } from "./service-home-policy.mjs"; +export type UpdateServiceHomeAuthority = import("./service-home-policy.mjs").UpdateServiceHomeAuthority; +export type UpdateServiceHomeInspector = () => UpdateServiceHomeAuthority; + +/** Automatic restart authority, distinct from the interactive guard's fail-open policy. */ +export function inspectUpdateServiceHome(deps: OwnershipDeps = {}): UpdateServiceHomeAuthority { + try { + const paths = deps.statePaths ?? serviceStatePaths(); + const homes = deps.currentHomes ?? currentServiceHomes(); + const before = resolveServiceState(inspectServiceStateEvidence(paths)); + if (before.kind === "unknown") return { kind: "unknown" }; + const ownership = inspectNativeCodexOwnership({ ...deps, statePaths: paths, currentHomes: homes }); + if (ownership.ownership === "unknown") return { kind: "unknown" }; + // 'owned' alone includes no record and no registration. Preserve that absence. + let kind: UpdateServiceHomeAuthority["kind"] = ownership.ownership === "foreign" ? "foreign" : before.kind === "none" ? "absent" : "same"; + if (kind === "same" && before.kind === "state" && before.state.codexSqliteHome !== undefined) { + const sqlite = compareServicePathToInstall(before.state.codexSqliteHome, + resolveCodexSqliteHome({ codexHome: homes.codexHome }), { realpathSync: deps.realpathSync }); + if (sqlite !== "same") kind = sqlite === "different" ? "foreign" : "unknown"; + } + const after = resolveServiceState(inspectServiceStateEvidence(paths)); + if (!isDeepStrictEqual(before, after) || !isDeepStrictEqual(homes, deps.currentHomes ?? currentServiceHomes())) { + return { kind: "unknown" }; + } + return kind === "foreign" ? { kind, subject: createHash("sha256").update(JSON.stringify({ before, homes })).digest("hex") } : { kind }; + } catch { + return { kind: "unknown" }; + } +} diff --git a/src/update/service-home-package-only-protocol.json b/src/update/service-home-package-only-protocol.json new file mode 100644 index 00000000000..be9294f3ebc --- /dev/null +++ b/src/update/service-home-package-only-protocol.json @@ -0,0 +1 @@ +{"schemaVersion":1,"packageOnlyProtocolVersion":2,"entry":"__update-package-only-probe"} diff --git a/src/update/service-home-policy.d.mts b/src/update/service-home-policy.d.mts new file mode 100644 index 00000000000..04d57b8381d --- /dev/null +++ b/src/update/service-home-policy.d.mts @@ -0,0 +1,25 @@ +export type UpdateServiceHomeAuthority = { + readonly kind: "same" | "foreign" | "unknown" | "absent" | "invalid"; + readonly subject?: string; +}; +export interface UpdateServiceHomePolicy { + authorizeMutation: import("./mutation-guard.mjs").UpdateMutationAuthorizer; + 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 const UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION: 2; +export declare const UPDATE_WORKER_HOME_FLAG: "--update-worker-home"; +export declare function updateWorkerHomeArgv(home: UpdateServiceHomeAuthority | undefined): string | null; +export declare function parseUpdateWorkerHome(argv: readonly string[]): UpdateServiceHomeAuthority | undefined; +export declare function isUpdateHomeProbe(args: string[]): boolean; +export declare function parseUpdateHomeProbe(value: unknown, options?: { requireLauncher?: boolean }): UpdateServiceHomeAuthority; +export declare function createUpdateServiceHomePolicy(inspect: () => UpdateServiceHomeAuthority, + refuse: (message: string) => void, packageOnly?: boolean, + delegatedBaseline?: UpdateServiceHomeAuthority): 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..3c296fb78f0 --- /dev/null +++ b/src/update/service-home-policy.mjs @@ -0,0 +1,112 @@ +import { createMutationAuthorizer } from "./mutation-guard.mjs"; +/** Pure policy shared by the Node launcher and Bun update paths. */ +/** @typedef {{ readonly kind: "same" | "foreign" | "unknown" | "absent" | "invalid", readonly subject?: string }} UpdateServiceHomeAuthority */ + +export const UPDATE_HOME_SCHEMA_VERSION = 1; +export const UPDATE_HOME_LAUNCHER_PROTOCOL = 1; +export const UPDATE_PACKAGE_ONLY_EXIT_CODE = 75; +/** v2 children accept the delegated parent baseline argv; v1 launchers never see it. */ +export const UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION = 2; +export const UPDATE_WORKER_HOME_FLAG = "--update-worker-home"; + +/** Encode the parent's verified baseline for the delegated launcher. All four observed + * kinds travel: an omitted baseline would let the child mint authority the parent never + * held (parent same/foreign, child observing unknown), and --update-package-only alone + * only restricts control without preserving which baseline authorized replacement. + * null means the baseline cannot be encoded and delegation must be refused. + * @param {UpdateServiceHomeAuthority | undefined} home @returns {string | null} */ +export function updateWorkerHomeArgv(home) { + if (home?.kind === "same" || home?.kind === "absent" || home?.kind === "unknown") return home.kind; + if (home?.kind === "foreign" && typeof home.subject === "string" && /^[a-f0-9]{64}$/.test(home.subject)) + return `foreign:${home.subject}`; + return null; +} + +/** The baseline a v2 delegating parent pinned for this update. Absent flag → undefined: + * the entry was self-launched (direct `ocx update`) and inspects itself. Malformed, + * duplicated or `=`-joined shapes → { kind: "invalid" }, a synthetic baseline that fails + * every check instead of falling back to a fresh inspection. + * @param {readonly string[]} argv @returns {UpdateServiceHomeAuthority | undefined} */ +export function parseUpdateWorkerHome(argv) { + let at = -1; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === UPDATE_WORKER_HOME_FLAG) { + if (at >= 0) return { kind: "invalid" }; + at = i; + } else if (argv[i].startsWith(`${UPDATE_WORKER_HOME_FLAG}=`)) return { kind: "invalid" }; + } + if (at < 0) return undefined; + const value = argv[at + 1]; + if (value === "same" || value === "absent" || value === "unknown") return { kind: value }; + const foreign = typeof value === "string" ? /^foreign:([a-f0-9]{64})$/.exec(value) : null; + return foreign ? { kind: "foreign", subject: foreign[1] } : { kind: "invalid" }; +} + +/** Exact argv only: malformed variants must not enter the ordinary status collector. + * @param {string[]} args */ +export function isUpdateHomeProbe(args) { + return args.length === 1 && args[0] === "__update-home-probe"; +} + +/** @param {unknown} value @param {{ requireLauncher?: boolean }} [options] + * @returns {UpdateServiceHomeAuthority} */ +export function parseUpdateHomeProbe(value, { requireLauncher = false } = {}) { + if (!value || typeof value !== "object" || Array.isArray(value)) return { kind: "unknown" }; + const record = /** @type {Record} */ (value); + const keys = Object.keys(record); + if (keys.some(key => !["schemaVersion", "kind", "subject", "launcherProtocolVersion"].includes(key)) + || record.schemaVersion !== UPDATE_HOME_SCHEMA_VERSION + || (requireLauncher && record.launcherProtocolVersion !== UPDATE_HOME_LAUNCHER_PROTOCOL) + || (record.launcherProtocolVersion !== undefined && record.launcherProtocolVersion !== UPDATE_HOME_LAUNCHER_PROTOCOL)) return { kind: "unknown" }; + if (record.kind === "foreign") { + return typeof record.subject === "string" && /^[a-f0-9]{64}$/.test(record.subject) + ? { kind: "foreign", subject: record.subject } : { kind: "unknown" }; + } + if (record.subject !== undefined) return { kind: "unknown" }; + return record.kind === "same" || record.kind === "absent" || record.kind === "unknown" + ? { kind: record.kind } : { kind: "unknown" }; +} + +/** A foreign registration never manages this foreground runtime. A changed claim is not absence. + * @param {() => UpdateServiceHomeAuthority} inspect @param {(message: string) => void} refuse + * @param {boolean} [packageOnly] A parent's restriction can only remove runtime authority. + * @param {UpdateServiceHomeAuthority} [delegatedBaseline] A v2 parent's verified baseline; + * when present it replaces inspection so the child cannot launder a changed home. */ +export function createUpdateServiceHomePolicy(inspect, refuse, packageOnly = false, delegatedBaseline) { + const initial = delegatedBaseline ?? 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; + }; + const policy = { + initial, + packageOnly, + get serviceAllowed() { return !packageOnly && initial.kind === "same" && current.kind === "same"; }, + checkPackage(home = inspect()) { + return initial.kind === "unknown" && home.kind === "unknown" || checkAuthority(home); + }, + check(home = inspect()) { + if (!packageOnly) return checkAuthority(home); + refuse(updateServiceHomeRefusal({ kind: "unknown" })); + return false; + }, + }; + return { ...policy, get serviceAllowed() { return policy.serviceAllowed; }, authorizeMutation: createMutationAuthorizer(policy) }; +} + +/** Static persisted guidance: never echo recorded paths or a probe's error output. + * @param {UpdateServiceHomeAuthority} authority @returns {string | null} */ +export function updateServiceHomeRefusal(authority) { + if (authority.kind === "same" || authority.kind === "absent") return null; + return authority.kind === "foreign" + ? "Automatic update restart refused: the installed service belongs to different directories. Run 'ocx service status' from the registered installation; no restart was authorized." + : "Automatic update restart refused: service registration ownership could not be verified. Check 'ocx service status' and the recorded installation before restarting manually."; +} diff --git a/src/update/service-home-probe-entry.ts b/src/update/service-home-probe-entry.ts new file mode 100644 index 00000000000..5cb6a27e6a0 --- /dev/null +++ b/src/update/service-home-probe-entry.ts @@ -0,0 +1,9 @@ +import { inspectUpdateServiceHome } from "./service-home-authority"; +import { isUpdateHomeProbe } from "./service-home-policy.mjs"; + +// This entry deliberately does not import the CLI root, status collector or launcher. +// --no-env-file is supplied by the Node parent; malformed/old entrypoints are unknown. +if (import.meta.main) { + if (!isUpdateHomeProbe(process.argv.slice(2))) process.exit(1); + console.log(JSON.stringify({ schemaVersion: 1, ...inspectUpdateServiceHome() })); +} diff --git a/src/update/service-home-probe.d.mts b/src/update/service-home-probe.d.mts new file mode 100644 index 00000000000..a138cf3c4ab --- /dev/null +++ b/src/update/service-home-probe.d.mts @@ -0,0 +1,15 @@ +import type { UpdateServiceHomeAuthority } from "./service-home-policy.mjs"; +import type { SpawnSyncOptionsWithStringEncoding } from "node:child_process"; +export declare const UPDATE_HOME_PROBE_ARGS: string[]; +export declare function supportsUpdateHomeProbe(launcher: string, read?: (path: string) => string): boolean; +export declare function updateHomeProbeEnvironment(env: Record, + homes: { codexHome: string; opencodexHome: string }): Record; +export declare function probeUpdateLauncherHome(launcher: string, run: Parameters[0], + node: string, env: Record, read?: (path: string) => string): UpdateServiceHomeAuthority; +export declare function probeUpdateServiceHome( + run: (bin: string, args: string[], options: SpawnSyncOptionsWithStringEncoding) => { status: number | null; stdout?: unknown; error?: unknown; signal?: unknown }, + bin: string, prefix: string[], options?: { requireLauncher?: boolean; env?: Record }, +): UpdateServiceHomeAuthority; + +export declare function probeUpdatePackageOnlyLauncher(launcher: string, run: Parameters[0], + node: string, env: Record, read?: (path: string) => string): boolean; diff --git a/src/update/service-home-probe.mjs b/src/update/service-home-probe.mjs new file mode 100644 index 00000000000..23212ed4892 --- /dev/null +++ b/src/update/service-home-probe.mjs @@ -0,0 +1,79 @@ +import { parseUpdateHomeProbe, UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION } from "./service-home-policy.mjs"; +import { readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export const UPDATE_HOME_PROBE_ARGS = ["__update-home-probe"]; + +/** A trusted installed-file compatibility claim, not a tamper attestation. + * Never execute an old launcher merely to ask whether it supports this protocol. + * @param {string} launcher @param {(path: string) => string} [read] */ +export function supportsUpdateHomeProbe(launcher, read = path => { + if (statSync(path).size > 4096) throw new Error("oversized declaration"); + return readFileSync(path, "utf8"); +}) { + try { + const value = JSON.parse(read(join(dirname(launcher), "..", "src", "update", "service-home-protocol.json"))); + return value?.schemaVersion === 1 && value?.launcherProtocolVersion === 1 + && value?.entry === "__update-home-probe" && Object.keys(value).length === 3; + } catch { return false; } +} + +/** Preserve user/home/auth inputs; remove only internal launch/service/delegation tokens. + * @param {Record} env + * @param {{ codexHome: string, opencodexHome: string }} homes */ +export function updateHomeProbeEnvironment(env, homes) { + const clean = { ...env, CODEX_HOME: homes.codexHome, OPENCODEX_HOME: homes.opencodexHome }; + for (const key of ["OCX_NODE_LAUNCH_CONTEXT", "OCX_PRE_BUN_ANTHROPIC_ENV", "OCX_SERVICE", "OCX_SERVICE_MANAGED", + "OCX_UPDATE_RESTART_CHILD", "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN", "OCX_SIBLING_OF_PORT", "OCX_SIBLING_HANDOFF_NONCE", + "OCX_BUN_RUNTIME_SOURCE", "OCX_BUN_RUNTIME_PATH"]) delete clean[key]; + return clean; +} + +/** @param {string} launcher @param {Parameters[0]} run + * @param {string} node @param {Record} env + * @param {(path: string) => string} [read] */ +export function probeUpdateLauncherHome(launcher, run, node, env, read) { + if (!supportsUpdateHomeProbe(launcher, read)) return { kind: "unknown" }; + return probeUpdateServiceHome(run, node, [launcher], { requireLauncher: true, env }); +} + +/** A narrow local subprocess protocol. It never falls back to ordinary status. + * @param {(bin: string, args: string[], options: object) => { status: number | null, stdout?: unknown, error?: unknown, signal?: unknown }} run + * @param {string} bin @param {string[]} prefix + * @param {{ requireLauncher?: boolean, env?: Record }} [options] + * @returns {import('./service-home-policy.mjs').UpdateServiceHomeAuthority} */ +export function probeUpdateServiceHome(run, bin, prefix, { requireLauncher = false, env } = {}) { + try { + const result = run(bin, [...prefix, ...UPDATE_HOME_PROBE_ARGS], { + encoding: "utf8", timeout: 8_000, maxBuffer: 4096, windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], ...(env ? { env } : {}), + }); + if (result.status !== 0 || result.error || result.signal || typeof result.stdout !== "string" + || Buffer.byteLength(result.stdout, "utf8") > 4096) return { kind: "unknown" }; + return parseUpdateHomeProbe(JSON.parse(result.stdout), { requireLauncher }); + } catch { return { kind: "unknown" }; } +} + +/** Separate capability: keep the original home-probe declaration readable by running v1 updaters. + * @param {string} launcher @param {Parameters[0]} run + * @param {string} node @param {Record} env + * @param {(path: string) => string} [read] */ +export function probeUpdatePackageOnlyLauncher(launcher, run, node, env, read = path => { + if (statSync(path).size > 4096) throw new Error("oversized declaration"); + return readFileSync(path, "utf8"); +}) { + if (!supportsUpdateHomeProbe(launcher, read)) return false; + try { + const declaration = JSON.parse(read(join(dirname(launcher), "..", "src", "update", "service-home-package-only-protocol.json"))); + if (declaration?.schemaVersion !== 1 || declaration?.packageOnlyProtocolVersion !== UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION + || 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 === UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION && Object.keys(response).length === 2; + } catch { return false; } +} diff --git a/src/update/service-home-protocol.json b/src/update/service-home-protocol.json new file mode 100644 index 00000000000..0d3e739a3f9 --- /dev/null +++ b/src/update/service-home-protocol.json @@ -0,0 +1 @@ +{"schemaVersion":1,"launcherProtocolVersion":1,"entry":"__update-home-probe"} diff --git a/src/update/supervised-recovery.d.mts b/src/update/supervised-recovery.d.mts index a0bb2212cd0..f73d4357ac8 100644 --- a/src/update/supervised-recovery.d.mts +++ b/src/update/supervised-recovery.d.mts @@ -13,8 +13,8 @@ export declare const SYSTEMD_SERVICE_UNIT: string; export declare function parseLaunchdJobPid(printOutput: string): number | null; export declare function parseSystemdMainPid(showOutput: string): number | null; export declare function hasRestartableServiceManager(deps?: ServiceManagerDeps): boolean; -export declare function restartServiceManagerJob(deps?: ServiceManagerDeps): - { ok: true } | { ok: false; reason: string; unsettled?: true }; +export declare function restartServiceManagerJob(deps?: ServiceManagerDeps, authorizeMutation?: import("./mutation-guard.mjs").UpdateMutationAuthorizer): + { ok: true } | { ok: false; reason: string; unsettled?: true; refused?: true }; export declare function freshServingPid(recordPid: number, staleRecordPid: number | null): number | null; export declare function unsettledManagerWarning(port: number): string; export declare function readServiceManagerPid(deps?: ServiceManagerDeps): number | null; diff --git a/src/update/supervised-recovery.mjs b/src/update/supervised-recovery.mjs index be9d40da462..4c2a67b792b 100644 --- a/src/update/supervised-recovery.mjs +++ b/src/update/supervised-recovery.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { guardedRun, guardedSpawnSync, UpdateMutationRefusedError } from "./mutation-guard.mjs"; /** * Manager-mediated runtime recovery after an update (#7043). @@ -26,7 +26,7 @@ function positivePid(value) { return Number.isSafeInteger(pid) && pid > 0 ? pid : null; } -function defaultRun(file, args) { +function defaultRun(file, args, authorizeMutation = () => true) { // An armed test process must never reach the developer's real launchd or systemd job; // same refusal as `assertLiveServiceManagerAllowed` for the TypeScript service layer. if (process.env.OCX_TEST_HOME_GUARD === "1") { @@ -37,7 +37,7 @@ function defaultRun(file, args) { const env = file === "systemctl" && !process.env.XDG_RUNTIME_DIR && typeof process.getuid === "function" ? { ...process.env, XDG_RUNTIME_DIR: `/run/user/${process.getuid()}` } : process.env; - const result = spawnSync(file, args, { encoding: "utf8", timeout: 20_000, windowsHide: true, env }); + const result = guardedSpawnSync(authorizeMutation, "service", file, args, { encoding: "utf8", timeout: 20_000, windowsHide: true, env }); if (result.error) { // A timeout kills only the waiting client; the manager's queued job keeps running. const timedOut = result.error.code === "ETIMEDOUT"; @@ -79,17 +79,24 @@ export function hasRestartableServiceManager(deps = {}) { * waiting while the manager may still complete the restart: callers must not start a competing * proxy on that answer. */ -export function restartServiceManagerJob(deps = {}) { - const { platform, uid, run } = resolve(deps); +export function restartServiceManagerJob(deps = {}, authorizeMutation = () => false) { + try { + const { platform, uid } = resolve(deps); + const run = deps.run ?? ((file, args) => defaultRun(file, args, authorizeMutation)); const result = platform === "darwin" - ? run("/bin/launchctl", ["kickstart", "-k", launchdTarget(uid)]) + ? guardedRun(authorizeMutation, "service", run, ["/bin/launchctl", ["kickstart", "-k", launchdTarget(uid)]]) : platform === "linux" - ? run("systemctl", ["--user", "restart", SYSTEMD_SERVICE_UNIT]) + ? guardedRun(authorizeMutation, "service", run, ["systemctl", ["--user", "restart", SYSTEMD_SERVICE_UNIT]]) : null; if (result === null) return { ok: false, reason: "no launchd or systemd manager on this platform" }; if (result.timedOut) return { ok: false, unsettled: true, reason: "the service manager did not finish the restart in time" }; if (!result.ok) return { ok: false, reason: result.stderr.split("\n")[0] || "the service manager refused the restart" }; return { ok: true }; + } catch (error) { + return error instanceof UpdateMutationRefusedError + ? { ok: false, refused: true, reason: "Automatic service restart refused; check 'ocx service status'." } + : { ok: false, reason: "the service manager restart failed" }; + } } /** The manager's main PID for the job, or null when it reports none. Never throws. */ diff --git a/src/update/transactional-install.d.mts b/src/update/transactional-install.d.mts index 26ce25e13e2..17bc015ed99 100644 --- a/src/update/transactional-install.d.mts +++ b/src/update/transactional-install.d.mts @@ -39,12 +39,14 @@ type NpmUpdateOptions = { runNpm: (args: string[]) => NpmInstallResult; /** npm cache root the pre-flight resolved and checked; pinned with `--cache` for staging. */ cachePath?: string; + /** Synchronous authorization after staging/verification, immediately before the live swap. */ + mayReplacePackage?: () => boolean; log?: (line: string) => void; deps?: UpdateFsDeps; }; type NpmUpdateResult = { ok: boolean; - phase: "stage" | "verify" | "swap-backup" | "swap-live" | "post-verify" | "double-fault" | "done"; + phase: "stage" | "verify" | "authorize" | "swap-backup" | "swap-live" | "post-verify" | "double-fault" | "done"; error?: string; rolledBack?: boolean; backup?: string; diff --git a/src/update/transactional-install.mjs b/src/update/transactional-install.mjs index f72e42f9aad..62c4cad8e30 100644 --- a/src/update/transactional-install.mjs +++ b/src/update/transactional-install.mjs @@ -1,3 +1,4 @@ +import { guardedRenameSync, ownedArtifactRm } from "./mutation-guard.mjs"; /** * Transactional npm self-update: stage -> verify -> swap -> rollback (#1942 / #1849). * @@ -21,9 +22,9 @@ * proves provenance only while this process owns the fresh path, not after another local * process could have replaced it. Other neighbouring entries are likewise left alone (#5624). */ -import { spawnSync } from "node:child_process"; +import { probeSpawnSync } from "./mutation-guard.mjs"; import { randomBytes } from "node:crypto"; -import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; /** @@ -195,7 +196,7 @@ function createOwnedStage(scopeDir, pkgName, deps = {}) { if (process.platform !== "win32") mkdir(join(stageRoot, "lib")); } catch (error) { // Created by this call and not yet handed to npm: remove it rather than leave a partial stage. - try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* reported by the caller */ } + try { ownedArtifactRm(stageRoot, { recursive: true, force: true }); } catch { /* reported by the caller */ } throw error; } return stageRoot; @@ -221,7 +222,7 @@ function readOwnedStageMarker(dir, pkgName) { function removeWithRetry(rm, target, attempts = 3) { for (let attempt = 0; ; attempt += 1) { try { - rm(target, { recursive: true, force: true }); + ownedArtifactRm(target, { recursive: true, force: true }, rm); return null; } catch (error) { const code = typeof error?.code === "string" ? error.code : "EUNKNOWN"; @@ -238,7 +239,7 @@ function removeWithRetry(rm, target, attempts = 3) { * provably ours and the next update can finish the job. */ export function removeOwnedStage(stageRoot, deps = {}) { - const rm = deps.rm ?? rmSync; + const rm = deps.rm; let names; try { names = readdirSync(stageRoot); @@ -305,7 +306,7 @@ export function sweepUpdateLeftovers({ packageDir, pkgName, log = () => {}, deps /** Whether the launcher that ran this npm update can still drive service/tray recovery. */ export function launcherUsableAfterNpmUpdate(tx) { - return Boolean(tx?.ok || tx?.rolledBack === true || ["stage", "verify", "swap-backup"].includes(tx?.phase)); + return Boolean(tx?.ok || tx?.rolledBack === true || ["stage", "verify", "authorize", "swap-backup"].includes(tx?.phase)); } /** Largest regular file under a directory tree (bounded depth) — locates the Bun binary. */ @@ -328,10 +329,10 @@ function findLargestFile(root, depth = 3) { } /** Bounded Windows-class rename retry: EPERM/EBUSY/EACCES from AV/indexers clears in ms. */ -function renameWithRetry(rename, from, to, attempts = 5) { +function renameWithRetry(rename, from, to, authorize = () => true, attempts = 5) { for (let attempt = 0; ; attempt += 1) { try { - rename(from, to); + guardedRenameSync(authorize, from, to, rename); return; } catch (error) { const code = error?.code; @@ -350,7 +351,7 @@ function recoveryMarkerPath(scopeDir) { /** Startup probe: restore a backup when the live tree is broken (D4 power-loss rows). */ export function bootRestoreProbe(packageDir, deps = {}) { - const rename = deps.rename ?? renameSync; + const rename = deps.rename; const scopeDir = dirname(packageDir); let backups = []; try { @@ -365,15 +366,15 @@ export function bootRestoreProbe(packageDir, deps = {}) { if (liveOk) { // Live is healthy: the backups are leftovers from a completed swap. Reap them. for (const name of backups) { - try { rmSync(join(scopeDir, name), { recursive: true, force: true }); } catch { /* keep */ } + try { ownedArtifactRm(join(scopeDir, name), { recursive: true, force: true }); } catch { /* keep */ } } - try { rmSync(recoveryMarkerPath(scopeDir), { force: true }); } catch { /* keep */ } + try { ownedArtifactRm(recoveryMarkerPath(scopeDir), { force: true }); } catch { /* keep */ } return { action: "reaped", count: backups.length }; } if (!existsSync(join(newestBackup, "package.json"))) return { action: "none" }; try { - try { rmSync(packageDir, { recursive: true, force: true }); } catch { /* may not exist */ } - rename(newestBackup, packageDir); + try { ownedArtifactRm(packageDir, { recursive: true, force: true }); } catch { /* may not exist */ } + guardedRenameSync(() => true, newestBackup, packageDir, rename); return { action: "restored", from: newestBackup }; } catch (error) { return { action: "failed", error: error?.message ?? String(error) }; @@ -390,10 +391,11 @@ function* npmUpdateTransaction({ targetVersion, tag, cachePath, + mayReplacePackage = () => true, log = () => {}, deps = {}, }) { - const rename = deps.rename ?? renameSync; + const rename = deps.rename; const scopeDir = dirname(packageDir); // Leftovers from earlier attempts are only reported; fresh unique stages step around them. This never fails. try { sweepUpdateLeftovers({ packageDir, pkgName, log, deps }); } catch { /* report-only */ } @@ -462,6 +464,14 @@ function* npmUpdateTransaction({ return { ok: false, phase: "verify", error: "staged tree failed verification: " + staged.failures.join("; ") }; } + // The installer may have awaited a supervisor. Only a synchronous true authorizes + // the live swap; errors, promises and refusals leave live untouched. + let authorized = false; + try { authorized = mayReplacePackage() === true; } catch { /* fail closed */ } + if (!authorized) { + discardStage(); + return { ok: false, phase: "authorize", error: "package replacement authority changed; rerun from the beginning" }; + } // D3: swap. live -> backup, stage -> live, re-verify live, rollback on failure. const backupRoot = join(scopeDir, stampedName(".ocx-backup")); const backupPackage = join(backupRoot, "opencodex"); @@ -472,11 +482,11 @@ function* npmUpdateTransaction({ return { ok: false, phase: "swap-backup", error: "could not create backup directory: " + (error?.message ?? String(error)) }; } try { - renameWithRetry(rename, packageDir, backupPackage); + renameWithRetry(rename, packageDir, backupPackage, () => mayReplacePackage() === true); } catch (error) { discardStage(); - try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } - return { ok: false, phase: "swap-backup", error: "could not move live tree aside: " + (error?.message ?? String(error)) }; + try { ownedArtifactRm(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } + return { ok: false, phase: error?.code === "EUPDATEAUTHORITY" ? "authorize" : "swap-backup", error: "could not move live tree aside: " + (error?.message ?? String(error)) }; } try { renameWithRetry(rename, stagedPackage, packageDir); @@ -485,7 +495,7 @@ function* npmUpdateTransaction({ try { renameWithRetry(rename, backupPackage, packageDir); discardStage(); - try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { ownedArtifactRm(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } return { ok: false, phase: "swap-live", rolledBack: true, error: "could not place staged tree: " + (error?.message ?? String(error)) }; } catch (rollbackError) { writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({ @@ -508,7 +518,7 @@ function* npmUpdateTransaction({ renameWithRetry(rename, packageDir, join(stageRoot, "rejected")); renameWithRetry(rename, backupPackage, packageDir); discardStage(); - try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } + try { ownedArtifactRm(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ } return { ok: false, phase: "post-verify", rolledBack: true, error: "live tree failed post-swap verification: " + liveCheck.failures.join("; ") }; } catch (rollbackError) { writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({ diff --git a/src/update/update-failure-guidance.mjs b/src/update/update-failure-guidance.mjs index d0cfca616bf..6d701498145 100644 --- a/src/update/update-failure-guidance.mjs +++ b/src/update/update-failure-guidance.mjs @@ -33,7 +33,7 @@ export function manualUpdateFailureGuidance({ bin, args, owner, platform = proce } /** Phases in which the live package was never touched. */ -const UNTOUCHED_PHASES = new Set(["stage", "verify", "swap-backup"]); +const UNTOUCHED_PHASES = new Set(["stage", "verify", "authorize", "swap-backup"]); /** * @param {{ phase?: string; rolledBack?: boolean; pkgName: string; version?: string; tag?: string }} failure diff --git a/src/update/worker-launch.ts b/src/update/worker-launch.ts index 3c964c3b489..331e9431da0 100644 --- a/src/update/worker-launch.ts +++ b/src/update/worker-launch.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from "node:child_process"; +import { probeSpawn, probeSpawnSync } from "./mutation-guard.mjs"; import { accessSync, constants, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute } from "node:path"; @@ -106,11 +106,11 @@ const systemdRunHooks: SystemdRunHooks = { // Run a real scope with the same absolute binary as its harmless version payload. // Probing only the outer --version would not verify the user bus. probeScope: path => { - const probe = spawnSync(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", timeout: 5_000, env: scopeProbeEnvironment() }); + const probe = probeSpawnSync(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", timeout: 5_000, env: scopeProbeEnvironment() }); return !probe.error && probe.status === 0; }, probeScopeAsync: path => new Promise(resolve => { - const probe = spawn(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", env: scopeProbeEnvironment() }); + const probe = probeSpawn(path, [...SYSTEMD_SCOPE_ARGS, path, "--version"], { stdio: "ignore", env: scopeProbeEnvironment() }); probe.unref(); const timer = setTimeout(() => { try { probe.kill("SIGKILL"); } catch { /* failed termination is not a successful probe */ } diff --git a/structure/INDEX.md b/structure/INDEX.md index 47c44d079bd..21eb72091f8 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -400,9 +400,14 @@ A source area can be described by more than one doc, because these docs are orga | `src/types/config.ts` | [`transports/timeouts.md`](transports/timeouts.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/update/guarded-port-reclaim.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | +| `src/update/guarded-runtime-ops.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | +| `src/update/guarded-tray-ops.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/index.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/update/job-pinned-start.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/job-supervised-restart.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/job.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | +| `src/update/mutation-guard.mjs` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/restart-ownership.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/update/supervised-recovery.mjs` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`companion.md`](companion.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 4b0633b2066..8552f61b1c1 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1087,6 +1087,11 @@ "scope": "Manager-first recovery after a failed post-update service refresh, and the non-zero unsupervised fallback.", "documents": [ "src/update/supervised-recovery.mjs", + "src/update/mutation-guard.mjs", + "src/update/guarded-runtime-ops.ts", + "src/update/guarded-port-reclaim.ts", + "src/update/guarded-tray-ops.ts", + "src/update/job-pinned-start.ts", "src/update/job-supervised-restart.ts", "src/update/job.ts" ] diff --git a/structure/ops/desktop-supervision.md b/structure/ops/desktop-supervision.md index 440e32578e1..1dbc951c14d 100644 --- a/structure/ops/desktop-supervision.md +++ b/structure/ops/desktop-supervision.md @@ -118,6 +118,13 @@ surface in `src/cli/update-restart.ts`, and stop guidance in `src/cli/desktop-runtime-guidance.ts`. These decisions follow [runtime ownership](../runtime.md#background-service-runtime-ownership). +Update recovery computes the Node install argv (including its backend read) and +the Bun direct-start runtime provenance before the final mutation authorizer. +The spawn arguments then use those prepared values; in-memory argv/environment +formatting is the only allowlisted work between that authorizer and process creation. +`tests/update/update-home-launcher.test.ts` and `tests/update/update-home-recovery.test.ts` +exercise refusal when preparation changes the home observation, plus stable-home starts. + The bypass ledger classifies these guards as **early warning**, not enforcement: | Guard | Surface / tier | Known limit and residual risk | Pass-through assertion | diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 2f3cfab42c7..eb2871190ab 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -425,8 +425,7 @@ Live supervision is re-read separately from durable ownership before stop, packa service restoration and direct recovery. Each service command, Node/Bun update run and dashboard restart decision shares one `createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: `desktop` or `unknown` with `desktopSeen: true` blocks; only a later `none` clears it. -Plain unknown/unsupported evidence preserves existing decisions unless an earlier block remains. -`src/update/runtime-ownership.mjs` denies all three update authorities and failed-update recovery +Plain unknown/unsupported evidence preserves existing decisions unless an earlier block remains; `src/update/runtime-ownership.mjs` denies all three update authorities and failed-update recovery when blocked. A restoration refusal after a completed package swap does not undo that update. Service install, repair, start and restart refuse before mutation, including Windows XML staging, when live Desktop supervision is verified; `ocx stop` shows the Desktop notice. Windows probe acquisition and residual limits: [desktop supervision](desktop-supervision.md#runtime-ownership-from-the-apps-side). @@ -435,9 +434,9 @@ or the post-install refresh): the manager's `ocx start` child cannot join it, an through the repair's health wait keeps that proxy from starting (#5760). The recovery decision is made again after the release, and the lease is re-acquired before each fallback's ownership re-read so a claim landing in the unleased window is vetoed rather than killed unleased. -Direct recovery retains the lease until readiness or its bounded deadline. The normal successful manual-runtime update still prints the existing -restart hint. - +Direct recovery retains the lease until readiness or its bounded deadline. If acquisition fails, Node and Bun return false before owner inspection or spawn and print static manual repair/start guidance without the error's details. +`src/update/index.ts` rechecks service-home authority in the mutation caller after its final ownership or recovery-plan await, before proxy stop and each service repair. Service mutations also require `homePolicy.serviceAllowed`; a previously installed registration disappearing before full stop refuses, while recovery uses guarded direct start. No await separates these final checks from the control command. The dashboard worker in `src/update/job.ts` also checks service permission before stopping wrappers after its service import. Package replacement retains its separate stable-unknown package-only policy. +The normal successful manual-runtime update still prints the existing restart hint. All three update lanes use the shared latched mutation gate described in [Update Supervised Recovery](update-supervised-recovery.md#service-home-authority), including fresh checks between blocking control calls and before terminal success. Every updater lane makes the same exception where the service manager starts the proxy outside the updater's process tree, so that proxy has to take the lease itself; held through the repair's health wait, the lease kept it from starting, and recovery fell through to a second, @@ -453,7 +452,7 @@ service repair` and re-acquires it at the direct-start fallthrough, waiting long outlast one service-wrapper respawn, then re-runs the durable-owner and live-supervision veto under it before mutating the port, because a claim could have landed during the now-unleased refresh window. A lease that stays claimed fails closed: nothing is started, and the job is marked failed, since -the refresh before it produced no serving proxy; an ownership veto still ends as succeeded. +the refresh before it produced no serving proxy; an ownership veto ends as succeeded only after a fresh service-home check. CLI refresh and final fallback vetoes give home refusal precedence too. On Windows, `src/update/npm-invocation.mjs` reads copied shell-location keys case-insensitively, with exact-key precedence, no ambiguous alias selection, and the same absolute-path checks. @@ -467,6 +466,7 @@ Other Scoop apps, version-directory entries (`NO_JUNCTION`), custom home-root Sc arbitrary descendants and cwd inside the resolved Node installation are not admitted. The Windows npm staging runner (`src/update/npm-install.mjs`) keeps a 180-second install budget and a separate 10-second join budget. Before cleanup or verification, it requires proof that the full installer tree exited, including when npm returns status zero. +After that barrier and stage verification, the transaction's synchronous replacement callback rechecks home authority, owner, supervision and dead liveness before the live swap. A denied callback returns `authorize`, removes only the owned exited stage and leaves the old package usable; failure guidance classifies this as an untouched install. The same callback runs after synchronous npm staging. Pinned restart preparation separately checks current service permission inside its wrapper callback after the fresh home gate, so confirmed absence grants foreground cleanup without a global service stop. On Windows, `src/update/npm-install-job.ps1` creates the root atomically inside a private kill-on-close Job Object using `STARTUPINFOEX` and `PROC_THREAD_ATTRIBUTE_JOB_LIST`. It stays suspended until local exit-proof handling is armed. `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` admits only stdin and npm's stderr, excluding the Job and supervisor result pipe; attribute lists and value buffers are freed. Windows 10+ Job List support is required: attribute setup or extended creation failure returns `EINSTALLJOIN` with a clear prerequisite and native error, without starting an unsupervised installer or retrying a legacy creation path. Timeout terminates the job, checks zero active members and waits for the root handle. The updater and recovery stay outside this job; a watchdog bounds supervisor startup/protocol failure. Missing exit proof retains the complete stage and marker. npm output drains before a successful join returns; failed supervision stops forwarding it. Windows timeout diagnostics retain `ETIMEDOUT` and the budget; stage failures retain owner-aware recovery through the untouched launcher. POSIX keeps the legacy synchronous install and status-only transaction; complete-tree timeout cleanup remains open there because process groups do not contain detached descendants. The npm transaction creates each staging directory exclusively and may clean that fresh path while the creating process still owns it. On POSIX it also creates the stage's `lib` directory, because npm's strict script policy plans the global tree before creating the prefix layout (#5760). diff --git a/structure/ops/update-supervised-recovery.md b/structure/ops/update-supervised-recovery.md index 8ea35e16b9d..412997cf1a3 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 @@ -37,12 +37,194 @@ update exits non-zero. Failed-update recovery of a managed install in `src/updat the manager the same way before its direct fallback; that path already exits non-zero. 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. +Home-authority refusal is a separate `refused` outcome: both launchers print static service-status +guidance, start no proxy, and fail the successful-update fallback with a non-zero exit. The Bun +fallback preserves that outcome rather than treating it as an ownership veto. Direct recovery +callers also preserve these outcomes when a service disappears: `true` and ordinary `vetoed` +complete foreground recovery successfully; `false` and `refused` fail. A managed fallback that +actually starts remains UNSUPERVISED and fails. The Node foreground caller explicitly fails a +home check after shim or tray work rather than falling through to success. +Direct recovery checks homes after lease acquisition and again after ownership inspection; service repair checks +homes and service permission in its caller after the last awaited ownership or recovery plan. + +## Service home authority + +`src/update/mutation-guard.mjs` supplies the synchronous `authorizeMutation(kind)` attached +to each update home policy. Its guarded primitive adapters first recursively copy every argument +into inert data: arrays by index, and own enumerable object properties read once into fresh +null-prototype records, discarding inherited properties (including Node's special env prototype). +Accessors and Proxy traps run during this copy. Cycles and functions in data are rejected. +The adapter then invokes the same latched synchronous gate +and dispatches the primitive with only those copies, without an await or preparation in between. +Node, Bun and the dashboard worker use this boundary for runtime, service, tray, shim, package +mutations and terminal success publication. `guardedRun` accepts only a trusted primitive adapter +that consumes its materialized argument list; orchestration closures are not operations. +`guardedExecFile` alone permits its documented final completion callback, which the native API +invokes on a later event. Synchronous updater/report callbacks run before their guarded leaves. +`src/update/guarded-runtime-ops.ts` owns Bun runtime state, PID, TCP and service-stop adapters; +`src/update/guarded-tray-ops.ts` owns the dynamically loaded Windows tray adapters. +The worker prepares its latest stored job snapshot, directory and serialized payload before +the success-write adapter in `src/update/job.ts`; publishing `succeeded` without an authorizer +refuses even when reached through an ordinary job-store helper. +The adapter carries the same synchronous gate through the atomic writer's +`validateBeforeRename` hook, after temp preparation and before every native rename attempt, +including Windows retries. Runtime PID termination similarly authorizes each actual signal: +the POSIX grace wait cannot lend its earlier SIGTERM authority to a later SIGKILL. +Port reclaim passes its sticky gate into the termination ladder after identity preparation +and into TCP reclamation. Both Bun updater entrypoints use the update-owned +`src/update/guarded-port-reclaim.ts` adapter, which requires a synchronous gate and refuses +an omitted or invalid gate before shared reclaim I/O. Ordinary non-update reclaim callers +retain the shared helper's defaults. `tests/update/update-reclaim-authorizer.test.ts` covers +omission, invalid gates, denial and the stable available-port result. +Each prepared native TCP row is authorized immediately before +SetTcpEntry, after FFI loading, netstat inspection and pointer preparation. Availability +success also requires the gate to remain open. PID/runtime records and registry pointers +check after path preparation; WinSW, scheduler, scoped wrapper and tray stops carry their +gate through command preparation to dispatch. Optional leaf hooks preserve ordinary callers. +Each call obtains a fresh home observation; a refusal or throwing observation closes the +attempt permanently, including package replacement and success after a transient re-match. +Service calls additionally require `serviceAllowed`. Confirmed disappearance skips service +control while retaining authorized foreground cleanup. `package-publish` uses the package +policy and grants no runtime authority to a restricted update. + +The two cleanup kinds complete only an operation already attempted: `own-child` retires the +exact still-live pinned-start ChildProcess, and `own-tray` restores only the own tray recorded +running before this update stopped it. They remain available after home refusal, never install +or refresh a tray, and never discover another runtime or service target. Transaction staging +and bounded rollback remain package-local; the Node transaction rechecks the original policy +after staging through its synchronous replacement callback before touching the live tree. + +The production tray handoff checks after its module import and immediately before stopping. +Shim completion and tray installation are distinct boundaries, as are a blocking PID kill and +the next wrapper stop. Cold updates and workers without restart recheck after their final +control command before publishing success. The manager helper returns `vetoed` for an ordinary +ownership change under still-authorized homes; that skips recovery successfully. `refused` +is a home failure and an actually started unsupervised fallback remains a failure. +`src/update/job-pinned-start.ts` performs pinned-start I/O and checks after log setup, just +before spawning. Its caller passes the same latched authorizer. +`src/update/job-windows-restart.ts` receives that same authorizer at all worker call +sites. It rechecks service permission after backend observation, before each backend +stop and after the blocking stop or its catch path before wrapper termination. Path +setup precedes a final service check at the scoped scheduler-wrapper killer. + +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. +If direct recovery cannot reacquire the ownership lease, both CLI lanes return failure with static +manual repair/start guidance, without inspecting owners or starting another proxy. +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. +Full proxy stop in both CLI lanes requires current service permission when the attempt captured +a service; a disappearing registration refuses with manual status guidance before sending stop. +Service refresh rechecks home after ownership inspection, including Bun's awaited read, before +returning an ownership veto. Home refusal remains failure; an authorized ownership veto is success. +The final CLI fallback veto and worker entry/reacquisition veto apply the same refusal precedence. +The npm transaction in `src/update/transactional-install.mjs` checks its synchronous +`mayReplacePackage` callback after staging and verification, before any live rename. The Node +launcher supplies fresh baseline-home, durable-owner, supervision and dead-liveness checks for +both synchronous and asynchronous installers. Refusal discards only this attempt's exited stage, +returns the untouched `authorize` failure phase and retains the old usable launcher. Rollback +after a swap begins remains bounded transaction cleanup. The dashboard's npm path delegates to +this guarded launcher; the Bun raw installer path is synchronous and has no awaited swap boundary. +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-home` plus `--update-worker-result`: the flag pins the worker's verified +initial home authority — all four kinds, unknown included — as the child's baseline in place of a +fresh inspection, so a service registered or removed between the worker's check and the child's run +cannot mint authority the worker never held, and a malformed or duplicated baseline fails closed +rather than falling back to inspection. A stable unknown baseline still permits the package-only +replacement. 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 raw package-manager lane +cannot carry baseline argv, so the worker rechecks home authority at the replacement boundary itself, +after the own-foreground tray handoff and before the command runs; on refusal the only side effect +is restoring that stopped tray, and on a post-replacement refusal the same restore is the only tray +action — the check runs before any shim, tray install or service refresh, never after. +Both CLI lanes restore that stopped tray only through a verified, still-present package launcher; +a tray that was not running receives no cleanup command, and refusal never installs or refreshes it. +The dashboard also carries its original home baseline into `finishGuiUpdateRestart` and the actual +restart policy through `RestartIo`, retaining it across lease acquisition and asynchronous health +checks. A registration appearing after initial absence, or a changed foreign subject, cannot become +new recovery authority. Standalone restart calls without a delegated baseline inspect their own. + +The dashboard worker checks the unchanged v1 `src/update/service-home-protocol.json` plus the v2 +`src/update/service-home-package-only-protocol.json`, then the delegated launcher's exact private +`__update-package-only-probe` response. The version bump fails closed in both directions: an old +worker refuses a v2 launcher whose flag it would ignore, and a v2 worker refuses a v1 launcher that +cannot enforce a pinned baseline. This separate capability probe performs no Bun resolution +or home inspection. Missing declarations, failed calls and malformed responses refuse delegation. +Keeping the original home-probe declaration and response at v1 lets already-running v1 updaters +inspect and recover after installing the new package. `src/update/job-update-home.ts` owns the +capability gate. Markers claim compatibility of trusted installed files, not tamper attestation. + +The new Node launcher intercepts this exact private command before Desktop handoff, boot restore +and ordinary install-capable Bun resolution. It resolves Bun and its compatibility floor from the +actual probe target's package with installation disabled, then uses +`--no-env-file` with `src/update/service-home-probe-entry.ts`, which imports the authoritative +inspector rather than the CLI root/status collector. No full status fallback exists. Probes have +an eight-second timeout and 4 KiB output ceiling; errors, unsupported versions and malformed JSON +are unknown. Explicit home snapshots preserve the SQLite-home resolver and user/auth environment; +only internal launcher, service, restart-child, sibling and delegation tokens are removed. +Missing Bun in this probe returns unknown rather than exiting its caller; ordinary launcher +resolution keeps its installation and terminal failure behavior. Nested probe deadlines can refuse +a valid installation during slow cold starts; the bounds are not a readiness guarantee. +Successful updates recover a foreground runtime only when this update attempted its stop. A cold +CLI update without prior runtime state stays stopped and prints the existing manual-start guidance. + +An already-running old Node parent keeps its old implementation after package replacement; these +checks cannot retrofit it. Automatic delegation requires the guarded launcher already present. +Existing stop authorization remains unchanged: stable foreign foreground recovery applies only +when that runtime's own stop/update is permitted and succeeds, not to every cross-home combination. ## The dashboard update worker -The dashboard restart worker in `src/update/job.ts` follows the same order through +`src/update/service-home-authority.ts` distinguishes matching service homes, a known foreign +registration, unknown evidence and confirmed absence. It combines service-state resolution with +the unattended native ownership inspector and the lifecycle SQLite-home comparison; physical +directory aliases remain equivalent. A changing state read is unknown. The inspector's `owned` +result alone does not establish a registration, because it also includes confirmed absence. + +The dashboard worker permits service repair, manager restart and global Windows service stops +only for a matching home. Stable foreign registration and confirmed absence retain the captured +foreground runtime's existing PID/port and lease checks for direct recovery, without touching +the other registration or reporting that foreground runtime as UNSUPERVISED. Unknown evidence +refuses automatic restart. A matching home that becomes foreign or unknown after the unleased +refresh refuses both manager recovery and direct fallback. A foreign record must remain the same +observed subject for foreground recovery. Refusals persist static manual-status guidance in the +terminal error, including authority changes after installation, never recorded paths or probe output. +Package-only command displays retain the Node launcher shape with a `` placeholder before +persistence; the executable and argument array retain the actual launcher path, including spaces. + +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. +Pinned-start preparation and both ghost-listener waits share reclaim's latched authority gate. +Preparation rechecks before wrapper cleanup, each PID kill, PID/runtime-state removal and TCP-row +deletion; the waits recheck before each TCP-row deletion. A denial immediately returns the existing +restart refusal, including after a transient re-match; void-returning preparation overrides remain valid. +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 scoped Windows +wrapper cleanup under the same latched service guard. Pinned direct recovery receives global service-stop authority only for a matching +managed home; foreground recovery does not acquire that authority. +The preparation callback tests the current `serviceAllowed` after preparation's fresh mutation +gate: a matching registration disappearing still allows foreground PID/port cleanup, while +skipping the global wrapper stop selected before that read. + +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 +size cap, and `src/update/job-update-tray.ts` owns the production own-foreground tray handoff for +the same reason. The helper publishes its pre-stop plan to the worker before stopping; if post-stop +status and immediate native restore both fail, the worker retains the original running state and +retries only `tray start` through its verified launcher. A failed handoff never permits package +replacement, and a failed cleanup retains the handoff error. 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, using the same helpers and the same manager-PID-against-serving-PID proof. A proven supervised restart skips the direct start. An unsettled restart starts no competing proxy and fails the job. @@ -61,6 +243,36 @@ refuses, matching `assertLiveServiceManagerAllowed` in the service layer. ## Coverage +`tests/update/update-mutation-boundaries.test.ts` executes production CLI and worker bodies +with injected module/process/store I/O for the import, shim, tray, PID-kill and manager-veto +boundaries, including stable controls. The runtime cases in +`tests/update/update-mutation-guard.test.ts` check the refusal latch, confirmed absence and +restricted publication. Runtime mutation guards are the security boundary for update effects +and fresh authorization. + +The scanner and inventory cases are auxiliary best-effort regression lint over known patterns, +explicitly not a security boundary or a completeness proof. JavaScript acquisition forms and +transitive effects outside those patterns can escape this lint. A green scan does not establish +that all effects are guarded. `tests/helpers/update-mutation-scanner.ts` shares known patterns +with `tests/helpers/update-capability-policy.ts`; `tests/helpers/update-import-policy.ts` records +dependency/binding snapshots, and `tests/helpers/update-primitive-allowlist.ts` records exact +probe and cleanup exceptions. These snapshots confer no runtime authority. +The helper inventory scans update modules and the launcher's direct source imports using known +function/call/count patterns; detected unclassified sites fail the regression check. The launcher +resolver-body snapshot and `tests/update/update-capability-policy.test.ts` are likewise auxiliary. +These lint cases parse through TypeScript 7's sync client, which cannot start under Bun on POSIX +(`tests/helpers/update-lint-parser.ts`); there they are skipped, while every runtime guard case runs. +`tests/update/update-mutation-scanner.test.ts` rejects raw named-options, spread and member-read +mutants, native import aliases, Bun shell imports/references and tagged-template execution. +It also rejects direct worker/process and file-write primitives outside exact reviewed +private-artifact and read-probe exceptions. `tests/update/update-effect-authority.test.ts` +executes signal, TCP, service/tray and state-removal leaves with injected OS I/O. +`tests/update/update-guarded-ops.test.ts` exercises +getter, accessor, Proxy and function-bearing arguments at the runtime copy/gate boundary. +`tests/update/update-windows-wrapper-authority.test.ts` executes +the complete wrapper helper and worker restart bodies with injected external I/O for +stable, absent, unknown and foreign homes, including throwing backend and stop calls. + `tests/update/update-supervised-recovery.test.ts` pins the manager commands, the label and unit against `src/service/state.ts`, the manager-PID-against-serving-PID verdict, the test guard, and the Bun lane's ordering. `tests/update/update-desktop-owner.test.ts` drives the launcher lane: a @@ -69,3 +281,25 @@ 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. +`tests/update/update-recovery-authority.test.ts` executes the production pinned-preparation body, +both ghost-wait call paths and CLI lease refusals with injected OS I/O, including home changes +at preparation mutation boundaries, mid-wait changes and latched transient denials. +The launcher suite also runs the complete Node update function in an isolated Node child with +replacement/control I/O intercepted; `tests/update/update-bun-ownership-lease.test.ts` runs the actual +Bun updater child. Both enforce the delegated baseline before replacement, retain stable-unknown +package-only success, and reject malformed baselines. The Bun child also rejects a changed +post-replacement home before shim/tray/service commands. `tests/update/update-home-worker.test.ts` +executes tray handoff and command injection on every platform, covering both a previously running +and a stopped tray so refusal can restore only the former, without install or refresh. +The worker suite changes absent/foreign authority under the restart lease, after the worker's last +check, and reaches the real recovery policy with no service repair or direct start permitted. +The Node child and executable Bun refusal body also cover stopped/unverified tray cleanup. +`tests/update/update-tray-handoff.test.ts` also drives the default Windows worker path in isolated +children with native tray I/O mocked: running, idle, absent, missing initial evidence, and post-stop +status plus native-restore failures, including a launcher cleanup failure. No handoff override is used. diff --git a/tests/ci-workflows/bun-runtime.test.ts b/tests/ci-workflows/bun-runtime.test.ts index 0c196b6774e..383127c1b98 100644 --- a/tests/ci-workflows/bun-runtime.test.ts +++ b/tests/ci-workflows/bun-runtime.test.ts @@ -304,8 +304,12 @@ describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { ]; for (const relative of launchers) { const text = readFileSync(repoPath(relative), "utf8"); - const spawnCount = (text.match(/spawn\(process\.execPath/g) ?? []).length; + // The updater dispatches its restart through guardedSpawn(gate, kind, process.execPath, ...). + const spawnCount = (text.match(/(?:\bspawn\(|\bguardedSpawn\([^,()]+,\s*"[a-z-]+",\s*)process\.execPath/g) ?? []).length; const directStampCount = (text.match(/env: withProcessRuntimeProvenance\(/g) ?? []).length; + const preparedStampCount = relative === "src/update/index.ts" + ? (text.match(/const runtimeEnv = withProcessRuntimeProvenance\(env\);/g) ?? []).length : 0; + if (preparedStampCount > 0) expect(text.match(/env: runtimeEnv\b/g)).toHaveLength(preparedStampCount); const detachedStartStampCount = relative === "src/cli/index.ts" ? (text.match(/env: detachedStartEnvironment\(\)/g) ?? []).length : 0; @@ -313,7 +317,7 @@ describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { expect(text).toContain("return withProcessRuntimeProvenance(env)"); } expect(spawnCount).toBeGreaterThan(0); - expect(directStampCount + detachedStartStampCount).toBe(spawnCount); + expect(directStampCount + preparedStampCount + detachedStartStampCount).toBe(spawnCount); } }); }); diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 6d4fc1d2ca2..e8bc6028e92 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -222,7 +222,7 @@ exit 0 expect(launcher).toContain('["add", "-g", "--allow-build=bun", `${PKG}@${tag}`]'); expect(launcher).toContain('return String(currentVersion).includes("-preview.") ? "preview" : "latest"'); expect(launcher).toContain("!isBunGlobalInstall()"); - expect(launcher).toContain("repairCodexShimIfNeeded(postUpdateLauncher)"); + expect(launcher).toContain("repairCodexShimIfNeeded(postUpdateLauncher, homePolicy.authorizeMutation)"); expect(launcher).toContain("runNpmSelfUpdate()"); expect(launcher).toContain("runPnpmSelfUpdate()"); }); diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 411ff9c25a0..6e76cbc7e08 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -726,11 +726,14 @@ describe("a sibling start leaves shared client routing to the live owner", () => ["src/cli/claude.ts", 'env: withProcessRuntimeProvenance(withoutSiblingMarker({ ...process.env, OCX_SERVICE: "1" })),'], ["src/cli/opencode.ts", opencodeStartEnv], ["src/cli/minimax.ts", opencodeStartEnv], - ["src/update/job.ts", "const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"], + ["src/update/job-pinned-start.ts", "const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);"], ["src/update/index.ts", "const env = mutation.controlEnvironment(withoutSiblingMarker(process.env));"], ] as const) { expect(readFileSync(repoPath(path), "utf8"), path).toContain(env); } + const worker = readFileSync(repoPath("src/update/job.ts"), "utf8"); + expect(worker).toContain('import { spawnWorkerPinnedStart } from "./job-pinned-start"'); + expect(worker).toContain("return spawnWorkerPinnedStart(restartCommand(false, installer, launcher, port), authorizeMutation,"); // The package launcher's own post-update restart cannot import the helper; it deletes inline. expect(readFileSync(repoPath("bin/ocx.mjs"), "utf8")).toContain("delete env.OCX_SIBLING_OF_PORT;"); expect(readFileSync(repoPath("bin/ocx.mjs"), "utf8")).toContain("delete env.OCX_SIBLING_HANDOFF_NONCE;"); diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index 972ad5b25ac..c3aff7940cb 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"'); @@ -59,7 +59,7 @@ describe("ocx.mjs package launcher (source invariants)", () => { expect(source).toContain("[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source,"); // The stamp must sit inside the spawn's env object, not merely somewhere in the file. - const spawnStart = source.indexOf("const child = spawn(bun, [cliPath"); + const spawnStart = source.indexOf("const child = probeSpawn(bun, [cliPath"); expect(spawnStart).toBeGreaterThanOrEqual(0); const spawnCall = source.slice(spawnStart, source.indexOf("});", spawnStart)); expect(spawnCall).toContain("[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source"); @@ -84,7 +84,7 @@ describe("ocx.mjs package launcher (source invariants)", () => { }); test("the long-running Bun child stays hidden under a headless Windows launcher (#1236)", () => { - const spawnStart = source.indexOf("const child = spawn(bun, [cliPath"); + const spawnStart = source.indexOf("const child = probeSpawn(bun, [cliPath"); expect(spawnStart).toBeGreaterThanOrEqual(0); const spawnCall = source.slice(spawnStart, source.indexOf("});", spawnStart)); @@ -100,11 +100,11 @@ describe("ocx.mjs package launcher (source invariants)", () => { expect(source).toContain("pnpmOwnerInvocation(owner, args)"); expect(source).toContain("const latestInvocation = managerInvocation("); expect(source).toContain("const installInvocation = managerInvocation(installArgs);"); - expect(source).toContain("spawnSync(latestInvocation.file, latestInvocation.args"); + expect(source).toContain("probeSpawnSync(latestInvocation.file, latestInvocation.args"); // #1942: the staged install spawns through the same hardened npmInvocation resolver // inside the transactional runNpm callback. expect(source).toContain("const invocation = npmInvocation(args);"); - expect(source).toContain("spawnSync(invocation.file, invocation.args"); + expect(source).toContain("guardedSpawnSync(homePolicy.authorizeMutation, \"package\", invocation.file, invocation.args"); expect(source).not.toContain("shell: true"); expect(source).not.toContain('"npm.cmd"'); }); @@ -155,7 +155,7 @@ describe("ocx.mjs package launcher (source invariants)", () => { expect(guard).toBeGreaterThan(-1); // The spawn must no longer splat the raw environment, or the deletes above are pointless. - const spawnStart = source.indexOf("const child = spawn(bun, [cliPath,"); + const spawnStart = source.indexOf("const child = probeSpawn(bun, [cliPath,"); expect(spawnStart).toBeGreaterThan(-1); expect(source.slice(spawnStart)).not.toContain("...process.env,"); expect(source.slice(spawnStart, source.indexOf("});", spawnStart))) @@ -180,10 +180,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); @@ -211,7 +211,7 @@ test("Desktop supervision is freshly checked before Node stop and both package-m expect(source.match(/createSupervisionLatch\(\)/g)).toHaveLength(1); const initial = source.indexOf("supervision: observeSupervision()", source.indexOf("const initialOwnership")); const gate = source.indexOf("const preStopPlan = planUpdateRuntimeHandling({"); - const stop = source.indexOf('const stopRes = spawnSync(process.execPath, [launcher, "stop"]'); + const stop = source.indexOf('const stopRes = guardedSpawnSync(homePolicy.authorizeMutation, serviceWasInstalled ? "service" : "runtime", process.execPath, [launcher, "stop"]'); const npm = source.indexOf("const tx = await transactionalNpmUpdate({"); const pnpm = source.indexOf("const update = runPnpmGlobalUpdate({"); expect(initial).toBeGreaterThan(0); expect(gate).toBeGreaterThan(initial); diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index 4cc117a15b5..67fe73a7b36 100644 --- a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts +++ b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts @@ -43,7 +43,7 @@ describe("Codex CLI updater launcher policy", () => { expect(source).toContain("resolveBun({ allowInstall: !codexCliUpdateInspection })"); // A missing bundle makes installJs nullable; allowInstall still gates its execution. expect(source).toContain( - 'if (allowInstall && installJs && existsSync(installJs)) {\n const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });', + 'if (allowInstall && installJs && existsSync(installJs)) {\n const r = probeSpawnSync(process.execPath, [installJs], { stdio: "inherit" });', ); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3f3a0e2fd38..1a7bc64f880 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,4 @@ -{ "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", +{ "update-reclaim-authorizer.test.ts": "update", "update-capability-policy.test.ts": "update", "update-service-home.test.ts": "update", "update-service-home-policy.test.ts": "update", "update-home-recovery.test.ts": "update", "update-recovery-authority.test.ts": "update", "update-recovery-boundaries.test.ts": "update", "update-home-launcher.test.ts": "update", "update-home-worker.test.ts": "update", "update-mutation-boundaries.test.ts": "update", "update-mutation-guard.test.ts": "update", "update-guarded-ops.test.ts": "update", "update-effect-authority.test.ts": "update", "update-windows-wrapper-authority.test.ts": "update", "update-mutation-scanner.test.ts": "update", "refreshed-upstream-deadlines.test.ts": "responses", "local-upstream-deadlines.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "t3code-client.test.ts": "clients", "t3code-client-lifecycle.test.ts": "clients", "fix-jsonc-cr-comments.test.ts": "lib", diff --git a/tests/fixtures/update-mutation-guard.ts b/tests/fixtures/update-mutation-guard.ts new file mode 100644 index 00000000000..3a207b2b659 --- /dev/null +++ b/tests/fixtures/update-mutation-guard.ts @@ -0,0 +1,14 @@ +declare const authorizeMutation: (kind: string) => boolean; +declare const mutate: () => void; +declare const observe: () => boolean; +declare const wait: () => Promise; + +export function allowed() { if (!authorizeMutation("runtime")) return; mutate(); } +export async function staleAwait() { if (!authorizeMutation("runtime")) return; await wait(); mutate(); } +export function staleBlocking() { if (!authorizeMutation("runtime")) return; observe(); mutate(); } +export function wrongBranch() { if (!authorizeMutation("runtime")) mutate(); } +export function nestedClosure() { const later = () => authorizeMutation("runtime"); mutate(); return later; } +export function nonterminalDenial() { if (!authorizeMutation("runtime")) { if (observe()) return; } mutate(); } +export function unguardedSecondAction() { if (!authorizeMutation("runtime")) return; mutate(); mutate(); } +export async function awaitInCondition() { if (authorizeMutation("runtime") && await wait()) mutate(); } +export async function freshAfterAwait() { await wait(); if (!authorizeMutation("runtime")) return; mutate(); } diff --git a/tests/helpers/npm-operation.ts b/tests/helpers/npm-operation.ts new file mode 100644 index 00000000000..4076d7ec07c --- /dev/null +++ b/tests/helpers/npm-operation.ts @@ -0,0 +1,16 @@ +/** + * The npm operation a spawnSync call invokes: argv[0] of a direct invocation, or the + * first `^"arg"`-wrapped token of a `cmd /d /s /c` verbatim line. npmInvocation wraps + * every argument but never the executable, so a spaced install path — even one + * containing a bare `view` word — cannot forge an operation token, and an install's + * later `view`-named argument stays out of position 0. + */ +export function spawnedNpmOperation( + args: readonly string[], + options: { windowsVerbatimArguments?: boolean } = {}, +): string | undefined { + if (options.windowsVerbatimArguments && args.slice(0, 3).join(" ") === "/d /s /c") { + return /\^"(.*?)\^"/.exec(args[3] ?? "")?.[1]; + } + return args[0]; +} diff --git a/tests/helpers/update-bun-ownership-child.ts b/tests/helpers/update-bun-ownership-child.ts index fa8c53a9692..2b54f49f0c6 100644 --- a/tests/helpers/update-bun-ownership-child.ts +++ b/tests/helpers/update-bun-ownership-child.ts @@ -5,6 +5,7 @@ import * as processTools from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { spawnedNpmOperation } from "./npm-operation"; import { repoPath } from "./repo-root"; const realSpawnSync = processTools.spawnSync; @@ -15,7 +16,19 @@ 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; +let packageReplaced = false; +const delegatedHome = scenario.startsWith("delegated-"); +if (delegatedHome) { + running = false; + const baseline = scenario === "delegated-known-unknown" || scenario === "delegated-post-replacement" ? "same" + : scenario === "delegated-malformed" ? "invalid-shape" : "unknown"; + process.argv.push("--update-worker-home", baseline, "--update-worker-result"); +} 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 +69,18 @@ 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 === "delegated-post-replacement") return { kind: packageReplaced ? "unknown" : "same" }; + if (delegatedHome) return { kind: scenario === "delegated-unknown-known" ? "same" : "unknown" }; + if (scenario === "home-changed-after-stop" && !running) 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, @@ -109,8 +134,8 @@ mock.module("node:child_process", () => ({ ...processTools, Date.now = () => realNow() + clockOffset; return directChild; }, - spawnSync: (_bin: string, args: string[], options: { env?: NodeJS.ProcessEnv } = {}) => { - if (args.includes("view")) return { status: 0, stdout: "99.0.0\n", stderr: "" }; + spawnSync: (_bin: string, args: string[], options: { env?: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean } = {}) => { + if (spawnedNpmOperation(args, options) === "view") return { status: 0, stdout: "99.0.0\n", stderr: "" }; const env = options.env ?? process.env; const phase = args.includes("stop") ? "stop" : args.includes("service") ? "recovery" : args.some(arg => arg.includes("@bitkyc08/opencodex")) ? "replacement" : "ancillary"; @@ -123,9 +148,10 @@ mock.module("node:child_process", () => ({ ...processTools, if (scenario === "unknown-runtime") writeFileSync(runtime, "{malformed"); } if (phase === "replacement") { + packageReplaced = true; if (scenario === "replacement-throw") throw new Error("injected replacement failure"); if (scenario === "foreign-owner") writeFileSync(authority, JSON.stringify({ revision: 2, owner: "desktop", installId: "owner-b", consentGeneration: 2 })); - if (direct || scenario === "package-fail" || scenario === "foreign-owner" || scenario === "recovery-throw") return { status: 1 }; + if (direct || scenario === "home-unknown-fail" || scenario === "package-fail" || scenario === "foreign-owner" || scenario === "recovery-throw") return { status: 1 }; } if (phase === "recovery") { if (scenario === "recovery-throw") throw new Error("injected recovery failure"); diff --git a/tests/helpers/update-capability-policy.ts b/tests/helpers/update-capability-policy.ts new file mode 100644 index 00000000000..eab57a1509d --- /dev/null +++ b/tests/helpers/update-capability-policy.ts @@ -0,0 +1,227 @@ +// Best-effort regression lint over known acquisition patterns, not a security +// boundary or completeness proof. Runtime mutation guards enforce authority. +import * as ts from "typescript/unstable/ast"; +import { updateBoundaryRootSites, updateDeferredImports, updateStaticImports } from "./update-import-policy"; +import { updatePrimitiveAllowlist } from "./update-primitive-allowlist"; +import { updateProcessAllowlist } from "./update-process-allowlist"; + +// Known-pattern acquisition lint: unfamiliar runtime API names are flagged by default. +// Opening files is deliberately excluded: even a read-mode open returns a mutable +// FileHandle/fd. Ban acquisition rather than trying to follow every later alias. +const fsReads = new Set(["access", "accessSync", "constants", "existsSync", "lstat", "lstatSync", + "stat", "statSync", "fstat", "fstatSync", "readFile", "readFileSync", "readdir", "readdirSync", + "readlink", "readlinkSync", "realpath", "realpathSync"]); +const bunReads = new Set(["sleep", "sleepSync", "version", "revision", "which", "semver", "env"]); +const processReads = new Set(["env", "argv", "execPath", "platform", "arch", "pid", "ppid", "versions", + "version", "cwd", "getuid", "geteuid", "exitCode"]); +// These modules expose data/formatting/timing utilities, not runtime controls. +const utilityModules = new Set(["path", "path/posix", "path/win32", "url", "util", "util/types", + "buffer", "events", "assert", "assert/strict", "timers", "timers/promises", "string_decoder"]); +const readsFor = (module: string): Set | undefined => { + module = module.replace(/^node:/, ""); + if (module === "fs" || module === "fs/promises") return fsReads; + if (module === "bun") return bunReads; + if (module === "crypto") return new Set(["randomBytes", "randomUUID", "createHash"]); + if (module === "os") return new Set(["homedir", "tmpdir", "platform", "arch", "type", "release"]); + if (module === "tty") return new Set(["isatty"]); + // process imports are disallowed: the global view below is deliberately narrow. + return undefined; +}; +export function sensitiveModule(module: string): boolean { + // Unknown builtins and third-party capability providers also fail closed. + // Repository modules remain subject to the update-module/reference inventory. + return !module.startsWith("./") && !module.startsWith("../") + && !utilityModules.has(module.replace(/^node:/, "")); +} +const text = (node: ts.Node) => node.getText().replace(/\s+/g, " "); +const member = (node: ts.Node): { base: ts.Expression; name?: string } | undefined => { + if (ts.isPropertyAccessExpression(node)) return { base: node.expression, name: node.name.text }; + if (ts.isElementAccessExpression(node)) return { base: node.expression, + name: node.argumentExpression && ts.isStringLiteral(node.argumentExpression) ? node.argumentExpression.text : undefined }; +}; +const unwrap = (node: ts.Expression): ts.Expression => ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node) + || ts.isAsExpression(node) ? unwrap(node.expression) : node; + +export function capabilityPolicy(source: ts.SourceFile, file = "") { + const context = (node: ts.Node) => { + for (let p = node.parent; p; p = p.parent) if (ts.isFunctionDeclaration(p) && p.name) return p.name.text; + return ""; + }; + const deferredKey = (node: ts.Node) => { + let parent = node; + while (parent.parent && !ts.isVariableDeclaration(parent) && !ts.isBinaryExpression(parent)) parent = parent.parent; + return file + ":" + context(node) + ":" + text(parent); + }; + const exactSites = new Set([...updatePrimitiveAllowlist, ...updateProcessAllowlist].map(([key]) => key)); + const exactSite = (node: ts.Node) => exactSites.has(file + ":" + context(node) + ":" + text(node)); + const boundaryRootCounts = new Map(); + const trustedRootSite = (node: ts.Node) => { + const key = file + ":" + context(node) + ":" + text(node); + return updateBoundaryRootSites.has(key) && boundaryRootCounts.get(key) === 1; + }; + const deferredCounts = new Map(); + for (const key of updateDeferredImports) deferredCounts.set(key, (deferredCounts.get(key) ?? 0) + 1); + const actualDeferred = new Map(); + const functions: ts.FunctionDeclaration[] = []; + const collect = (node: ts.Node) => { + const rootKey = file + ":" + context(node) + ":" + text(node); + if (updateBoundaryRootSites.has(rootKey)) boundaryRootCounts.set(rootKey, (boundaryRootCounts.get(rootKey) ?? 0) + 1); + if (ts.isFunctionDeclaration(node) && node.name?.text === "bunBinDir") functions.push(node); + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const key = deferredKey(node); actualDeferred.set(key, (actualDeferred.get(key) ?? 0) + 1); + } + node.forEachChild(collect); + }; collect(source); + const resolver = file === "bin/ocx.mjs" && functions.length === 1 ? functions[0] : undefined; + const resolverBody = resolver?.body?.statements; + const confinedResolver = resolver?.parameters.length === 1 && text(resolver.parameters[0]) === "launcherPath" + && resolverBody?.length === 2 + && text(resolverBody[0]) === 'const resolver = createRequire(launcherPath || import.meta.url);' + && text(resolverBody[1]) === 'return dirname(resolver.resolve("bun/package.json"));'; + const inResolver = (node: ts.Node) => { + for (let p: ts.Node | undefined = node; p; p = p.parent) if (p === resolver) return !!confinedResolver; + return false; + }; + const forbiddenRoots = new Set(["require", "module", "exports", "createRequire", "globalThis", "global", "window", "self", + "eval", "Function", "Reflect", "Proxy", "WebAssembly", "WebSocket", "Worker", "SharedWorker", "EventSource", "fetch"]); + const isName = (node: ts.Identifier) => { + const p = node.parent; + return ts.isPropertyAccessExpression(p) && p.name === node || ts.isPropertyAssignment(p) && p.name === node && !ts.isComputedPropertyName(p.name) + || ts.isImportSpecifier(p) || ts.isImportClause(p) || ts.isNamespaceImport(p) + || ts.isBindingElement(p) && p.propertyName === node; + }; + const bindings = new Map(); + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly) continue; + const module = (statement.moduleSpecifier as ts.StringLiteral).text; + if (!sensitiveModule(module) && updateStaticImports[file]?.includes(text(statement))) continue; + const clause = statement.importClause; + if (clause.name) bindings.set(clause.name.text, { module }); + const named = clause.namedBindings; + if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { module }); + if (named && ts.isNamedImports(named)) for (const binding of named.elements) + if (!binding.isTypeOnly) bindings.set(binding.name.text, { module, name: (binding.propertyName ?? binding.name).text }); + } + const safeImport = (module: string, name?: string) => name !== undefined && !!readsFor(module)?.has(name); + // Propagate forbidden acquisition identity through declarations, assignments, + // destructuring and call results. The root itself is rejected even if unused; + // this propagation also makes the inventory report downstream loader aliases. + const tainted = new Set(); + const rootViolation = (node: ts.Node): boolean => { + if (ts.isMetaProperty(node)) { + const p = node.parent; + return !(ts.isPropertyAccessExpression(p) && p.expression === node + && (p.name.text === "url" || file === "src/update/service-home-probe-entry.ts" && p.name.text === "main")); + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const key = deferredKey(node); + return !deferredCounts.has(key) || actualDeferred.get(key)! > deferredCounts.get(key)!; + } + if (!ts.isIdentifier(node) || isName(node)) return false; + if (node.text === "createRequire" || node.text === "resolver") return !inResolver(node); + if (forbiddenRoots.has(node.text) || tainted.has(node.text)) return true; + if (["process", "Bun", "fs"].includes(node.text)) { + const p = node.parent; + if (ts.isTypeOfExpression(p)) return false; + if (!ts.isPropertyAccessExpression(p) || p.expression !== node) return true; + const reads = node.text === "process" ? processReads : node.text === "Bun" ? bunReads : fsReads; + if (node.text === "process" && ["stdout", "stderr", "stdin"].includes(p.name.text)) { + const outer = member(p.parent); + if (outer?.base === p && ["isTTY", "fd", "columns", "rows"].includes(outer.name ?? "")) return false; + } + return !reads.has(p.name.text); + } + return false; + }; + const hasRoot = (node: ts.Node): boolean => !exactSite(node) && !trustedRootSite(node) + && (rootViolation(node) || node.forEachChild(hasRoot) === true); + const bindNames = (node: ts.Node) => { + if (ts.isIdentifier(node)) { const size = tainted.size; tainted.add(node.text); return size !== tainted.size; } + let changed = false; + node.forEachChild(child => { if (bindNames(child)) changed = true; }); return changed; + }; + for (let changed = true; changed;) { + changed = false; + const visit = (node: ts.Node) => { + if (ts.isVariableDeclaration(node) && node.initializer && hasRoot(node.initializer)) changed = bindNames(node.name) || changed; + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && hasRoot(node.right)) changed = bindNames(node.left) || changed; + node.forEachChild(visit); + }; visit(source); + } + const aliases: ts.VariableDeclaration[] = []; + const findAliases = (node: ts.Node) => { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) aliases.push(node); node.forEachChild(findAliases); }; + findAliases(source); + for (let changed = true; changed;) { + changed = false; + for (const declaration of aliases) { + const name = (declaration.name as ts.Identifier).text; + if (bindings.has(name)) continue; + let origin: { module: string; name?: string } | undefined; + const inspect = (node: ts.Node) => { + if (ts.isCallExpression(node)) return; + if (ts.isIdentifier(node)) { const value = bindings.get(node.text); if (value && !safeImport(value.module, value.name)) origin = value; } + node.forEachChild(inspect); + }; + inspect(declaration.initializer!); + if (origin) { bindings.set(name, origin); changed = true; } + } + } + const reason = (node: ts.Node): string | undefined => { + if (ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly || ts.isExportDeclaration(node) && node.moduleSpecifier && !node.isTypeOnly) { + const specifier = (node.moduleSpecifier as ts.StringLiteral).text; + const clause = ts.isImportDeclaration(node) ? node.importClause : undefined; + const named = clause?.namedBindings; + const readonlyImport = readsFor(specifier) && !clause?.name && named && ts.isNamedImports(named) + && named.elements.every(b => b.isTypeOnly || safeImport(specifier, (b.propertyName ?? b.name).text)); + if (!readonlyImport && !updateStaticImports[file]?.includes(text(node))) return "unreviewed static dependency or binding"; + } + if (rootViolation(node)) return "forbidden capability root/acquisition/escape"; + if (ts.isImportDeclaration(node) && !node.importClause && sensitiveModule((node.moduleSpecifier as ts.StringLiteral).text)) + return "unapproved capability side-effect import"; + if (ts.isImportDeclaration(node) && node.importClause && !node.importClause.isTypeOnly) { + const module = (node.moduleSpecifier as ts.StringLiteral).text, clause = node.importClause; + if (sensitiveModule(module) && (clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings) + || clause.namedBindings.elements.some(b => !b.isTypeOnly && !safeImport(module, (b.propertyName ?? b.name).text)))) + return "unapproved capability import"; + } + if (ts.isExportDeclaration(node) && node.moduleSpecifier && !node.isTypeOnly + && sensitiveModule((node.moduleSpecifier as ts.StringLiteral).text)) return "capability re-export"; + if (ts.isImportEqualsDeclaration(node)) return "capability require binding"; + if (ts.isCallExpression(node) && ["require", "import"].includes(text(node.expression))) { + const target = node.arguments[0]; + if (!target || !ts.isStringLiteral(target) || sensitiveModule(target.text)) return "unapproved capability module load"; + } + const access = member(node); + if (access) { + const base = unwrap(access.base), root = text(base); + if ((root === "globalThis" || root === "global" || root === "window" || root === "self") + && (!access.name || !["setTimeout", "clearTimeout", "setInterval", "clearInterval"].includes(access.name))) + return "unapproved global capability"; + const allowed = root === "Bun" ? bunReads : root === "process" ? processReads + : root === "fs" ? fsReads : undefined; + if (allowed && (!access.name || !allowed.has(access.name))) { + // Standard stream metadata is inert; writes still need an exact exemption. + if (root === "process" && ["stdout", "stderr", "stdin"].includes(access.name ?? "")) { + const outer = member(node.parent); + if (outer && outer.base === node && ["isTTY", "fd", "columns", "rows"].includes(outer.name ?? "")) return; + } + return "unapproved runtime capability"; + } + } + if (ts.isIdentifier(node)) { + if (inResolver(node) && ["createRequire", "resolver"].includes(node.text)) return; + const parent = node.parent; + // Names are not references, but shorthand properties and destructuring RHSs are. + if (ts.isPropertyAccessExpression(parent) && parent.name === node + || ts.isPropertyAssignment(parent) && parent.name === node + || ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent)) return; + const imported = bindings.get(node.text); + if (imported && !safeImport(imported.module, imported.name)) return "raw imported capability reference"; + if (["Bun", "process", "fs", "globalThis", "global", "window", "self"].includes(node.text) && !(member(parent)?.base === node) + && !ts.isTypeOfExpression(parent)) return "runtime capability object escape"; + if (["WebSocket", "Worker", "SharedWorker", "EventSource", "fetch", "eval", "Function"].includes(node.text)) + return "unapproved runtime constructor/evaluator"; + } + }; + return { reason, rootViolation, trustedRootSite }; +} diff --git a/tests/helpers/update-guarded-io.mjs b/tests/helpers/update-guarded-io.mjs new file mode 100644 index 00000000000..f9cf329a9b9 --- /dev/null +++ b/tests/helpers/update-guarded-io.mjs @@ -0,0 +1,36 @@ +import { guardedRun, UpdateMutationRefusedError } from "../../src/update/mutation-guard.mjs"; + +/** Run the real materialization/authorization boundary with external primitive I/O + * injected by source-body tests. An absent seam throws; it never reaches the host. */ +export function guardedUpdateIo(deps) { + const native = name => deps[name] ?? (() => { throw new Error(`Missing update I/O seam: ${name}`); }); + return { + prepareUpdateJob: (job, patch, line) => ({ ...job, ...patch, log: line ? [...(job.log ?? []), line] : job.log }), + writeJob: (job, auth, kind = "publish") => job.status === "succeeded" + ? guardedRun(auth ?? (() => false), kind, deps.writeJob ?? (job => native("updateJob")(job, job)), [job]) + : (deps.writeJob ?? (job => native("updateJob")(job, job)))(job), + probeSpawn: native("spawn"), probeSpawnSync: native("spawnSync"), + probeProcessAlive: pid => deps.process.kill(pid, 0), + relayOwnSignal: signal => deps.process.kill(deps.process.pid, signal), + guardedRun, + guardedPublishJob: (auth, kind, target, text) => guardedRun(auth, kind, native("atomicWriteFile"), [target, text]), + UpdateMutationRefusedError, + guardedExit: (auth, kind, code) => guardedRun(auth, kind, deps.process.exit.bind(deps.process), [code]), + guardedSpawn: (auth, kind, ...args) => guardedRun(auth, kind, native("spawn"), args), + guardedSpawnSync: (auth, kind, ...args) => guardedRun(auth, kind, native("spawnSync"), args), + guardedKillProxy: (auth, kind, pid, override) => guardedRun(auth, kind, override ?? native("killProxy"), [pid]), + guardedRemovePid: (auth, ...args) => guardedRun(auth, "runtime", native("removePid"), args), + guardedRemoveRuntimePort: auth => guardedRun(auth, "runtime", native("removeRuntimePort"), []), + guardedDropWindowsTcp: (auth, ...args) => guardedRun(auth, "runtime", native("dropWindowsTcpRowsForLocalPort"), args), + guardedStopWinsw: auth => guardedRun(auth, "service", native("stopWinswService"), []), + guardedStopWindows: auth => guardedRun(auth, "service", native("stopWindows"), []), + guardedKillWindowsWrappers: (auth, ...args) => guardedRun(auth, "service", native("killWindowsSchedulerWrappers"), args), + }; +} + +export function guardedTrayIo(tray) { + return { ...tray, + guardedStopWindowsTray: auth => guardedRun(auth, "tray", tray.stopWindowsTray, []), + guardedStartWindowsTray: auth => guardedRun(auth, "own-tray", tray.startWindowsTray, []), + }; +} diff --git a/tests/helpers/update-helper-control-contracts.ts b/tests/helpers/update-helper-control-contracts.ts new file mode 100644 index 00000000000..d1a53a593d4 --- /dev/null +++ b/tests/helpers/update-helper-control-contracts.ts @@ -0,0 +1,102 @@ +// Best-effort known-pattern inventory, not a security boundary or completeness proof. +// Runtime mutation guards enforce authority and freshness. +// Probe, private-artifact cleanup and committed-swap rollback rows confer no new runtime authority. +type Contract = readonly [file: string, context: string, call: string, count: number, contract: string]; +export const helperControlContracts: readonly Contract[] = [ + ["src/update/notify.ts","maybeShowUpdatePrompt","process.exit(0)",1,"terminal prompt completes after ordinary runUpdate; no new update success publication"], + ["src/update/service-home-probe-entry.ts","","process.exit(1)",1,"reject malformed bounded probe argv"], + ["src/update/notify.ts","maybeShowUpdatePrompt","createInterface({ input: process.stdin, output: process.stdout })",1,"ordinary terminal choice prompt, no update control"], + ["src/update/npm-cache-preflight.mjs","","process.stdout.write(JSON.stringify({ protocol: PROTOCOL_VERSION, ...result }))",1,"bounded private worker protocol only"], + ["src/update/npm-install.mjs","runNpmInstall","process.stderr.write(chunk)",1,"forward own staging installer's diagnostic stream"], + ["src/update/pnpm-read-policy.mjs","withPnpmCommandCwd","mkdtempSync(join(tmpdir(), \"ocx-pnpm-command-\"))",1,"exclusive disposable pnpm read-policy cwd"], + ["src/update/pnpm-read-policy.mjs","withPnpmCommandCwd","rmdirSync(cwd)",1,"remove only identity-checked empty private command cwd"], + ["src/update/transactional-install.mjs","createOwnedStage","mkdir(stageRoot)",1,"exclusive randomly named private stage, never reuse an existing directory"], + ["src/update/transactional-install.mjs","createOwnedStage","mkdir(join(stageRoot, \"lib\"))",1,"npm layout inside the exclusively owned stage"], + ["src/update/transactional-install.mjs","npmUpdateTransaction","mkdirSync(backupRoot, { recursive: true })",1,"empty backup container; live swap separately guarded"], + ["src/lib/bun-path-runtime.mjs","findPathBun","spawn(path, args,",1,"read-only bounded process/registration/registry probe"], + ["src/lib/desktop-cli-handoff.mjs","runDesktopCliHandoff","spawnChild(plan.target,",1,"ordinary CLI handoff excludes update before child selection"], + ["src/lib/desktop-cli-handoff.mjs","runDesktopCliHandoff","child.kill(signal)",1,"ordinary handoff signal relay to its exact child"], + ["src/lib/desktop-cli-record.mjs","noMacAcl","spawnSync(\"/bin/ls\",",1,"read-only bounded process/registration/registry probe"], + ["src/service/desktop-supervision.mjs","run","execFileSync(command, args,",1,"read-only bounded process/registration/registry probe"], + ["src/service/ownership-mutation-lease.mjs","processAlive","process.kill(pid, 0)",1,"read-only bounded process/registration/registry probe"], + ["src/service/ownership-mutation-lease.mjs","processImage","spawnSync(\"ps\",",1,"read-only bounded process/registration/registry probe"], + ["src/service/ownership-mutation-lease.mjs","reclaim","unlinkSync(ownerPath)",1,"only the verified dead or exact held private ownership lock; never runtime/service control"], + ["src/service/ownership-mutation-lease.mjs","acquireOwnershipMutationLease","writeFileSync(descriptor,",1,"only the verified dead or exact held private ownership lock; never runtime/service control"], + ["src/service/ownership-mutation-lease.mjs","acquireOwnershipMutationLease","unlinkSync(ownerPath)",1,"only the verified dead or exact held private ownership lock; never runtime/service control"], + ["src/service/ownership-mutation-lease.mjs","release","unlinkSync(currentHeld.snapshot.ownerPath)",1,"only the verified dead or exact held private ownership lock; never runtime/service control"], + ["src/service/ownership-mutation-lease.mjs","withOwnershipMutationLease","run()",1,"lease callback; guarded caller owns actual update controls"], + ["src/update/async-check.ts","latestVersionAsync","deps.spawnFn(target.bin, target.args,",1,"read-only bounded process/registration/registry probe"], + ["src/update/async-check.ts","latestVersionAsync","child.kill()",2,"terminate only this function's exact disposable probe child"], + ["src/update/bind-probe.ts","spawnBindProbe","probeSpawnSync(bin, [\"-e\", script],",1,"read-only bounded process/registration/registry probe"], + ["src/update/notify.ts","triggerBackgroundRefreshIfStale","probeSpawn(process.execPath, args,",1,"version-cache refresh child; no package/runtime/service controls"], + ["src/update/npm-cache-preflight.mjs","runPreflightWorker","probeSpawn( options.execPath ?? process.execPath, workerArgs,",1,"private cache preflight worker, no live package/runtime mutation"], + ["src/update/npm-install.mjs","runNpmInstall","guardedSpawn(authorizeMutation, \"package\", supervisor,",1,"single package dispatch from guarded bin/ocx.mjs callback; live swap separately reauthorized"], + ["src/update/npm-install.mjs","runNpmInstall","child.kill()",2,"terminate only own npm supervisor; unconfirmed tree retains stage"], + ["src/update/npm-install-job.ps1","native installer job","RequireAttribute(CreateProcess(file,",1,"create staging installer atomically in its own Job Object, no live swap"], + ["src/update/npm-install-job.ps1","native installer job","Require(TerminateJobObject(job, 1));",1,"terminate only this installer's owned job and join before stage removal"], + ["src/update/npm-install-job.ps1","native installer job","TerminateJobObject(job, 1);",1,"error cleanup only this installer's owned job, never discovered processes"], + ["src/update/ownership-transaction.ts","withUpdateOwnershipLease","run({",1,"lease callback; guarded caller owns actual update controls"], + ["src/update/pnpm-global-install.mjs","singleAbsoluteCommandPath","runPnpm(commandPath, args, true)",1,"read-only bounded process/registration/registry probe"], + ["src/update/pnpm-global-install.mjs","readPnpmGlobalPackage","runPnpm(ownerGlobalArgs([\"list\",",1,"read-only bounded process/registration/registry probe"], + ["src/update/pnpm-global-install.mjs","listGlobalPackage","runPnpm(commandPath, [\"list\",",1,"read-only bounded process/registration/registry probe"], + ["src/update/pnpm-global-install.mjs","resolvePnpmGlobalOwner","runPnpm(commandPath, args, capture)",1,"read-only bounded process/registration/registry probe"], + ["src/update/pnpm-global-install.mjs","runPnpmGlobalUpdate","runPnpm(ownerGlobalArgs([\"add\",",2,"callback reauthorizes package immediately before spawn in bin/ocx.mjs or runOwnedPnpm"], + ["src/update/pnpm-read-policy.mjs","withPnpmCommandCwd","run(PNPM_READ_CWD)",1,"callback reauthorizes package immediately before spawn in bin/ocx.mjs or runOwnedPnpm"], + ["src/update/pnpm-read-policy.mjs","withPnpmCommandCwd","run(cwd)",1,"callback reauthorizes package immediately before spawn in bin/ocx.mjs or runOwnedPnpm"], + ["src/update/pnpm-read-policy.mjs","withPnpmCommandCwd","writeFileSync(join(cwd, files[",2,"exclusive private read-policy files in own temporary command cwd"], + ["src/update/pnpm-read-policy.mjs","withPnpmCommandCwd","ownedArtifactUnlink(join(cwd, name))",1,"remove only this command's private read-policy files"], + ["src/update/proxy-liveness-probe.mjs","probeProxyLiveness","probeSpawnSync( process.execPath,",1,"read-only bounded process/registration/registry probe"], + ["src/update/registry-integrity.mjs","checkRegistryPackageIntegrity","run([\"view\",",1,"read-only bounded process/registration/registry probe"], + ["src/update/service-home-probe.mjs","probeUpdateServiceHome","run(bin, [...prefix, ...UPDATE_HOME_PROBE_ARGS],",1,"read-only bounded process/registration/registry probe"], + ["src/update/service-home-probe.mjs","probeUpdatePackageOnlyLauncher","run(node, [launcher, \"__update-package-only-probe\"],",1,"read-only bounded process/registration/registry probe"], + ["src/update/supervised-recovery.mjs","defaultRun","guardedSpawnSync(authorizeMutation, \"service\", file, args,",1,"one synchronous manager dispatch; restart callers have fresh service guards, other args are probes"], + ["src/update/supervised-recovery.mjs","restartServiceManagerJob","guardedRun(authorizeMutation, \"service\", run, [\"/bin/launchctl\",",1,"single synchronous service dispatch, no blocking observation after caller guard"], + ["src/update/supervised-recovery.mjs","restartServiceManagerJob","guardedRun(authorizeMutation, \"service\", run, [\"systemctl\",",1,"single synchronous service dispatch, no blocking observation after caller guard"], + ["src/update/supervised-recovery.mjs","readServiceManagerPid","run(\"/bin/launchctl\", [\"print\",",1,"read-only bounded process/registration/registry probe"], + ["src/update/supervised-recovery.mjs","readServiceManagerPid","run(\"systemctl\", [\"--user\", \"show\",",1,"read-only bounded process/registration/registry probe"], + ["src/update/supervised-recovery.mjs","readParentPid","run(\"ps\",",1,"read-only bounded process/registration/registry probe"], + ["src/update/transactional-install.mjs","createOwnedStage","writeFileSync(join(stageRoot, UPDATE_OWNER_MARKER),",1,"only this transaction's owned, exited staging tree/marker"], + ["src/update/transactional-install.mjs","createOwnedStage","ownedArtifactRm(stageRoot,",1,"only this transaction's owned, exited staging tree/marker"], + ["src/update/transactional-install.mjs","removeWithRetry","ownedArtifactRm(target,",1,"only this transaction's owned, exited staging tree/marker"], + ["src/update/transactional-install.mjs","renameWithRetry","guardedRenameSync(authorize, from, to, rename)",1,"synchronous package swap or bounded rollback after mayReplacePackage, never runtime control"], + ["src/update/transactional-install.mjs","bootRestoreProbe","ownedArtifactRm(join(scopeDir, name),",1,"ordinary launcher recovery: only dead-owner verified stages"], + ["src/update/transactional-install.mjs","bootRestoreProbe","ownedArtifactRm(recoveryMarkerPath(scopeDir),",1,"ordinary launcher recovery: settled marker after verified live tree"], + ["src/update/transactional-install.mjs","bootRestoreProbe","ownedArtifactRm(packageDir,",1,"ordinary launcher recovery: restore verified backup for broken live tree"], + ["src/update/transactional-install.mjs","bootRestoreProbe","guardedRenameSync(() => true, newestBackup, packageDir, rename)",1,"ordinary launcher recovery: restore verified backup for broken live tree"], + ["src/update/transactional-install.mjs","npmUpdateTransaction","renameWithRetry(rename, packageDir, backupPackage,",1,"synchronous package swap or bounded rollback after mayReplacePackage, never runtime control"], + ["src/update/transactional-install.mjs","npmUpdateTransaction","renameWithRetry(rename, stagedPackage, packageDir)",1,"synchronous package swap or bounded rollback after mayReplacePackage, never runtime control"], + ["src/update/transactional-install.mjs","npmUpdateTransaction","renameWithRetry(rename, backupPackage, packageDir)",2,"synchronous package swap or bounded rollback after mayReplacePackage, never runtime control"], + ["src/update/transactional-install.mjs","npmUpdateTransaction","renameWithRetry(rename, packageDir, join(stageRoot, \"rejected\"))",1,"synchronous package swap or bounded rollback after mayReplacePackage, never runtime control"], + ["src/update/transactional-install.mjs","npmUpdateTransaction","ownedArtifactRm(backupRoot,",3,"synchronous package swap or bounded rollback after mayReplacePackage, never runtime control"], + ["src/update/transactional-install.mjs","npmUpdateTransaction","writeFileSync(recoveryMarkerPath(scopeDir),",2,"synchronous package swap or bounded rollback after mayReplacePackage, never runtime control"], + ["src/update/tray-update-plan.mjs","handoffWindowsTrayForUpdate","io.stop()",1,"callback authorizes tray after import immediately before actual stop"], + ["src/update/tray-update-plan.mjs","handoffWindowsTrayForUpdate","io.start()",1,"callback authorizes bounded own-tray restoration after attempted stop"], + ["src/update/worker-launch.ts","","probeSpawnSync(path, [...SYSTEMD_SCOPE_ARGS,",1,"read-only bounded process/registration/registry probe"], + ["src/update/worker-launch.ts","","probeSpawn(path, [...SYSTEMD_SCOPE_ARGS,",1,"read-only bounded process/registration/registry probe"], + ["src/update/worker-launch.ts","","probe.kill(\"SIGKILL\")",1,"terminate only this function's exact disposable probe child"], + ["src/update/guarded-runtime-ops.ts","guardedKillProxy","guardedRun(authorize, kind, native, [pid])",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/guarded-runtime-ops.ts","guardedRemovePid","removePid(materializeMutationData(pid),",1,"same runtime gate immediately before prepared unlink"], + ["src/update/guarded-runtime-ops.ts","guardedRemoveRuntimePort","removeRuntimePort(undefined,",1,"same runtime gate immediately before record and registry unlink"], + ["src/update/guarded-runtime-ops.ts","guardedDropWindowsTcp","dropWindowsTcpRowsForLocalPort(target,",1,"same runtime gate immediately before each prepared SetTcpEntry"], + ["src/update/guarded-runtime-ops.ts","guardedStopWinsw","stopWinswService(mutationGate(",1,"same service gate at prepared native command"], + ["src/update/guarded-runtime-ops.ts","guardedStopWindows","stopWindows(mutationGate(",1,"same service gate at prepared scheduler command"], + ["src/update/guarded-runtime-ops.ts","guardedKillWindowsWrappers","killWindowsSchedulerWrappers(materializeMutationData(paths),",1,"same service gate after path/script preparation"], + ["src/update/guarded-tray-ops.ts","guardedStartWindowsTray","guardedRun(authorize, \"own-tray\", startWindowsTray, [])",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/guarded-tray-ops.ts","guardedStopWindowsTray","stopWindowsTray(()",1,"same tray gate after heartbeat and command preparation"], + ["src/update/mutation-guard.mjs","guardedSpawn","guardedRun(authorize, kind, spawn, [file, args, options])",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","guardedSpawnSync","guardedRun(authorize, kind, native, [file, args, options])",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","guardedExecFile","execFile(...data, callback)",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","guardedExecFileSync","guardedRun(authorize, kind, execFileSync, [file, args, options])",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","guardedKill","guardedRun(authorize, kind, process.kill.bind(process), [pid, signal])",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","guardedRenameSync","guardedRun(authorize, \"package\", native, [from, to])",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","ownedArtifactUnlink","unlinkSync(materializeMutationData(target))",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","probeProcessAlive","process.kill(materializeMutationData(pid), 0)",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/mutation-guard.mjs","relayOwnSignal","process.kill(process.pid, materializeMutationData(signal))",1,"materialize plain data then synchronous authorization and primitive dispatch"], + ["src/update/npm-cache-preflight.mjs","resolveNpmCachePath","(options.spawnSyncFn ?? probeSpawnSync)(invocation.file,",1,"bounded npm config read"], + ["src/update/mutation-guard.mjs","guardedExit","guardedRun(authorize, kind, process.exit.bind(process), [code])",1,"materialize status then authorize terminal publication"], + ["src/update/guarded-runtime-ops.ts","guardedKillProxy","guardedExecFileSync(authorize, kind, taskkill,",1,"fresh authorization after Windows liveness/path preparation"], + ["src/update/guarded-runtime-ops.ts","guardedKillProxy","guardedKill(authorize, kind, target,",2,"reauthorize each POSIX signal including escalation after the grace wait"], + ["src/update/guarded-runtime-ops.ts","guardedPublishJob","native(path, payload, undefined,",1,"atomic writer validates home immediately before publication and each rename retry"], + ["src/update/mutation-guard.mjs","guardedRun","operation(...data)",1,"materialized data then authorizer then direct primitive dispatch"], + ["src/update/mutation-guard.mjs","ownedArtifactRm","native(...data)",1,"bounded private transaction artifact cleanup"] +]; diff --git a/tests/helpers/update-helper-inventory.ts b/tests/helpers/update-helper-inventory.ts new file mode 100644 index 00000000000..86749aaf8c6 --- /dev/null +++ b/tests/helpers/update-helper-inventory.ts @@ -0,0 +1,78 @@ +// Best-effort inventory/lint of known patterns, not a security boundary or +// completeness proof. Runtime mutation guards enforce update authority. +import { readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import * as ts from "typescript/unstable/ast"; +import { API } from "typescript/unstable/sync"; +import { repoPath, repoRoot } from "./repo-root"; +import { compact, files, rawMutationViolations, updateModuleFiles } from "./update-mutation-scanner"; +import { capabilityPolicy } from "./update-capability-policy"; + +// Share the known acquisition patterns with the auxiliary scanner. +export function capabilityControlCalls(source: ts.SourceFile, file = ""): ts.CallExpression[] { + const policy = capabilityPolicy(source, file), calls: ts.CallExpression[] = []; + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node)) { + let sensitive = false; + const inspect = (part: ts.Node) => { + if (ts.isArrowFunction(part) || ts.isFunctionExpression(part)) return; + if (policy.reason(part)) sensitive = true; + part.forEachChild(inspect); + }; + inspect(node.expression); + for (const argument of node.arguments) inspect(argument); + if (policy.reason(node)) sensitive = true; + if (sensitive) calls.push(node); + } + node.forEachChild(visit); + }; + visit(source); return calls; +} + +export function helperControlInventory(parser?: API) { + const launcher = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); + const imports = [...launcher.matchAll(/from\s+"(\.\.\/src\/[^\"]+)"/g)].map(m => + relative(repoRoot(), resolve(dirname(repoPath("bin/ocx.mjs")), m[1])).replaceAll("\\", "/")); + const helpers = [...new Set([ + ...updateModuleFiles(), + ...imports, + ])].filter(f => !files.includes(f)).sort(); + const nativeHelper = "src/update/npm-install-job.ps1"; + const policyFiles = ["bin/ocx.mjs", ...updateModuleFiles()]; + const api = parser ?? new API(), snapshot = api.updateSnapshot({ openFiles: [...new Set([...helpers, ...policyFiles])].map(f => repoPath(f)) }); + const policyViolations: string[] = []; + const sites: { file: string; context: string; call: string; line: number }[] = []; + try { + // Include launcher/primary modules as well as helper dispatch sites. A root + // escape or unreviewed import is inventoried even when it makes no call yet. + for (const file of policyFiles) { + const source = snapshot.getDefaultProjectForFile(repoPath(file))!.program.getSourceFile(repoPath(file))!; + policyViolations.push(...rawMutationViolations(source, file).map(reason => file + ": " + reason)); + } + for (const file of helpers) { + const source = snapshot.getDefaultProjectForFile(repoPath(file))!.program.getSourceFile(repoPath(file))!; + const capabilityCalls = new Set(file.startsWith("src/update/") && !/\/(?:mutation-guard\.mjs|guarded-runtime-ops\.ts|guarded-tray-ops\.ts)$/.test(file) + ? capabilityControlCalls(source, file) : []); + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && !["spawnFailure", "registrySpawnTarget"].includes(compact(node.expression)) + && !(ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "trim") + && (capabilityCalls.has(node) || /[sS]pawn|execFile|guardedRun|guardedRename|guardedKill|guardedExecFile|ownedArtifact|^native$|^operation$|(?:^|\.)kill$|^run$|^runPnpm$|^io\.(?:stop|start)$|^(?:removePid|removeRuntimePort|dropWindowsTcpRowsForLocalPort|stopWinswService|stopWindows|killWindowsSchedulerWrappers|stopWindowsTray)$|(?:^|\.)(?:rename(?:WithRetry)?|rm|unlink|writeFile|copyFile)(?:Sync)?$/.test(compact(node.expression)))) { + let context = ""; + for (let p: ts.Node | undefined = node.parent; p; p = p.parent) { + if (ts.isFunctionDeclaration(p) && p.name) { context = p.name.text; break; } + } + sites.push({ file, context, call: compact(node), line: source.getLineAndCharacterOfPosition(node.getStart()).line + 1 }); + } + node.forEachChild(visit); + }; + visit(source); + } + } finally { snapshot.dispose(); if (!parser) api.close(); } + // The launcher's Windows staging supervisor embeds C#, outside the TS parser. + // Only executable call lines count; native declarations confer no authority. + readFileSync(repoPath(nativeHelper), "utf8").split(/\r?\n/).forEach((line, index) => { + if (!line.includes("extern ") && /\b(?:CreateProcess\w*|Terminate\w*)\s*\(|\b(?:Start|Stop)-Process\b|\b(?:Remove|Move|Copy)-Item\b/.test(line)) + sites.push({ file: nativeHelper, context: "native installer job", call: line.trim(), line: index + 1 }); + }); + return { helpers: [...helpers, nativeHelper], sites, policyViolations }; +} diff --git a/tests/helpers/update-home-fixtures.ts b/tests/helpers/update-home-fixtures.ts new file mode 100644 index 00000000000..0bc3e81edc0 --- /dev/null +++ b/tests/helpers/update-home-fixtures.ts @@ -0,0 +1,14 @@ +import type { RestartIo } from "../../src/update/job"; + +/** Non-home restart tests explicitly own their fake service and never touch a live manager. */ +export const sameHomeRestartIo: Pick = { + serviceHomeAuthorityFn: () => ({ kind: "same" }), + stopServiceWrappersFn: () => {}, + // These fixtures exercise direct fallback, not host-dependent launchd/systemd probes. + // Preserve the real manager boundary's lease release before the fallback reacquires it. + restartServiceManagerFn: async target => { target.release?.(); return "failed"; }, +}; +export const absentHomeRestartIo: typeof sameHomeRestartIo = { + ...sameHomeRestartIo, + serviceHomeAuthorityFn: () => ({ kind: "absent" }), +}; diff --git a/tests/helpers/update-import-policy.ts b/tests/helpers/update-import-policy.ts new file mode 100644 index 00000000000..1fc2ea9e4fd --- /dev/null +++ b/tests/helpers/update-import-policy.ts @@ -0,0 +1,332 @@ +// Best-effort regression lint data for known dependency edges and bindings, not a +// security boundary or completeness proof. Runtime mutation guards enforce authority. +export const updateStaticImports: Readonly> = { + "bin/ocx.mjs": [ + "import { guardedRun, guardedSpawn, guardedSpawnSync, guardedExit, probeProcessAlive, relayOwnSignal } from \"../src/update/mutation-guard.mjs\";", + "import { probeSpawn, probeSpawnSync } from \"../src/update/mutation-guard.mjs\";", + "import { createSupervisionLatch, inspectDesktopSupervision } from \"../src/service/desktop-supervision.mjs\";", + "import { planDesktopCliHandoff, runDesktopCliHandoff } from \"../src/lib/desktop-cli-handoff.mjs\";", + "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, parseUpdateWorkerHome, UPDATE_HOME_LAUNCHER_PROTOCOL, UPDATE_PACKAGE_ONLY_EXIT_CODE, UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION } from \"../src/update/service-home-policy.mjs\";", + "import { probeUpdateServiceHome, supportsUpdateHomeProbe, updateHomeProbeEnvironment } from \"../src/update/service-home-probe.mjs\";", + "import { inspectPackageRuntimeLiveness, planStoppedRuntimeRecovery, planUpdateRuntimeHandling, } from \"../src/update/runtime-ownership.mjs\";", + "import { inspectInstallStateBytes, selectAuthoritativeServiceState, serviceStateFilesFor, } from \"../src/service/install-state-contract.mjs\";", + "import { acquireOwnershipMutationLease, ownershipMutationLeaseChildEnvironment, unprivilegedOwnershipMutationEnvironment, } from \"../src/service/ownership-mutation-lease.mjs\";", + "import { randomBytes } from \"node:crypto\";", + "import { createRequire } from \"node:module\";", + "import { existsSync, readFileSync, readdirSync } from \"node:fs\";", + "import { homedir } from \"node:os\";", + "import { dirname, join, resolve } from \"node:path\";", + "import { fileURLToPath } from \"node:url\";", + "import { isRealBunBinary } from \"../src/lib/bun-binary-validator.mjs\";", + "import { scopeTranspilerCacheToRuntime } from \"../src/lib/bun-transpiler-cache-scope.mjs\";", + "import { findDesktopCli, findPathBun } from \"../src/lib/bun-path-runtime.mjs\";", + "import { npmInvocation } from \"../src/update/npm-invocation.mjs\";", + "import { runNpmInstall } from \"../src/update/npm-install.mjs\";", + "import { pnpmInvocationForPath, resolvePnpmCommands } from \"../src/update/pnpm-invocation.mjs\";", + "import { detectInstallOwnershipFromPath } from \"../src/update/install-detection.mjs\";", + "import { pnpmOwnerInvocation, resolvePnpmGlobalOwner, runPnpmGlobalUpdate, } from \"../src/update/pnpm-global-install.mjs\";", + "import { PNPM_READ_CWD, withPnpmCommandCwd, pnpmReadEnvironment } from \"../src/update/pnpm-read-policy.mjs\";", + "import { checkRegistryPackageIntegrity } from \"../src/update/registry-integrity.mjs\";", + "import { hasPendingTeardownIn } from \"../src/config/pending-teardown-names.mjs\";", + "import { npmCachePreflightFailureMessage, resolveNpmCachePath, runNpmCachePreflight, } from \"../src/update/npm-cache-preflight.mjs\";", + "import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from \"../src/update/tray-update-plan.mjs\";", + "import { bootRestoreProbe, launcherUsableAfterNpmUpdate, transactionalNpmUpdate as transactionalNpmUpdateSync, transactionalNpmUpdateAsync } from \"../src/update/transactional-install.mjs\";", + "import { manualUpdateFailureGuidance, npmUpdateFailureGuidance } from \"../src/update/update-failure-guidance.mjs\";", + "import { captureCodexCliTransportEnv, CODEX_CLI_TRANSPORT_ENV_KEYS, CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS, codexCliInspectionLookupSeeds, carriesCodexCliUpdateInspectionContext, isCodexCliUpdateInspectionArgv, } from \"../src/update/codex-cli-update-launch-policy.mjs\";", + "import { freshServingPid, hasRestartableServiceManager, readParentPid, readServiceManagerPid, restartServiceManagerJob, supervisionVerdict, unsettledManagerWarning, unsupervisedRuntimeWarning, } from \"../src/update/supervised-recovery.mjs\";" + ], + "src/update/async-check.ts": [ + "import { probeSpawn } from \"./mutation-guard.mjs\";", + "import { unprivilegedOwnershipMutationEnvironment } from \"../service/ownership-mutation-lease.mjs\";", + "import { PKG, registrySpawnTarget, type Channel, type Installer } from \"./index\";", + "import { PNPM_READ_CWD, pnpmReadEnvironment } from \"./pnpm-read-policy.mjs\";" + ], + "src/update/badge.ts": [ + "import { currentVersion, defaultUpdateTag, detectInstall, type Channel } from \"./index\";", + "import { CACHE_MAX_AGE_MS, isNewer, isSourceBuildVersion, readVersionCache } from \"./notify\";" + ], + "src/update/bind-probe.ts": [ + "import { probeSpawnSync } from \"./mutation-guard.mjs\";" + ], + "src/update/check-types.ts": [], + "src/update/codex-cli-update-launch-policy.mjs": [], + "src/update/desktop-badge.ts": [ + "import { defaultUpdateTag } from \"./index\";" + ], + "src/update/guarded-port-reclaim.ts": [ + "import { reclaimListenPort, type ReclaimListenPortOptions } from \"../server/port-reclaim\";" + ], + "src/update/guarded-runtime-ops.ts": [ + "import { isProcessAlive, waitForExit } from \"../lib/process-control\";", + "import { atomicWriteFile } from \"../config/atomic-write\";", + "import { removePid, removeRuntimePort } from \"../config/process-state\";", + "import { dropWindowsTcpRowsForLocalPort } from \"../server/windows-tcp-drop\";", + "import { stopWinswService } from \"../lib/winsw\";", + "import { stopWindows } from \"../service\";", + "import { killWindowsSchedulerWrappers } from \"../lib/windows-service-wrappers\";", + "import { guardedRun, guardedKill, guardedExecFileSync, materializeMutationData, UpdateMutationRefusedError, type UpdateMutationAuthorizer } from \"./mutation-guard.mjs\";" + ], + "src/update/guarded-tray-ops.ts": [ + "import { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } from \"../tray/windows\";", + "import { guardedRun, UpdateMutationRefusedError, type UpdateMutationAuthorizer } from \"./mutation-guard.mjs\";" + ], + "src/update/index.ts": [ + "import { guardedRun, guardedSpawn, guardedSpawnSync } from \"./mutation-guard.mjs\";", + "import { probeSpawn, probeSpawnSync } from \"./mutation-guard.mjs\";", + "import { createSupervisionLatch, inspectDesktopSupervision } from \"../service/desktop-supervision.mjs\";", + "import { manualUpdateFailureGuidance } from \"./update-failure-guidance.mjs\";", + "import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from \"./stop-contract.mjs\";", + "import { proxyIdentityAt } from \"../server/proxy-liveness\";", + "import { probeProxyLiveness } from \"./proxy-liveness-probe.mjs\";", + "import { decidePostStopUpdate } from \"./stop-decision.mjs\";", + "import { existsSync, readFileSync, readdirSync } from \"node:fs\";", + "import { fileURLToPath } from \"node:url\";", + "import { dirname, join, resolve } from \"node:path\";", + "import { getConfigDir, loadConfig } from \"../config\";", + "import { readPid, readRuntimePort, getRuntimePortPath } from \"../config/process-state\";", + "import { pendingTeardownOutstanding } from \"../config/pending-teardown\";", + "import { planUpdateRuntimeHandling, planStoppedRuntimeRecovery, inspectPackageRuntimeLiveness } from \"./runtime-ownership.mjs\";", + "import { unprivilegedOwnershipMutationEnvironment } from \"../service/ownership-mutation-lease.mjs\";", + "import { withUpdateOwnershipLease, readUpdateRuntimeTarget } from \"./ownership-transaction\";", + "import { createUpdateServiceHomePolicy, parseUpdateWorkerHome, 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\";", + "import { pnpmOwnerInvocation, readPnpmGlobalPackage, resolvePnpmGlobalOwner, runPnpmGlobalUpdate, } from \"./pnpm-global-install.mjs\";", + "import { checkRegistryPackageIntegrity } from \"./registry-integrity.mjs\";", + "import { npmCachePreflightFailureMessage, runNpmCachePreflight, } from \"./npm-cache-preflight.mjs\";", + "import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from \"./tray-update-plan.mjs\";", + "import { withProcessRuntimeProvenance } from \"../lib/bun-runtime\";", + "import { withoutSiblingMarker } from \"../codex/sibling-start\";", + "import { packageVersion } from \"../lib/package-version\";", + "import { isStandaloneModuleUrl } from \"../lib/standalone\";", + "import { selfLaunchArgv } from \"../lib/self-launch-argv\";", + "import { PNPM_READ_CWD, withPnpmCommandCwd, pnpmReadEnvironment } from \"./pnpm-read-policy.mjs\";", + "import { freshServingPid, hasRestartableServiceManager, readParentPid, readServiceManagerPid, restartServiceManagerJob, supervisionVerdict, unsettledManagerWarning, unsupervisedRuntimeWarning, } from \"./supervised-recovery.mjs\";" + ], + "src/update/install-detection.mjs": [ + "import { existsSync, readFileSync, realpathSync, statSync } from \"node:fs\";" + ], + "src/update/job-pinned-start.ts": [ + "import { guardedSpawn } from \"./mutation-guard.mjs\";", + "import { closeSync, mkdirSync, openSync, writeSync } from \"node:fs\";", + "import { join } from \"node:path\";", + "import { getConfigDir } from \"../config\";", + "import { withoutSiblingMarker } from \"../codex/sibling-start\";" + ], + "src/update/job-supervised-restart.ts": [ + "import { readRuntimePort } from \"../config/process-state\";", + "import { proxyIdentityAt } from \"../server/proxy-liveness\";", + "import { createUpdateServiceHomePolicy, inspectUpdateServiceHome, updateServiceHomeRefusal, type UpdateServiceHomeInspector } from \"./service-home-authority\";", + "import { guardedRun, UpdateMutationRefusedError } from \"./mutation-guard.mjs\";", + "import { freshServingPid, hasRestartableServiceManager, readParentPid, readServiceManagerPid, restartServiceManagerJob, supervisionVerdict, unsettledManagerWarning, unsupervisedRuntimeWarning, } from \"./supervised-recovery.mjs\";" + ], + "src/update/job-update-home.ts": [ + "import { probeSpawnSync } from \"./mutation-guard.mjs\";", + "import { createUpdateServiceHomePolicy, inspectUpdateServiceHome, type UpdateServiceHomeInspector } from \"./service-home-authority\";", + "import { UPDATE_PACKAGE_ONLY_EXIT_CODE, UPDATE_WORKER_HOME_FLAG, updateWorkerHomeArgv } from \"./service-home-policy.mjs\";", + "import { currentServiceHomes } from \"../service/state\";", + "import { probeUpdatePackageOnlyLauncher, updateHomeProbeEnvironment } from \"./service-home-probe.mjs\";" + ], + "src/update/job-update-tray.ts": [ + "import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from \"./tray-update-plan.mjs\";" + ], + "src/update/job-windows-restart.ts": [ + "import { join } from \"node:path\";", + "import { getConfigDir } from \"../config\";", + "import { guardedStopWinsw, guardedStopWindows, guardedKillWindowsWrappers } from \"./guarded-runtime-ops\";", + "import { readServiceBackend } from \"../service\";" + ], + "src/update/job.ts": [ + "import { guardedRun, guardedSpawnSync, materializeMutationData } from \"./mutation-guard.mjs\";", + "import { probeSpawn, probeSpawnSync } from \"./mutation-guard.mjs\";", + "import { existsSync, mkdirSync, readFileSync } from \"node:fs\";", + "import { dirname, join, sep } from \"node:path\";", + "import { fileURLToPath } from \"node:url\";", + "import { atomicWriteFile, getConfigDir, loadConfig } from \"../config\";", + "import { readPid, readRuntimePort, verifyPidIdentity } from \"../config/process-state\";", + "import { isProcessAlive } from \"../lib/process-control\";", + "import { selfLaunchArgv } from \"../lib/self-launch-argv\";", + "import { buildWindowsElevatedArgumentList, resolveTrustedWindowsPowerShellExe, } from \"../lib/windows-elevation\";", + "import { listListenPids, scanListenPids, type ListenPidScan } from \"../server/port-reclaim\";", + "import { guardedReclaimListenPort as reclaimListenPort } from \"./guarded-port-reclaim\";", + "import { guardedKillProxy, guardedRemovePid, guardedRemoveRuntimePort, guardedDropWindowsTcp, guardedPublishJob, type KillProxy } from \"./guarded-runtime-ops\";", + "import { isHealthzVersion, isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity, } from \"../server/proxy-liveness\";", + "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 { type Channel, type Installer, PKG, STANDALONE_UPDATE_HINT, checkUpdatePackageIntegrity, currentVersion, defaultUpdateTag, detectInstall, detectInstallOwnership, latestVersion, miseUpdateCommand, updateCommand, updateCommandStr, resolveCurrentPnpmGlobalOwner, resolvePnpmActiveLauncher, } from \"./index\";", + "import { isNewer } from \"./notify\";", + "import { isRealBunBinary } from \"../lib/bun-binary-validator.mjs\";", + "import { planWindowsTrayUpdate } from \"./tray-update-plan.mjs\";", + "import { GUI_UPDATE_FAILURE_NEXT_STEP } from \"./update-failure-guidance.mjs\";", + "import { npmCachePreflightFailureMessage, runNpmCachePreflight, type NpmCachePreflightReason, } from \"./npm-cache-preflight.mjs\";", + "import { guiUpdateWorkerCommand } from \"./worker-launch\";", + "import { prepareWorkerUpdateHome, packageOnlyUpdateSucceeded } from \"./job-update-home\";", + "import { defaultWorkerTrayHandoff } from \"./job-update-tray\";", + "import { spawnWorkerPinnedStart } from \"./job-pinned-start\";", + "import { spawnBindProbe, strictBindProbeScript } from \"./bind-probe\";", + "import { withoutUpdateTeardownHandover, withUpdateTeardownHandover } from \"./teardown-handover\";" + ], + "src/update/mise-launcher-target.ts": [ + "import { realpathSync } from \"node:fs\";", + "import { dirname, isAbsolute, join } from \"node:path\";", + "import { observePackageManifestAt, packageManifestPathIn, runningPackageRoot, } from \"../lib/package-tree-integrity\";", + "import { readServiceInstallState, SERVICE_MANAGED_ENV } from \"../service/state\";", + "import { detectInstallOwnershipFromPath, type InstallOwnership } from \"./install-detection.mjs\";" + ], + "src/update/mutation-guard.mjs": [ + "import { spawn, spawnSync, execFile, execFileSync } from \"node:child_process\";", + "import { renameSync, rmSync, unlinkSync } from \"node:fs\";" + ], + "src/update/notify.ts": [ + "import { probeSpawn } from \"./mutation-guard.mjs\";", + "import { existsSync, readFileSync } from \"node:fs\";", + "import { join } from \"node:path\";", + "import { isatty } from \"node:tty\";", + "import { createInterface } from \"node:readline/promises\";", + "import { atomicWriteFile, getConfigDir } from \"../config\";", + "import { hasStarPromptRun } from \"../cli/star-prompt\";", + "import { selfLaunchArgv } from \"../lib/self-launch-argv\";", + "import { type Channel, currentVersion, detectInstall, latestVersion, runUpdate, updateCommandStr, updateTag, } from \"./index\";" + ], + "src/update/npm-cache-preflight.mjs": [ + "import { lstatSync, readdirSync, realpathSync, statSync } from \"node:fs\";", + "import { probeSpawnSync } from \"./mutation-guard.mjs\";", + "import { homedir } from \"node:os\";", + "import { dirname, isAbsolute, resolve } from \"node:path\";", + "import { fileURLToPath } from \"node:url\";", + "import { npmInvocation } from \"./npm-invocation.mjs\";" + ], + "src/update/npm-install.mjs": [ + "import { guardedSpawn, materializeMutationData } from \"./mutation-guard.mjs\";", + "import { dirname, join, win32 } from \"node:path\";", + "import { fileURLToPath } from \"node:url\";" + ], + "src/update/npm-invocation.mjs": [ + "import { existsSync, realpathSync } from \"node:fs\";", + "import { win32 } from \"node:path\";" + ], + "src/update/ownership-transaction.ts": [ + "import { readFileSync } from \"node:fs\";", + "import { acquireOwnershipMutationLease, ownershipMutationLeaseChildEnvironment, unprivilegedOwnershipMutationEnvironment, } from \"../service/ownership-mutation-lease.mjs\";" + ], + "src/update/pnpm-global-install.mjs": [ + "import { existsSync, readFileSync, realpathSync, statSync } from \"node:fs\";", + "import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from \"node:path\";", + "import { pnpmInvocationForPath } from \"./pnpm-invocation.mjs\";", + "import { verifyPnpmInstallTree } from \"./transactional-install.mjs\";" + ], + "src/update/pnpm-invocation.mjs": [ + "import { existsSync } from \"node:fs\";", + "import { win32 } from \"node:path\";" + ], + "src/update/pnpm-owner-worker.ts": [ + "import { resolveCurrentPnpmGlobalOwner } from \"./index\";" + ], + "src/update/pnpm-read-policy.mjs": [ + "import { ownedArtifactUnlink } from \"./mutation-guard.mjs\";", + "import { mkdtempSync, lstatSync, writeFileSync, rmdirSync } from \"node:fs\";", + "import { tmpdir } from \"node:os\";", + "import { dirname, join } from \"node:path\";", + "import { fileURLToPath } from \"node:url\";" + ], + "src/update/proxy-liveness-probe.mjs": [ + "import { probeSpawnSync } from \"./mutation-guard.mjs\";" + ], + "src/update/refresh-scheduler.ts": [ + "import { checkForUpdate, type UpdateCheckResult } from \"./job\";", + "import { currentVersion, defaultUpdateTag, detectInstall, detectInstallOwnership, miseUpdateCommand, type Channel, type Installer } from \"./index\";", + "import { isSourceBuildVersion, readVersionCache, REFRESH_INTERVAL_MS, writeFreshVersionCache } from \"./notify\";", + "import { latestVersionAsync } from \"./async-check\";" + ], + "src/update/registry-integrity.mjs": [], + "src/update/restart-ownership.ts": [ + "import { resolveServiceOwnership } from \"../service\";", + "import { serviceStatePaths } from \"../service/state\";", + "import { acquireOwnershipMutationLease, OWNERSHIP_MUTATION_LEASE_TOKEN_ENV, } from \"../service/ownership-mutation-lease.mjs\";", + "import { planUpdateRuntimeHandling } from \"./runtime-ownership.mjs\";", + "import { createSupervisionLatch, inspectDesktopSupervision } from \"../service/desktop-supervision.mjs\";" + ], + "src/update/runtime-ownership.mjs": [], + "src/update/service-home-authority.ts": [ + "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\";" + ], + "src/update/service-home-policy.mjs": [ + "import { createMutationAuthorizer } from \"./mutation-guard.mjs\";" + ], + "src/update/service-home-probe-entry.ts": [ + "import { inspectUpdateServiceHome } from \"./service-home-authority\";", + "import { isUpdateHomeProbe } from \"./service-home-policy.mjs\";" + ], + "src/update/service-home-probe.mjs": [ + "import { parseUpdateHomeProbe, UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION } from \"./service-home-policy.mjs\";", + "import { readFileSync, statSync } from \"node:fs\";", + "import { dirname, join } from \"node:path\";" + ], + "src/update/stop-contract.mjs": [], + "src/update/stop-decision.mjs": [ + "import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE, STOP_TEARDOWN_HANDED_OVER_EXIT_CODE, } from \"./stop-contract.mjs\";" + ], + "src/update/supervised-recovery.mjs": [ + "import { guardedRun, guardedSpawnSync, UpdateMutationRefusedError } from \"./mutation-guard.mjs\";" + ], + "src/update/teardown-handover.ts": [ + "import { clearPendingTeardown, isPendingTeardownAbandoned, listPendingTeardowns, pendingTeardownPathFor, pendingTeardownsAreExactly, readPendingTeardown, } from \"../config/pending-teardown\";", + "import { isLikelyOcxProcess } from \"../config/process-state\";", + "import { isProcessAlive } from \"../lib/process-control\";" + ], + "src/update/transactional-install.mjs": [ + "import { guardedRenameSync, ownedArtifactRm } from \"./mutation-guard.mjs\";", + "import { probeSpawnSync } from \"./mutation-guard.mjs\";", + "import { randomBytes } from \"node:crypto\";", + "import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from \"node:fs\";", + "import { basename, dirname, join } from \"node:path\";" + ], + "src/update/tray-update-plan.mjs": [], + "src/update/update-failure-guidance.mjs": [ + "import { pnpmGlobalCommandArgs } from \"./pnpm-global-install.mjs\";" + ], + "src/update/worker-launch.ts": [ + "import { probeSpawn, probeSpawnSync } from \"./mutation-guard.mjs\";", + "import { accessSync, constants, realpathSync, statSync } from \"node:fs\";", + "import { dirname, isAbsolute } from \"node:path\";" + ] +}; +export const updateDeferredImports: readonly string[] = [ + "src/update/index.ts:resolvedRuntimeOwnership:{ resolveServiceOwnership } = await import(\"../service\")", + "src/update/index.ts:runUpdate:{ serviceStatePaths } = await import(\"../service\")", + "src/update/index.ts:runUpdate:{ inspectUpdateServiceHome } = await import(\"./service-home-authority\")", + "src/update/index.ts:runUpdate:{ isServiceInstalled } = await import(\"../service\")", + "src/update/index.ts:runUpdate:{ getWindowsTrayStatus, guardedStartWindowsTray, guardedStopWindowsTray } = await import(\"./guarded-tray-ops\")", + "src/update/index.ts:runUpdate:{ serviceReinstallArgs, isServiceViable } = await import(\"../service\")", + "src/update/index.ts:runUpdate:{ guardedStartWindowsTray } = await import(\"./guarded-tray-ops\")", + "src/update/index.ts:runUpdate:{ guardedStartWindowsTray } = await import(\"./guarded-tray-ops\")", + "src/update/index.ts:runUpdate:{ isCodexShimInstalled } = await import(\"../codex/shim\")", + "src/update/index.ts:runUpdate:{ serviceReinstallArgs } = await import(\"../service\")", + "src/update/index.ts:runUpdate:{ guardedReclaimListenPort: reclaimListenPort } = await import(\"./guarded-port-reclaim\")", + "src/update/index.ts:runUpdate:{ isServiceViable } = await import(\"../service\")", + "src/update/index.ts:runUpdate:{ guardedStartWindowsTray } = await import(\"./guarded-tray-ops\")", + "src/update/job-update-tray.ts:defaultWorkerTrayHandoff:{ getWindowsTrayStatus, guardedStartWindowsTray, guardedStopWindowsTray } = await import(\"./guarded-tray-ops\")", + "src/update/job.ts:restartAfterUpdate:{ serviceReinstallArgs } = await import(\"../service\")" +]; +// Existing trusted wrapper internals: each complete expression is reviewed. +// Import permission does not grant a wrapper arbitrary loader/global access. +export const updateBoundaryRootSites = new Map([ + ['src/update/mutation-guard.mjs:materializeMutationData:Reflect.ownKeys(value)', 'enumerate own string/symbol keys before authorization'], + ['src/update/mutation-guard.mjs:guardedKill:process.kill.bind(process)', 'native signal adapter consumed by guardedRun after materialization and authorization'], + ['src/update/mutation-guard.mjs:guardedExit:process.exit.bind(process)', 'native terminal adapter consumed by guardedRun after materialization and authorization'], + ['src/update/mutation-guard.mjs:probeProcessAlive:process.kill(materializeMutationData(pid), 0)', 'signal-zero liveness observation only'], + ['src/update/mutation-guard.mjs:relayOwnSignal:process.kill(process.pid, materializeMutationData(signal))', 'ordinary launcher signal relay to this process only'], +]); diff --git a/tests/helpers/update-lint-parser.ts b/tests/helpers/update-lint-parser.ts new file mode 100644 index 00000000000..2536754cd81 --- /dev/null +++ b/tests/helpers/update-lint-parser.ts @@ -0,0 +1,20 @@ +import { test } from "bun:test"; +import { API } from "typescript/unstable/sync"; + +/** + * Parser for the auxiliary update lint (known-pattern regression checks, not a security + * boundary). TypeScript 7's sync client reads its child's stdio through Node stream + * handles on POSIX, which Bun does not expose, so the client cannot start there. Only that + * known POSIX startup failure yields null; any other failure, and every failure on + * Windows, still throws. Runtime guard tests never depend on this parser. + */ +export function openUpdateLintParser(): API | null { + try { return new API(); } + catch (error) { + if (process.platform !== "win32" && error instanceof TypeError && /_handle/.test(error.message)) return null; + throw error; + } +} + +/** A lint case that runs only where the sync parser could start. */ +export const lintTest = (api: API | null) => test.skipIf(api === null); diff --git a/tests/helpers/update-mutation-scanner.ts b/tests/helpers/update-mutation-scanner.ts new file mode 100644 index 00000000000..fe1682ad2ca --- /dev/null +++ b/tests/helpers/update-mutation-scanner.ts @@ -0,0 +1,120 @@ +// Best-effort regression lint over known syntax patterns, not a security boundary +// or completeness proof. Runtime mutation guards enforce update authority. +import * as ts from "typescript/unstable/ast"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { repoPath } from "./repo-root"; +import { updatePrimitiveAllowlist } from "./update-primitive-allowlist"; +import { capabilityPolicy } from "./update-capability-policy"; +import { updateProcessAllowlist } from "./update-process-allowlist"; + +const files = ["bin/ocx.mjs", "src/update/index.ts", "src/update/job.ts", "src/update/job-update-tray.ts", + "src/update/job-supervised-restart.ts", "src/update/job-pinned-start.ts", "src/update/job-windows-restart.ts"]; +const compact = (node: ts.Node) => node.getText().replace(/\s+/g, " "); + +export function updateModuleFiles(root = repoPath("src/update"), prefix = "src/update"): string[] { + return readdirSync(root, { withFileTypes: true }).flatMap(entry => entry.isDirectory() + ? updateModuleFiles(join(root, entry.name), prefix + "/" + entry.name) + : /\.(?:ts|mts|mjs|js)$/.test(entry.name) && !/\.d\.(?:ts|mts)$/.test(entry.name) ? [prefix + "/" + entry.name] : []).sort(); +} + +// These exemptions support the known-pattern lint; they do not establish that +// all possible JavaScript capability acquisition or effect sites are covered. +const boundaryModules = new Set(["src/update/mutation-guard.mjs", "src/update/guarded-runtime-ops.ts", "src/update/guarded-tray-ops.ts"]); +// Additional familiar legacy/injected helper references for regression detection. +const rawNames = new Set(["$", "spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync", "kill", "killProxy", "removePid", "removeRuntimePort", + "fork", "Worker", "terminate", "write", "writeFile", "writeFileSync", "appendFile", "appendFileSync", "copyFile", "copyFileSync", "truncate", "truncateSync", "symlink", "symlinkSync", "link", "linkSync", "rmdir", "rmdirSync", + "probeSpawn", "probeSpawnSync", "dropWindowsTcpRowsForLocalPort", "stopWindowsTray", "startWindowsTray", "stopWindows", "stopWinswService", "killWindowsSchedulerWrappers", "renameSync", "rmSync", "unlinkSync"]); +export function contextOf(node: ts.Node): string { + for (let p: ts.Node | undefined = node.parent; p; p = p.parent) + if (ts.isFunctionDeclaration(p) && p.name) return p.name.text; + return ""; +} +// Pinned non-control operations and exact child cleanup are documented in the +// helper inventory. This table contains complete expressions and occurrence counts, +// not expression grammars or prefixes. Adding a row requires boundary review. +export const rawReferenceAllowlist = new Map([...updatePrimitiveAllowlist, ...updateProcessAllowlist.map(([key, reason]) => [key, reason] as const)]); +const occurrenceLimits = new Map([ + ...updateProcessAllowlist.map(([key, , count]) => [key, count] as const), + ["src/update/async-check.ts:latestVersionAsync:child.kill()", 2], + ["src/update/npm-install.mjs:runNpmInstall:child.kill()", 2], + ["src/update/job-pinned-start.ts:spawnWorkerPinnedStart:closeSync(logFd)", 2], +]); +export function rawMutationViolations(source: ts.SourceFile, file: string): string[] { + const failures: string[] = []; + const policy = capabilityPolicy(source, file); + const seen = new Map(); + const allowed = (node: ts.Node) => { + const key = file + ":" + contextOf(node) + ":" + compact(node); + if (!rawReferenceAllowlist.has(key)) return false; + const count = (seen.get(key) ?? 0) + 1; seen.set(key, count); + if (count > (occurrenceLimits.get(key) ?? 1)) { failures.push("extra allowlisted capability occurrence: " + compact(node)); return false; } + return true; + }; + const visit = (node: ts.Node) => { + if ([ts.SyntaxKind.TypeQuery, ts.SyntaxKind.TypeReference, ts.SyntaxKind.TypeLiteral, ts.SyntaxKind.InterfaceDeclaration, ts.SyntaxKind.TypeAliasDeclaration].includes(node.kind)) return; + if (ts.isCallExpression(node) && file === "bin/ocx.mjs" && contextOf(node) === "runPackageManagerSelfUpdate" + && compact(node.expression) === "process.exit" && node.pos > source.text.indexOf("const homePolicy") + && compact(node.arguments[0]) !== "1") failures.push("unguarded successful update exit: " + compact(node)); + // Dependency admission applies even to trusted primitive implementations and + // exact legacy exemptions; an exemption cannot open a new import edge. + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + const admission = policy.reason(node); + if (admission === "unreviewed static dependency or binding") failures.push(admission + ": " + compact(node)); + } + if (boundaryModules.has(file)) { + if (policy.trustedRootSite(node)) return; + if (policy.rootViolation(node)) failures.push("forbidden wrapper capability root: " + compact(node)); + node.forEachChild(visit); return; + } + if (allowed(node)) return; + const capability = policy.reason(node); + if (capability) failures.push(capability + ": " + compact(node)); + if (ts.isImportDeclaration(node) && node.importClause && !node.importClause.isTypeOnly) { + const module = node.moduleSpecifier.getText().slice(1,-1); + if (/^(?:node:)?(?:child_process|process)$/.test(module)) failures.push("raw process import: " + compact(node)); + const bindings = node.importClause.namedBindings; + if (module === "bun" && (node.importClause.name || bindings && ts.isNamespaceImport(bindings))) + failures.push("raw Bun runtime import: " + compact(node)); + if (bindings && ts.isNamedImports(bindings)) for (const binding of bindings.elements) { + if (/mutation-guard\.mjs$/.test(module) && /^probeSpawn(?:Sync)?$/.test(binding.name.text) && !binding.propertyName) continue; + if (!binding.isTypeOnly && (rawNames.has((binding.propertyName ?? binding.name).text) || ["rm", "unlink", "rename"].includes((binding.propertyName ?? binding.name).text))) + failures.push("raw primitive import/alias: " + compact(node)); + } + if (bindings && ts.isNamespaceImport(bindings) && /(?:^node:)?fs(?:\/promises)?$/.test(module)) + failures.push("raw filesystem namespace: " + compact(node)); + if (node.importClause.name && /(?:^node:)?fs(?:\/promises)?$/.test(module)) failures.push("raw filesystem default import: " + compact(node)); + return; + } + if (ts.isExportDeclaration(node) && node.moduleSpecifier && !node.isTypeOnly) + if (/^["'](?:bun|(?:node:)?(?:child_process|process|fs(?:\/promises)?))["']$/.test(node.moduleSpecifier.getText()) + || !node.exportClause || ts.isNamedExports(node.exportClause) && node.exportClause.elements.some(e=>rawNames.has((e.propertyName ?? e.name).text))) failures.push("raw primitive re-export: " + compact(node)); + if (node.kind === ts.SyntaxKind.ImportEqualsDeclaration && /["'](?:bun|(?:node:)?(?:child_process|process|fs(?:\/promises)?))["']/.test(compact(node))) failures.push("raw primitive require import: " + compact(node)); + if (ts.isTaggedTemplateExpression(node)) failures.push("raw tagged-template execution: " + compact(node)); + if (ts.isCallExpression(node)) { + const name = compact(node.expression), text = compact(node); + if (name === "console.log" && /Updated|✅|[Pp]roxy is healthy/.test(text) || name === "updateJob" && text.includes('status: "succeeded"')) + failures.push("unguarded success publication: " + text); + } + if (ts.isCallExpression(node) && ["require", "import"].includes(compact(node.expression)) && node.arguments.some(arg=>/^(?:["'])(?:bun|(?:node:)?(?:child_process|process|fs(?:\/promises)?))["']$/.test(compact(arg)))) + failures.push("raw primitive module load: " + compact(node)); + if (ts.isElementAccessExpression(node) && node.argumentExpression && ts.isStringLiteral(node.argumentExpression) + && (rawNames.has(node.argumentExpression.text) || ["kill", "rm", "unlink", "rename"].includes(node.argumentExpression.text))) failures.push("computed raw primitive reference: " + compact(node)); + if (ts.isPropertyAccessExpression(node) && node.expression.kind !== ts.SyntaxKind.RegularExpressionLiteral + && (["kill", "rm", "unlink", "rename", "rmSync", "unlinkSync", "renameSync"].includes(node.name.text) || rawNames.has(node.name.text))) + failures.push("raw primitive reference: " + compact(node)); + if (ts.isIdentifier(node) && rawNames.has(node.text)) { + const parent=node.parent; + const isName = ts.isPropertyAccessExpression(parent) && parent.name === node || ts.isPropertyAssignment(parent) && parent.name === node + || ts.isFunctionDeclaration(parent) && parent.name === node || parent.kind === ts.SyntaxKind.Parameter && (parent as ts.ParameterDeclaration).name === node; + if (!isName) failures.push("raw primitive reference: " + node.text); + } + if (ts.isIdentifier(node) && node.text === "process" && !(ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node) + && !(ts.isTypeOfExpression(node.parent))) failures.push("raw process object reference: " + compact(node.parent)); + if (ts.isIdentifier(node) && node.text === "Bun" && !(ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node)) + failures.push("raw Bun object reference: " + compact(node.parent)); + node.forEachChild(visit); + }; + visit(source); return failures; +} +export { files, compact }; diff --git a/tests/helpers/update-node-home-child.mjs b/tests/helpers/update-node-home-child.mjs new file mode 100644 index 00000000000..18c48ea1fed --- /dev/null +++ b/tests/helpers/update-node-home-child.mjs @@ -0,0 +1,60 @@ +import { guardedUpdateIo } from "./update-guarded-io.mjs"; +// Execute the entire production Node update function in a real Node child. Only I/O +// boundaries are replaced: an unexpected command throws instead of reaching the host. +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createUpdateServiceHomePolicy, parseUpdateWorkerHome, UPDATE_PACKAGE_ONLY_EXIT_CODE } from "../../src/update/service-home-policy.mjs"; +import { inspectPackageRuntimeLiveness, planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../../src/update/tray-update-plan.mjs"; +import { manualUpdateFailureGuidance, npmUpdateFailureGuidance } from "../../src/update/update-failure-guidance.mjs"; + +const [home, observed, ...argv] = process.argv.slice(2); +if (!home || process.env.OPENCODEX_HOME !== home) throw new Error("isolated fixture home required"); +const launcher = fileURLToPath(new URL("../../bin/ocx.mjs", import.meta.url)); +const source = readFileSync(launcher, "utf8"); +const from = source.indexOf("async function runPackageManagerSelfUpdate(manager) {"); +const to = source.indexOf("function bunBinDir(", from); +if (from < 0 || to < from) throw new Error("production function boundary missing"); +const body = source.slice(from, to).replaceAll("import.meta.url", JSON.stringify(new URL("../../bin/ocx.mjs", import.meta.url).href)); +const trace = []; +const postRefusal = observed.startsWith("post-refusal-"); +const trayInitiallyRunning = postRefusal && observed !== "post-refusal-stopped"; +let trayRunning = trayInitiallyRunning; +let packageReplaced = false; +process.on("exit", () => process.stdout.write(`NODE_HOME_PROBE:${JSON.stringify(trace)}\n`)); +const dependencies = { + process: { argv: [process.execPath, launcher, "update", ...argv], env: process.env, execPath: process.execPath, platform: process.platform, exit: code => process.exit(code) }, + console, PKG: "@bitkyc08/opencodex", here: dirname(launcher), dirname, join, resolve, fileURLToPath, + currentPackageVersion: () => "1.0.0", updateTag: () => "latest", + npmInvocation: args => ({ file: "fixture-npm", args }), + spawnSync: (_bin, args) => { + if (args[0] === "view") return { status: 0, stdout: "99.0.0\n" }; + throw new Error(`unexpected control command: ${JSON.stringify(args)}`); + }, + checkRegistryPackageIntegrity: () => ({ ok: true, integrity: "sha512-fixture" }), + resolveNpmCachePath: () => ({ ok: true, path: home }), runNpmCachePreflight: () => ({ ok: true }), + unprivilegedOwnershipMutationEnvironment: env => env, + serviceStateFilesFor: () => [join(home, "service-state.json")], configDir: () => home, homedir: () => home, + createUpdateServiceHomePolicy, parseUpdateWorkerHome, UPDATE_PACKAGE_ONLY_EXIT_CODE, + readNodeUpdateHome: () => { trace.push("home"); return { kind: postRefusal ? packageReplaced ? "unknown" : "absent" : observed }; }, + existsSync, readFileSync, readdirSync, + selectAuthoritativeServiceState: () => ({ kind: "none", revision: 0 }), inspectInstallStateBytes: () => ({ kind: "absent" }), + createSupervisionLatch: () => ({ observe: () => false }), inspectDesktopSupervision: () => false, + planUpdateRuntimeHandling, inspectPackageRuntimeLiveness, handoffWindowsTrayForUpdate, + planWindowsTrayUpdate: status => planWindowsTrayUpdate(postRefusal ? { installed: true, running: trayInitiallyRunning } : status), + trayInstallState: () => ({ installed: postRefusal, running: trayRunning }), + runTrayLifecycle: (_launcher, action) => { trace.push(`tray:${action}`); trayRunning = action === "start"; return { status: 0 }; }, + probeProxyLiveness: () => "dead", hasPendingTeardownIn: () => false, + acquireOwnershipMutationLease: () => ({ token: "fixture", release: () => trace.push("release") }), + delegatedOwnershipMutationToken: null, + // The launcher imports the synchronous transaction as transactionalNpmUpdateSync. + transactionalNpmUpdateSync: () => { trace.push("replacement"); packageReplaced = true; return { ok: true }; }, + transactionalNpmUpdateAsync: async () => { trace.push("replacement"); packageReplaced = true; return { ok: true }; }, + manualUpdateFailureGuidance, npmUpdateFailureGuidance, + launcherUsableAfterNpmUpdate: () => observed !== "post-refusal-unverified", + launcherStartHint: () => "fixture manual start", +}; +// Sloppy function scope supplies the production module's bindings without rewriting +// its policy calls, branches, replacement gate, or terminal result. +await new Function("deps", `with (deps) { ${body}; return runPackageManagerSelfUpdate("npm"); }`)({ ...dependencies, ...guardedUpdateIo(dependencies) }); diff --git a/tests/helpers/update-primitive-allowlist.ts b/tests/helpers/update-primitive-allowlist.ts new file mode 100644 index 00000000000..3be1262677e --- /dev/null +++ b/tests/helpers/update-primitive-allowlist.ts @@ -0,0 +1,186 @@ +// Best-effort known-pattern regression lint, not a security boundary or +// completeness proof. Runtime mutation guards enforce update authority. +// Complete expressions, never prefixes or a JavaScript argument grammar. +// Probe rows confer no update authority. Child rows retain only their own child. +export const updatePrimitiveAllowlist: readonly (readonly [string, string])[] = [ + ["bin/ocx.mjs::import { createRequire } from \"node:module\";", "single structurally confined resolver in bunBinDir; the shared policy checks its entire body and every loader reference"], + ["bin/ocx.mjs::process.chdir(homedir())", "recover a deleted inherited cwd before launcher initialization; no package/state mutation"], + ["src/update/notify.ts::import { createInterface } from \"node:readline/promises\";", "existing interactive terminal prompt only; exact stream use below"], + ["src/update/notify.ts:maybeShowUpdatePrompt:createInterface({ input: process.stdin, output: process.stdout })", "ordinary terminal update-choice prompt, no package or runtime control"], + ["src/update/job-pinned-start.ts::import { closeSync, mkdirSync, openSync, writeSync } from \"node:fs\";", "exact append-only pinned-start diagnostic log setup/descriptor cleanup below"], + ["src/update/job-pinned-start.ts:spawnWorkerPinnedStart:mkdirSync(getConfigDir(), { recursive: true })", "ensure the private diagnostic log directory; never replace live files"], + ["src/update/job-pinned-start.ts:spawnWorkerPinnedStart:openSync(logPath, \"a\")", "append-only update-pinned-start.log descriptor captured by this function"], + ["src/update/job-pinned-start.ts:spawnWorkerPinnedStart:writeSync(logFd, `\\n--- ${new Date().toISOString()} ---\\n$ ${cmd.display}\\n`)", "append diagnostic header only to this function's log descriptor"], + ["src/update/job-pinned-start.ts:spawnWorkerPinnedStart:closeSync(logFd)", "close only this function's diagnostic log descriptor on refusal or after spawn"], + ["src/update/job.ts::import { existsSync, mkdirSync, readFileSync } from \"node:fs\";", "only ensureJobDir creates the private job-state directory"], + ["src/update/job.ts:ensureJobDir:mkdirSync(dir, { recursive: true, mode: 0o700 })", "create missing job-state directory; job success publication remains guarded"], + ["src/update/pnpm-read-policy.mjs:withPnpmCommandCwd:mkdtempSync(join(tmpdir(), \"ocx-pnpm-command-\"))", "exclusive disposable pnpm read-policy cwd under OS temp"], + ["src/update/transactional-install.mjs:createOwnedStage:mkdir = deps.mkdir ?? mkdirSync", "private stage mkdir alias; only the two exact stage paths below"], + ["src/update/transactional-install.mjs:createOwnedStage:mkdir(stageRoot)", "exclusive new randomly named private stage; existing directory fails"], + ["src/update/transactional-install.mjs:createOwnedStage:mkdir(join(stageRoot, \"lib\"))", "npm layout inside the exclusively owned stage"], + ["src/update/transactional-install.mjs:npmUpdateTransaction:mkdirSync(backupRoot, { recursive: true })", "empty backup container only; moving live package into it is separately guarded"], + ["src/update/transactional-install.mjs:removeOwnedStage:rm = deps.rm", "injected private-stage removal forwarded only to ownedArtifactRm"], + ["src/update/transactional-install.mjs:bootRestoreProbe:rename = deps.rename", "injected boot-recovery rename forwarded only to guardedRenameSync"], + ["src/update/transactional-install.mjs:npmUpdateTransaction:rename = deps.rename", "injected package rename forwarded only to guardedRenameSync"], + [ + "bin/ocx.mjs:readNodeUpdateHome:probeUpdateServiceHome(probeSpawnSync, bun.path, [\"--no-env-file\", join(dirname(launcherPath), \"..\", \"src\", \"update\", \"service-home-probe-entry.ts\")], { env })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs:runPackageManagerSelfUpdate:probeSpawnSync(invocation.file, invocation.args, { stdio: capture ? \"pipe\" : \"ignore\", encoding: \"utf8\", timeout: 20_000, windowsHide: true, cwd: PNPM_READ_CWD, env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(process.env)), ...invocation.options, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs:runPackageManagerSelfUpdate:probeSpawnSync(latestInvocation.file, latestInvocation.args, readProbeOptions(latestInvocation))", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs:runPackageManagerSelfUpdate:probeSpawnSync(invocation.file, invocation.args, readProbeOptions(invocation))", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs:readServiceInstalledFromStatus:probeSpawnSync(process.execPath, [launcherPath, \"status\", \"--json\"], { encoding: \"utf8\", timeout: 20_000, windowsHide: true, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs:refreshBackgroundServiceOrStartDirect:probeSpawnSync(process.execPath, [postUpdateLauncher, \"status\", \"--json\"], { encoding: \"utf8\", timeout: 20_000, windowsHide: true, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs:resolveBun:probeSpawnSync(process.execPath, [installJs], { stdio: \"inherit\" })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs::probeSpawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { stdio: \"inherit\", // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no // console to inherit. Without this flag Windows allocates a visible console for // the long-running Bun child, and closing that window kills the proxy (#1236). windowsHide: true, env: { // A cache shared with another Bun version (the CI test runner's) is evicted on every start. ...scopeTranspilerCacheToRuntime(inheritedEnv, bunRuntime.version, join), [NODE_LAUNCH_CONTEXT_ENV]: launchContext, [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, [BUN_RUNTIME_PATH_ENV]: bunRuntime.path, }, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "bin/ocx.mjs::child.kill(sig)", + "only this function's exact returned child / ordinary signal relay" + ], + [ + "src/update/async-check.ts::defaultDeps: AsyncLookupDeps = { ownerFn: pnpmOwner, spawnFn: probeSpawn }", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/async-check.ts:latestVersionAsync:child.kill()", + "only this function's exact returned child / ordinary signal relay" + ], + [ + "src/update/bind-probe.ts:spawnBindProbe:probeSpawnSync(bin, [\"-e\", script], { windowsHide: true, timeout: 4000, stdio: \"ignore\", })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:runPnpmCandidate:probeSpawn: typeof probeSpawnSync = probeSpawnSync", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:runPnpmCandidate:probeSpawn(invocation.file, invocation.args, { stdio: capture ? \"pipe\" : \"ignore\", encoding: \"utf8\", timeout: 20_000, windowsHide: true, cwd: PNPM_READ_CWD, env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(process.env)), ...invocation.options, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:resolveCurrentPnpmGlobalOwner:probeSpawn = deps.spawn ?? probeSpawnSync", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:resolveCurrentPnpmGlobalOwner:runPnpmCandidate(commandPath, args, capture, probeSpawn)", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:runOwnedPnpm:probeSpawn: typeof probeSpawnSync = probeSpawnSync", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:runOwnedPnpm:guardedSpawnSync(authorizeMutation, \"package\", target.bin, target.args, { stdio, encoding: \"utf8\", timeout: 180_000, windowsHide: true, // Reads probe from the package dir; `add -g`/rollback children run from a neutral // directory so a Windows cwd handle never pins open the package pnpm is replacing. cwd, env: pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(target.env)), ...target.options, }, probeSpawn)", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:latestVersion:probeSpawn: typeof probeSpawnSync = probeSpawnSync", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:latestVersion:probeSpawn(manager.bin, manager.args, { encoding: \"utf8\", timeout: 12000, windowsHide: true, cwd: installer === \"pnpm\" ? PNPM_READ_CWD : undefined, env: installer === \"pnpm\" ? pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(manager.env ?? process.env)) : unprivilegedOwnershipMutationEnvironment(manager.env ?? process.env), ...manager.options, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:checkUpdatePackageIntegrity:probeSpawn: typeof probeSpawnSync = probeSpawnSync", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:checkUpdatePackageIntegrity:probeSpawn(target.bin, target.args, { encoding: \"utf8\", timeout: 12000, windowsHide: true, cwd: installer === \"pnpm\" ? PNPM_READ_CWD : undefined, env: installer === \"pnpm\" ? pnpmReadEnvironment(unprivilegedOwnershipMutationEnvironment(target.env ?? process.env)) : unprivilegedOwnershipMutationEnvironment(target.env ?? process.env), ...target.options, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:runUpdate:checkUpdatePackageIntegrity(latest, probeSpawnSync, installer, owner)", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/index.ts:runUpdate:runOwnedPnpm( owner!, args, capture, capture ? \"pipe\" : installStdio, probeSpawnSync, homePolicy.authorizeMutation, )", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/job-update-home.ts:automaticUpdateLauncherSupported:probeUpdatePackageOnlyLauncher(launcher, probeSpawnSync, node, env)", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/job.ts:spawnGuiUpdateWorker:probeSpawn(launch.command, launch.argv, { detached: true, stdio: \"ignore\", windowsHide: true, env: { ...process.env, OCX_SERVICE: \"1\" }, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/job.ts:spawnGuiUpdateWorker:probeSpawnSync( resolveTrustedWindowsPowerShellExe(), [\"-NoProfile\", \"-NoLogo\", \"-NonInteractive\", \"-Command\", ps], { encoding: \"utf8\", windowsHide: true, timeout: 15_000 }, )", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/job.ts:runGuiUpdateWorker:checkUpdatePackageIntegrity(check.latestVersion, probeSpawnSync, check.installer, pnpmOwner)", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/notify.ts:triggerBackgroundRefreshIfStale:probeSpawn(process.execPath, args, { detached: true, stdio: \"ignore\", windowsHide: true, env: { ...process.env, OCX_SERVICE: \"1\" }, // never let the helper prompt })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/npm-cache-preflight.mjs:resolveNpmCachePath:(options.spawnSyncFn ?? probeSpawnSync)(invocation.file, invocation.args, { encoding: \"utf8\", timeout: options.timeoutMs ?? NPM_CONFIG_TIMEOUT_MS, windowsHide: true, cwd: options.cwd ?? homedir(), ...invocation.options, env: invocation.options?.env ?? env, })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/npm-cache-preflight.mjs:runPreflightWorker:probeSpawn = options.spawnSyncFn ?? probeSpawnSync", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/npm-cache-preflight.mjs:runPreflightWorker:probeSpawn( options.execPath ?? process.execPath, workerArgs, { encoding: \"utf8\", timeout: options.timeoutMs ?? WORKER_TIMEOUT_MS, windowsHide: true, env: options.env ?? process.env, }, )", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/npm-install.mjs:runNpmInstall:child.kill()", + "only this function's exact returned child / ordinary signal relay" + ], + [ + "src/update/proxy-liveness-probe.mjs:probeProxyLiveness:probeSpawnSync( process.execPath, [\"-e\", script, host, String(port), String(timeoutMs)], { encoding: \"utf8\", timeout: timeoutMs + 1500, windowsHide: true }, )", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/worker-launch.ts::probeSpawnSync(path, [...SYSTEMD_SCOPE_ARGS, path, \"--version\"], { stdio: \"ignore\", timeout: 5_000, env: scopeProbeEnvironment() })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/worker-launch.ts::probeSpawn(path, [...SYSTEMD_SCOPE_ARGS, path, \"--version\"], { stdio: \"ignore\", env: scopeProbeEnvironment() })", + "bounded probe or ordinary launcher; no update control" + ], + [ + "src/update/worker-launch.ts::probe.kill(\"SIGKILL\")", + "only this function's exact returned child / ordinary signal relay" + ], + ["src/update/async-check.ts:pnpmOwner:new Worker((deps.workerUrl ?? new URL(\"./pnpm-owner-worker.ts\", import.meta.url)).href)","bounded owner-discovery worker; no update controls"], + ["src/update/async-check.ts:pnpmOwner:worker.terminate()","only this function's own owner-discovery worker"], + ["src/update/pnpm-read-policy.mjs::import { mkdtempSync, lstatSync, writeFileSync, rmdirSync } from \"node:fs\";","private workspace, stage marker and committed-transaction recovery marker writes below only"], + ["src/update/pnpm-read-policy.mjs:withPnpmCommandCwd:writeFileSync(join(cwd, files[0]), \"packages: []\\nignorePnpmfile: true\\n\", { flag: \"wx\", mode: 0o600 })","exclusive private workspace files or identity-checked empty directory"], + ["src/update/pnpm-read-policy.mjs:withPnpmCommandCwd:writeFileSync(join(cwd, files[1]), \"ignore-pnpmfile=true\\n\", { flag: \"wx\", mode: 0o600 })","exclusive private workspace files or identity-checked empty directory"], + ["src/update/pnpm-read-policy.mjs:withPnpmCommandCwd:rmdirSync(cwd)","exclusive private workspace files or identity-checked empty directory"], + ["src/update/transactional-install.mjs::import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from \"node:fs\";","private workspace, stage marker and committed-transaction recovery marker writes below only"], + ["src/update/transactional-install.mjs:createOwnedStage:writeFileSync(join(stageRoot, UPDATE_OWNER_MARKER), JSON.stringify({ schema: 1, kind: \"staging\", pkgName, pid: process.pid, createdAt: (deps.now ?? Date.now)(), }), { flag: \"wx\" })","exclusive private staging marker"], + ["src/update/transactional-install.mjs:npmUpdateTransaction:writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({ at: new Date().toISOString(), backup: backupPackage, live: packageDir, restore: 'move \"' + backupPackage + '\" back to \"' + packageDir + '\"', error: String(error?.message ?? error), rollbackError: String(rollbackError?.message ?? rollbackError), }, null, 2))","recovery marker for this already committed package transaction"], + ["src/update/transactional-install.mjs:npmUpdateTransaction:writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({ at: new Date().toISOString(), backup: backupPackage, live: packageDir, restore: 'move \"' + backupPackage + '\" back to \"' + packageDir + '\"', error: \"post-swap verification failed: \" + liveCheck.failures.join(\"; \"), rollbackError: String(rollbackError?.message ?? rollbackError), }, null, 2))","recovery marker for this already committed package transaction"], + ["src/update/npm-cache-preflight.mjs::process.stdout.write(JSON.stringify({ protocol: PROTOCOL_VERSION, ...result }))","bounded helper protocol output; no successful update publication"], + ["src/update/npm-install.mjs:runNpmInstall:process.stderr.write(chunk)","bounded helper protocol output; no successful update publication"], + ["src/update/refresh-scheduler.ts:createRefreshScheduler:deps.write(tag, latest, deps.now())","version badge cache only; no runtime or install authority"] +]; diff --git a/tests/helpers/update-process-allowlist.ts b/tests/helpers/update-process-allowlist.ts new file mode 100644 index 00000000000..b8fa85247e4 --- /dev/null +++ b/tests/helpers/update-process-allowlist.ts @@ -0,0 +1,21 @@ +// Best-effort known-pattern regression lint, not a security boundary or +// completeness proof. Runtime mutation guards enforce update authority. +// Exact existing terminal/probe lifecycle sites. No arbitrary process API is admitted. +export const updateProcessAllowlist: readonly (readonly [string, string, number])[] = [ + ["bin/ocx.mjs:runPackageManagerSelfUpdate:process.exit(1)","terminal failure only; cannot publish successful update",29], + ["bin/ocx.mjs:runPackageManagerSelfUpdate:process.exit(0)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",1], + ["bin/ocx.mjs:fail:process.exit(1)","terminal failure only; cannot publish successful update",1], + ["bin/ocx.mjs::process.exit(0)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",3], + ["bin/ocx.mjs::process.exit(2)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",1], + ["bin/ocx.mjs::process.exit(1)","terminal failure only; cannot publish successful update",5], + ["bin/ocx.mjs::process.exit(127)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",1], + ["bin/ocx.mjs::process.exit(result.code)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",1], + ["bin/ocx.mjs::process.on(sig, handler)","ordinary launcher signal relay registration/cleanup",1], + ["bin/ocx.mjs::process.removeListener(sig, handler)","ordinary launcher signal relay registration/cleanup",1], + ["bin/ocx.mjs::process.exit(code ?? 1)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",1], + ["src/update/index.ts:runUpdate:process.exit(1)","terminal failure only; cannot publish successful update",4], + ["src/update/index.ts:runUpdate:process.exit(updateExitCode)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",1], + ["src/update/job.ts:defaultProbeProxyIdentity:fetch(`http://${probeHostname(hostname)}:${port}/healthz`, { signal: AbortSignal.timeout(750), })","bounded loopback health observation, no update control",1], + ["src/update/notify.ts:maybeShowUpdatePrompt:process.exit(0)","ordinary launcher/help/probe or terminal prompt outcome; update success remains guarded",1], + ["src/update/service-home-probe-entry.ts::process.exit(1)","terminal failure only; cannot publish successful update",1], +]; diff --git a/tests/helpers/update-source-body.ts b/tests/helpers/update-source-body.ts new file mode 100644 index 00000000000..2fbd4e24c58 --- /dev/null +++ b/tests/helpers/update-source-body.ts @@ -0,0 +1,17 @@ +import { guardedUpdateIo } from "./update-guarded-io.mjs"; +import { createMutationAuthorizer } from "../../src/update/mutation-guard.mjs"; + +/** Upgrade older I/O-only policy doubles when executing the production update bodies. */ +export function updateSourceFunction(...args: string[]) { + const execute = new Function(...args); + return (deps: Record) => { + const policy = deps.homePolicy; + if (policy && !policy.authorizeMutation) { + policy.authorizeMutation = createMutationAuthorizer({ + check: () => policy.check(), checkPackage: () => (policy.checkPackage ?? policy.check)(), + get serviceAllowed() { return policy.serviceAllowed ?? true; }, + }); + } + return execute({ ...deps, ...guardedUpdateIo(deps) }); + }; +} diff --git a/tests/helpers/update-worker-tray-child.ts b/tests/helpers/update-worker-tray-child.ts new file mode 100644 index 00000000000..daa692c8e12 --- /dev/null +++ b/tests/helpers/update-worker-tray-child.ts @@ -0,0 +1,42 @@ +// Isolated module mocks exercise the default Windows worker handoff, not trayHandoffFn. +import { mock } from "bun:test"; +import { repoPath } from "./repo-root"; + +const [home, scenario] = process.argv.slice(2); +if (!home || !scenario || process.env.OPENCODEX_HOME !== home) throw new Error("isolated fixture home required"); +const calls: string[] = []; +const commands: string[][] = []; +let replacements = 0; +let running = scenario.startsWith("running-"); +let statusReads = 0; +const failedHandoff = scenario === "running-status-start-fail" || scenario === "running-cleanup-fail"; +const tray = await import("../../src/tray/windows"); +function status() { + calls.push("status"); statusReads++; + if (scenario === "initial-status-fail" || failedHandoff && statusReads > 1) throw new Error("fixture tray status unavailable"); + return { installed: scenario !== "absent", running }; +} +mock.module(repoPath("src/tray/windows.ts"), () => ({ ...tray, + getWindowsTrayStatus: status, + stopWindowsTray: () => { calls.push("stop"); running = false; return status(); }, + startWindowsTray: () => { calls.push("native-start"); throw new Error("fixture first tray restore failed"); }, +})); +const { readUpdateJob, runGuiUpdateWorker } = await import("../../src/update/job"); +const io = { + platform: "win32" as const, + checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", channel: "latest" as const, + installer: "npm" as const, updateAvailable: true, canUpdate: true, command: "fixture", releaseNotesUrl: "fixture" }), + integrityFn: () => ({ ok: true as const, integrity: "sha512-testfixturevalue000000000" }), + cachePreflightFn: () => ({ ok: true, reason: "ok" }), + serviceHomeAuthorityFn: () => ({ kind: "same" as const }), + launcherSupportsUpdateHomeFn: () => true, + runCommandFn: () => { replacements++; return { status: 0, signal: null }; }, + trayCommandFn: (_job: unknown, _bin: string, args: string[]) => { + commands.push(args.slice(1)); + if (scenario === "running-cleanup-fail") throw new Error("fixture launcher restore failed"); + return { status: 0, signal: null }; + }, +}; +await runGuiUpdateWorker("default-tray", "latest", false, io); +const job = readUpdateJob("default-tray"); +console.log(`TRAY_WORKER_PROBE:${JSON.stringify({ calls, commands, replacements, status: job?.status, restarted: job?.restarted, error: job?.error })}`); diff --git a/tests/server/port-reclaim.test.ts b/tests/server/port-reclaim.test.ts index a3632cc8baf..21a26984d40 100644 --- a/tests/server/port-reclaim.test.ts +++ b/tests/server/port-reclaim.test.ts @@ -1027,4 +1027,72 @@ describe("reclaimListenPort", () => { })).resolves.toBe(true); expect(killed).toEqual([4242, 4242]); }); + + test("denied authority during the availability await blocks kill and TCP-row drop", async () => { + let authorized = true; + const killed: number[] = []; + const dropped: number[] = []; + await expect(reclaimListenPort(10100, "127.0.0.1", { + timeoutMs: 80, + intervalMs: 20, + scanIntervalMs: 20, + dropTcpRows: true, + killOcxHolders: true, + onlyKillPids: [4242], + // The gate flips while isAvailableFn is in flight — the interleaving a + // service-home change has against the update job's real reclaimer. + isAvailableFn: async () => { + authorized = false; + return false; + }, + listListenPidsFn: () => [4242], + isAliveFn: () => true, + verifyOcxFn: pid => pid, + killFn: pid => { + killed.push(pid); + }, + dropTcpFn: port => { + dropped.push(port); + return { dropped: 1, skippedIpv6: 0, accessDenied: 0 }; + }, + mayMutateFn: () => authorized, + sleepMs: async () => {}, + })).resolves.toBe(false); + expect(killed).toEqual([]); + expect(dropped).toEqual([]); + }); + + test("a mid-wait authority denial vetoes the TCP-row drop after an authorized kill", async () => { + let authorized = true; + let alive = true; + const killed: number[] = []; + const dropped: number[] = []; + await expect(reclaimListenPort(10100, "127.0.0.1", { + timeoutMs: 80, + intervalMs: 20, + scanIntervalMs: 20, + dropTcpRows: true, + killOcxHolders: true, + onlyKillPids: [4242], + isAvailableFn: async () => false, + listListenPidsFn: () => [4242], + isAliveFn: () => alive, + verifyOcxFn: pid => pid, + // Authority flips while the authorized kill is in flight — the very next + // destructive step (the TCP-row drop) must already be denied. + killFn: pid => { + killed.push(pid); + alive = false; + authorized = false; + }, + dropTcpFn: port => { + dropped.push(port); + return { dropped: 1, skippedIpv6: 0, accessDenied: 0 }; + }, + mayMutateFn: () => authorized, + sleepMs: async () => {}, + })).resolves.toBe(false); + expect(killed).toEqual([4242]); + expect(dropped).toEqual([]); + }); }); diff --git a/tests/update/pnpm-command-isolation.test.ts b/tests/update/pnpm-command-isolation.test.ts index 207822878ce..39333419f85 100644 --- a/tests/update/pnpm-command-isolation.test.ts +++ b/tests/update/pnpm-command-isolation.test.ts @@ -49,7 +49,7 @@ for (const fail of [false,true]) { if(args[0]==="list") stdout=JSON.stringify([{path:"/global",dependencies:{ocx_test:{version:versions[Math.min(listCall++,versions.length-1)],path:"/pkg"}}}]); } return {status,stdout,stderr:"",pid:1,output:[],signal:null}; - }) as never), verify:()=>({ok:true}),verifyShims:()=>({ok:true}) }); + }) as never, () => true), verify:()=>({ok:true}),verifyShims:()=>({ok:true}) }); expect(result.ok).toBe(!fail); expect(spawnCalls).toBeGreaterThan(1); expect(mutations).toHaveLength(fail ? 2 : 1); for(const cwd of mutations) expect(existsSync(cwd)).toBe(false); diff --git a/tests/update/update-bun-ownership-lease.test.ts b/tests/update/update-bun-ownership-lease.test.ts index afb8e05f43c..01389f9e3d5 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,46 @@ 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 }); +}); + +for (const scenario of ["delegated-known-unknown", "delegated-unknown-known", "delegated-malformed", "delegated-unknown-unknown"]) test(`the actual Bun child enforces ${scenario}`, async () => { + const result = await run(scenario); + const stableUnknown = scenario === "delegated-unknown-unknown"; + expect(result.exit, result.log).toBe(stableUnknown ? 75 : 1); + expect(result.trace.map(row => row.phase)).toEqual(stableUnknown ? ["replacement", "after"] : ["after"]); + expect(result.trace.at(-1)).toMatchObject({ reacquired: true, parentToken: false }); + if (stableUnknown) expect(result.log).toContain("Restart the proxy:"); +}); + +test("the actual Bun updater refuses changed post-replacement authority before shim, tray or service commands", async () => { + const result = await run("delegated-post-replacement"); + expect(result.exit, result.log).toBe(1); + expect(result.trace.map(row => row.phase)).toEqual(["stop", "replacement", "after"]); + expect(result.log).toContain("ownership could not be verified"); + expect(result.trace.at(-1)).toMatchObject({ reacquired: true, parentToken: false }); +}); diff --git a/tests/update/update-capability-policy.test.ts b/tests/update/update-capability-policy.test.ts new file mode 100644 index 00000000000..5f846f0697e --- /dev/null +++ b/tests/update/update-capability-policy.test.ts @@ -0,0 +1,133 @@ +// Auxiliary regression lint over known patterns, not a security boundary or +// completeness proof. Runtime mutation guards enforce update authority. +import { afterAll, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { lintTest, openUpdateLintParser } from "../helpers/update-lint-parser"; +import { rawMutationViolations } from "../helpers/update-mutation-scanner"; +import { capabilityControlCalls } from "../helpers/update-helper-inventory"; +import { repoPath } from "../helpers/repo-root"; + +const api = openUpdateLintParser(); +const lint = lintTest(api); +afterAll(() => api?.close()); +function inspect(text: string, target = "src/update/job-pinned-start.ts") { + const dir = mkdtempSync(join(tmpdir(), "ocx-closed-policy-")), file = join(dir, "probe.ts"); + writeFileSync(file, text); + const snapshot = api!.updateSnapshot({ openFiles: [file] }); + try { + const source = snapshot.getDefaultProjectForFile(file)!.program.getSourceFile(file)!; + return { scanner: rawMutationViolations(source, target), inventory: capabilityControlCalls(source, target) }; + } finally { snapshot.dispose(); rmSync(dir, { recursive: true, force: true }); } +} + +const probes = [ + 'import.meta.require("node:fs").cpSync(source,target);', + 'const io = import.meta.require("node:fs"); io.cpSync(source,target);', + 'const { cpSync: copy } = import.meta.require("node:fs"); copy(source,target);', + 'const { openSync: open, ftruncateSync: cut } = import.meta.require("node:fs"); cut(open(target,"r+"),0);', + 'import.meta.require("node:net").createConnection(options);', + 'import.meta.require("bun:ffi").dlopen(library,symbols);', + 'import.meta.require("bun").file(target).delete();', + 'const load = require; load("node:fs").cpSync(source,target);', + 'const load = import.meta.require; const io = load("node:fs"); const copy = io.cpSync; copy(source,target);', + 'const load = require; const io = load("node:fs"); const { cpSync: copy } = io; copy(source,target);', + 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const load = require; load("node:fs").cpSync(source,target);', + 'import { createRequire } from "node:module"; function bunBinDir(launcherPath){ const resolver = createRequire(launcherPath || import.meta.url); return resolver("node:fs").cpSync(source,target); }', + 'const { require: load } = import.meta; load("node:fs").cpSync(source,target);', + 'const load = (import.meta["require"]); load("node:fs").cpSync(source,target);', + 'const runtime = module; runtime.require("node:fs").cpSync(source,target);', + 'const out = exports; out.publish(target);', + 'eval(payload);', 'const run = eval; run(payload);', 'new Function(payload)();', // justified: parsed adversarial fixtures, never executed + 'const run = Function; run(payload)();', + 'globalThis.setTimeout(callback,0);', 'const root = globalThis; root.run(payload);', + 'const { run } = global; run(payload);', 'consume(self);', + 'Reflect.apply(operation,null,args);', 'const invoke = Reflect.apply; invoke(operation,null,args);', + 'const root = new Proxy(process,handler); root.cwd();', + 'WebAssembly.instantiate(bytes);', + 'process.binding("fs");', 'process.dlopen(module,path);', 'process._linkedBinding("fs");', + 'process["cwd"]();', 'Bun["sleep"](1);', + 'const { cwd } = process; cwd();', 'const copy = {...Bun}; copy.sleep(1);', + 'const get = () => process; get().cwd();', 'consume(process);', + 'await import("./new-helper");', 'const load = () => import("./index"); await load();', + 'import.meta.resolve("node:fs");', + 'import { run } from "./unreviewed-helper"; run(target);', + 'let load; load = require; let io; io = load("node:fs"); io.cpSync(source,target);', +]; +for (const probe of probes) for (const mode of ["scanner", "inventory"] as const) + lint(`${mode} rejects known acquisition pattern: ${probe}`, () => { + expect(inspect(probe, "bin/ocx.mjs")[mode].length).toBeGreaterThan(0); + }); + +for (const probe of [ + 'import { helper } from "./unreviewed-helper";', + 'import { run } from "../service/unreviewed";', + 'import { setTimeout } from "node:timers";', + 'export * from "./unreviewed-helper";', + 'import "./index";', +]) lint("static imports/exports require explicit admission: " + probe, () => { + expect(inspect(probe).scanner.length).toBeGreaterThan(0); +}); + +for (const target of ["src/update/mutation-guard.mjs", "src/update/guarded-runtime-ops.ts", "src/update/guarded-tray-ops.ts"]) + for (const probe of ['import.meta.require("node:fs").cpSync(source,target);', + 'const load = require; load("node:fs");', 'Reflect.apply(operation,null,args);', + 'await import("node:vm");', 'import { runInThisContext } from "node:vm";']) + lint("wrapper lint rejects known import/root pattern: " + target + ": " + probe, () => { + expect(inspect(probe, target).scanner.length).toBeGreaterThan(0); + }); + +lint("inventory preserves loader-result identity through destructuring", () => { + const result = inspect('const load = require; const io = load("node:fs"); const { cpSync: copy } = io; copy(source,target);'); + expect(result.inventory.map(node => node.getText())).toContain("copy(source,target)"); +}); + +lint("wrapper root slots reject extra occurrences", () => { + expect(inspect('function materializeMutationData(value){ Reflect.ownKeys(value); Reflect.ownKeys(value); }', + "src/update/mutation-guard.mjs").scanner.length).toBeGreaterThan(0); +}); + +lint("deferred imports admit only reviewed bindings, contexts and counts", () => { + const binding = 'const { guardedStartWindowsTray } = await import("./guarded-tray-ops");'; + expect(inspect('async function runUpdate(){' + binding.repeat(3) + '}', "src/update/index.ts").scanner).toEqual([]); + for (const body of [binding.repeat(4), 'const runtime = await import("./guarded-tray-ops");', + 'const { startWindowsTray } = await import("./guarded-tray-ops");']) + expect(inspect('async function runUpdate(){' + body + '}', "src/update/index.ts").scanner.length).toBeGreaterThan(0); + expect(inspect('async function other(){' + binding + '}', "src/update/index.ts").scanner.length).toBeGreaterThan(0); +}); + +lint("production resolver resolves default and alternate package roots without executing a package", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-resolver-")); + try { + const source = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); + const start = source.indexOf("function bunBinDir(launcherPath) {"); + const body = source.slice(start, source.indexOf("\n}", start) + 2); + expect(body).toContain('resolver.resolve("bun/package.json")'); + const roots = [root, join(root, "other")]; + for (const base of roots) { + mkdirSync(join(base, "node_modules", "bun"), { recursive: true }); + writeFileSync(join(base, "node_modules", "bun", "package.json"), '{"name":"bun","main":"index.js"}'); + writeFileSync(join(base, "node_modules", "bun", "index.js"), 'throw new Error("package code must not load");'); + } + const fixture = join(root, "resolver.mjs"); + writeFileSync(fixture, 'import {createRequire} from "node:module"; import {dirname} from "node:path";\n' + + body + '\nconsole.log(JSON.stringify([bunBinDir(),bunBinDir(process.argv[2]),bunBinDir("")]));\n'); + const result = spawnSync(process.platform === "win32" ? "node.exe" : "node", [fixture, join(roots[1], "launcher.mjs")], + { encoding: "utf8", timeout: 10_000, windowsHide: true }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual([root, roots[1], root].map(base => join(base, "node_modules", "bun"))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +lint("resolver-only site admits resolution and rejects escape, aliases and a second factory", () => { + const prefix = 'import { createRequire } from "node:module"; function bunBinDir(launcherPath){'; + const factory = 'const resolver = createRequire(launcherPath || import.meta.url);'; + expect(inspect(prefix + factory + 'return dirname(resolver.resolve("bun/package.json"));}', "bin/ocx.mjs").scanner).toEqual([]); + for (const use of ['return resolver;', 'const load = resolver; return load("node:fs");', + 'consume(resolver);', 'return resolver.resolve.call(null,"bun/package.json");', + 'return resolver["resolve"]("bun/package.json");', 'return resolver.resolve("node:fs");', + 'const other = createRequire(launcherPath || import.meta.url);']) + expect(inspect(prefix + factory + use + '}', "bin/ocx.mjs").scanner.length).toBeGreaterThan(0); +}); diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index 5826d96d6fc..b53c466aa40 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -9,6 +9,7 @@ * a fix ships on one side only. */ import { describe, expect, test } from "bun:test"; +import { updateSourceFunction } from "../helpers/update-source-body"; import { readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; import { @@ -172,7 +173,7 @@ describe("both updaters consult the shared rule", () => { test("the Bun updater gates its stop, its refresh and its restart hint", () => { expect(bunPath).toContain("from \"./runtime-ownership.mjs\""); - expect(bunPath).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(bunPath).toContain("if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); expect(bunPath).toContain("if (!runtimePlan.mayReplacePackage)"); expect(bunPath).toContain("if (postInstallPlan.mayRestoreService) {"); }); @@ -342,9 +343,9 @@ describe("live Desktop update authority", () => { } }); -async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEvidence, runtimeTrusted = true): Promise { +async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEvidence, runtimeTrusted = true, homeAllowed = true): Promise { const source = readFileSync(repoPath(lane === "Bun" ? "src/update/index.ts" : "bin/ocx.mjs"), "utf8"); - const start = source.indexOf(lane === "Bun" ? " if (runtimePlan.mayStopRuntime &&" : " if (stopNeeded) {"); + const start = source.indexOf(lane === "Bun" ? " if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime &&" : " if (stopNeeded) {"); const end = source.indexOf(lane === "Bun" ? ' if (stopStdio === "pipe")' : " const stillHasRuntimeState", start); expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); const counts = [0, 0, 0]; @@ -357,7 +358,7 @@ async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEviden else expect(deps).toBeUndefined(); return evidence; }; - const run = new Function("deps", `with (deps) { return (async () => { + const run = updateSourceFunction("deps", `with (deps) { return (async () => { let stopAttempted = false; ${source.slice(start, end)} } @@ -366,6 +367,7 @@ async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEviden try { await run({ runtimePlan, serviceWasInstalled: true, readPid: () => 321, readRuntimePort: () => ({}), + homePolicy: { check: () => homeAllowed, serviceAllowed: true, packageOnly: false }, pendingTeardownOutstanding: () => false, stopNeeded: true, resolvedRuntimeOwnership: async () => owner, readOwnership: () => owner, planUpdateRuntimeHandling, supervisionLatch: latch, inspectDesktopSupervision: inspect, @@ -387,12 +389,16 @@ test("Bun pre-stop uses inspector correlation when runtime identity is untrusted expect(await exercisePreStop("Bun", { kind: "none" }, false)).toEqual([1, 1, 1]); }); +test("Bun newly uncertain home refuses before stop and package replacement", async () => { + expect(await exercisePreStop("Bun", { kind: "none" }, true, false)).toEqual([0, 0, 0]); +}); + describe("Node service refresh mutation boundaries", () => { // The same function handles normal restoration and stopped-runtime failure recovery. const source = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); const start = source.indexOf(" function refreshBackgroundServiceOrStartDirect() {"); - const end = source.indexOf(" const updateLease =", start); - const run = new Function("deps", `with (deps) { + const end = source.indexOf(" let updateLease =", start); + const run = updateSourceFunction("deps", `with (deps) { ${source.slice(start, end)} return refreshBackgroundServiceOrStartDirect(); }`); @@ -410,6 +416,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 +457,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"); @@ -486,7 +493,7 @@ test("Bun refresh gate vetoes retained Desktop and activates only after positive const start = source.indexOf(" const mayRefresh = async () => {"); const end = source.indexOf(" if (!await mayRefresh())", start); expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); - const run = new Function("deps", `with (deps) { return (async () => { + const run = updateSourceFunction("deps", `with (deps) { return (async () => { ${source.slice(start, end)} return await mayRefresh(); })(); }`); @@ -497,6 +504,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-effect-authority.test.ts b/tests/update/update-effect-authority.test.ts new file mode 100644 index 00000000000..0845f47f76f --- /dev/null +++ b/tests/update/update-effect-authority.test.ts @@ -0,0 +1,174 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; +import { createUpdateServiceHomePolicy } from "../../src/update/service-home-policy.mjs"; +import { guardedRun, materializeMutationData, UpdateMutationRefusedError } from "../../src/update/mutation-guard.mjs"; + +// Execute the production orchestration with only OS I/O replaced. No signals, +// TCP deletions, or service/tray commands reach this machine. +function load(path: string, name: string, deps: object) { + const text = readFileSync(repoPath(path), "utf8"); + const candidates = [`export function ${name}(`, `export async function ${name}(`, `function ${name}(`]; + const start = candidates.map(c => text.indexOf(c)).find(n => n >= 0) ?? -1; + expect(start).toBeGreaterThanOrEqual(0); + const end = text.indexOf("\nexport ", start + 1); + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(text.slice(start, end < 0 ? undefined : end).replace("export ", "")); + return new Function("deps", `with(deps){${js};return ${name};}`)(deps); +} +function adapter(name: string, deps: object) { + const path = name === "guardedStopWindowsTray" ? "src/update/guarded-tray-ops.ts" : "src/update/guarded-runtime-ops.ts"; + const text = readFileSync(repoPath(path), "utf8").replace(/^import .+;\r?$/gm, "").replace(/^export \{.+\};\r?$/gm, "").replace(/^export /gm, ""); + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(text); + return new Function("deps", `with(deps){${js};return ${name};}`)({ guardedRun, materializeMutationData, UpdateMutationRefusedError, ...deps }); +} + +for (const phase of ["identity", "liveness", "kill-liveness", "escalation", "stable"]) + test(`reclaim carries authority to every signal after ${phase}`, async () => { + let home: "same" | "unknown" = "same", aliveReads = 0, waits = 0, probes = 0; + const signals: string[] = []; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const kill = load("src/lib/process-control.ts", "killProxy", { + process: { platform: "linux", kill: (_: number, signal: string) => { signals.push(signal); } }, + isProcessAlive: () => { if (phase === "kill-liveness") home = "unknown"; return true; }, + waitForExit: () => { if (++waits === 1 && phase === "escalation") home = "unknown"; return waits > 1; }, + }); + const reclaim = load("src/server/port-reclaim.ts", "reclaimListenPort", { + process: { platform: "linux", pid: 99 }, killProxy: kill, + dropWindowsTcpRowsForLocalPort: () => { throw new Error("unexpected TCP path"); }, + normalizeListenPidScan: (pids: number[]) => ({ ok: true, pids }), + }); + const result = await reclaim(19001, "127.0.0.1", { + timeoutMs: 500, intervalMs: 0, scanIntervalMs: 0, killOcxHolders: true, onlyKillPids: [4242], dropTcpRows: false, + isAvailableFn: async () => ++probes > 1, + listListenPidsFn: () => [4242], + isAliveFn: () => { if (++aliveReads === 2 && phase === "liveness") home = "unknown"; return true; }, + verifyOcxFn: (pid: number) => { if (aliveReads === 2 && phase === "identity") home = "unknown"; return pid; }, + mayMutateFn: () => policy.authorizeMutation("runtime"), sleepMs: async () => {}, + }); + expect(signals).toEqual(phase === "stable" ? ["SIGTERM", "SIGKILL"] : phase === "escalation" ? ["SIGTERM"] : []); + expect(result).toBe(phase === "stable"); + if (phase !== "stable") { home = "same"; expect(policy.authorizeMutation("publish")).toBe(false); } + }); + +for (const operation of ["wrapper", "winsw", "scheduler", "tray", "pid", "runtime", "registry"]) + for (const changed of [true, false]) test(`leaf ${operation} gates after preparation (${changed})`, () => { + let home: "same" | "unknown" = "same", effects = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const prepare = () => { if (changed) home = "unknown"; return "fixture"; }; + const gate = () => { if (!policy.authorizeMutation(operation === "tray" ? "tray" : ["pid", "runtime", "registry"].includes(operation) ? "runtime" : "service")) + throw Object.assign(new Error("refused"), { code: "EUPDATEAUTHORITY" }); }; + const effect = () => { effects++; return "stopped"; }; + let invoke: () => unknown; + if (operation === "wrapper") { + const fn = load("src/lib/windows-service-wrappers.ts", "killWindowsSchedulerWrappers", { + process: { platform: "win32" }, resolveTrustedWindowsPowerShellExe: prepare, + windowsWrapperKillScript: () => "fixture", spawnSync: effect, + }); + const wrapped = adapter("guardedKillWindowsWrappers", { killWindowsSchedulerWrappers: fn }); + invoke = () => wrapped(policy.authorizeMutation, { scriptPath: "script", launcherPath: "launcher" }); + } else if (operation === "winsw") { + const run = load("src/lib/winsw.ts", "runWinsw", { + winswExePath: prepare, execFileSync: effect, SERVICE_STOPWAIT_TIMEOUT_MS: 1, SERVICE_COMMAND_TIMEOUT_MS: 1, + }); + const fn = load("src/lib/winsw.ts", "stopWinswService", { runWinsw: run, statusWinswRaw: () => "stopped" }); + invoke = () => adapter("guardedStopWinsw", { stopWinswService: fn })(policy.authorizeMutation); + } else if (operation === "scheduler") { + const query = load("src/service/windows-scheduler.ts", "querySchtasks", { + isTestHomeGuardArmed: () => false, querySchtasksForTests: null, windowsSchtasks: prepare, runFile: effect, + }); + const schtasks = load("src/service/windows-scheduler.ts", "schtasks", { querySchtasks: query, toWindowsSchtasksError: (e: unknown) => e }); + const stop = load("src/service/windows-ops.ts", "stopWindows", { schtasks, TASK: "fixture", isWindowsSchedulerEndBenign: () => false }); + invoke = () => adapter("guardedStopWindows", { stopWindows: stop })(policy.authorizeMutation); + } else if (operation === "tray") { + const signal = load("src/tray/windows.ts", "signalTrayStop", { + readHeartbeat: () => ({}), windowsPowerShellPath: prepare, currentEntry: () => ({}), + windowsTrayProcessArgs: () => [], execFileSync: effect, + }); + const fn = load("src/tray/windows.ts", "stopWindowsTray", { + assertWindows() {}, readHeartbeat: () => ({}), signalTrayStop: signal, + waitForTrayExit: () => true, getWindowsTrayStatus: () => ({}), + }); + invoke = () => adapter("guardedStopWindowsTray", { stopWindowsTray: fn })(policy.authorizeMutation); + } else if (operation === "registry") { + const fn = load("src/config/owner-registry.ts", "unregisterOwnerRegistryHome", { + registryEntryPath: prepare, ownerRegistryDir: () => "fixture", unlinkSync: effect, + }); + invoke = () => fn("fixture", gate); + } else { + const fn = load("src/config/process-state.ts", operation === "pid" ? "removePid" : "removeRuntimePort", { + readPidFileValue: () => 4242, readRuntimePort: () => ({}), getPidPath: prepare, + getRuntimePortPath: prepare, unlinkSync: effect, unregisterOwnerRegistryHome() {}, getConfigDir: () => "fixture", + }); + invoke = () => operation === "pid" ? adapter("guardedRemovePid", { removePid: fn })(policy.authorizeMutation, 4242) + : adapter("guardedRemoveRuntimePort", { removeRuntimePort: fn })(policy.authorizeMutation); + } + if (changed) expect(invoke).toThrow(); else invoke(); + expect(effects).toBe(changed ? 0 : 1); + if (changed) { home = "same"; expect(policy.authorizeMutation("publish")).toBe(false); } + }); + +test("Windows kill refusal propagates even when a later liveness check would report exit", () => { + let reads = 0; + const fn = adapter("guardedKillProxy", { + process: { platform: "win32", env: {} }, isProcessAlive: () => ++reads === 1, waitForExit: () => true, + guardedExecFileSync: () => { throw new UpdateMutationRefusedError(); }, + }); + expect(() => fn(() => false, "runtime", 4242)).toThrow(UpdateMutationRefusedError); + expect(reads).toBe(1); +}); + +test("guarded TCP adapter carries the authorizer through netstat preparation", () => { + let home: "same" | "unknown" = "same", deletions = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const drop = load("src/server/windows-tcp-drop.ts", "dropWindowsTcpRowsForLocalPort", { + process: { platform: "win32" }, loadSetTcpEntry: () => () => { deletions++; return 0; }, + readNetstatAno: () => { home = "unknown"; return "fixture"; }, + parseTcpQuadsForLocalPort: () => [{ localAddr: "127.0.0.1", remoteAddr: "127.0.0.1", localPort: 19001, remotePort: 20000 }], + ipv4ToWinUint32: () => 1, htons: (n: number) => n, ptr: () => 0, + }); + const wrapped = adapter("guardedDropWindowsTcp", { dropWindowsTcpRowsForLocalPort: drop }); + expect(() => wrapped(policy.authorizeMutation, 19001)).toThrow(); + expect(deletions).toBe(0); +}); + +for (const changed of [true, false]) test(`reclaim default TCP callback retains its gate (${changed})`, async () => { + let home: "same" | "unknown" = "same", deletions = 0, probes = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const drop = load("src/server/windows-tcp-drop.ts", "dropWindowsTcpRowsForLocalPort", { + process: { platform: "win32" }, loadSetTcpEntry: () => () => { deletions++; return 0; }, + readNetstatAno: () => { if (changed) home = "unknown"; return "fixture"; }, + parseTcpQuadsForLocalPort: () => [{ localAddr: "127.0.0.1", remoteAddr: "127.0.0.1", localPort: 19001, remotePort: 20000 }], + ipv4ToWinUint32: () => 1, htons: (n: number) => n, ptr: () => 0, + }); + const reclaim = load("src/server/port-reclaim.ts", "reclaimListenPort", { + process: { platform: "win32", pid: 99 }, dropWindowsTcpRowsForLocalPort: drop, + normalizeListenPidScan: (pids: number[]) => ({ ok: true, pids }), + }); + const result = await reclaim(19001, "127.0.0.1", { + timeoutMs: 500, intervalMs: 0, scanIntervalMs: 0, dropTcpRows: true, + listListenPidsFn: () => [], isAliveFn: () => false, verifyOcxFn: () => null, killFn() {}, + isAvailableFn: async () => ++probes > 1, sleepMs: async () => {}, mayMutateFn: () => policy.authorizeMutation("runtime"), + }); + expect(result).toBe(!changed); expect(deletions).toBe(changed ? 0 : 1); +}); + +for (const phase of ["ffi", "netstat", "pointer", "second-row", "stable"]) + test(`TCP deletion authorizes each prepared native row after ${phase}`, () => { + let home: "same" | "unknown" = "same", deletions = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const drop = load("src/server/windows-tcp-drop.ts", "dropWindowsTcpRowsForLocalPort", { + process: { platform: "win32" }, + loadSetTcpEntry: () => { if (phase === "ffi") home = "unknown"; return () => { + deletions++; if (phase === "second-row") home = "unknown"; return 0; + }; }, + readNetstatAno: () => { if (phase === "netstat") home = "unknown"; return "fixture"; }, + parseTcpQuadsForLocalPort: () => Array.from({ length: 2 }, () => ({ localAddr: "127.0.0.1", remoteAddr: "127.0.0.1", localPort: 19001, remotePort: 20000 })), + ipv4ToWinUint32: () => 1, htons: (n: number) => n, + ptr: () => { if (phase === "pointer") home = "unknown"; return 0; }, + }); + const invoke = () => drop(19001, () => policy.authorizeMutation("runtime")); + if (phase === "stable") expect(invoke().dropped).toBe(2); + else expect(invoke).toThrow(); + expect(deletions).toBe(phase === "stable" ? 2 : phase === "second-row" ? 1 : 0); + if (phase !== "stable") { home = "same"; expect(policy.authorizeMutation("publish")).toBe(false); } + }); diff --git a/tests/update/update-guarded-ops.test.ts b/tests/update/update-guarded-ops.test.ts new file mode 100644 index 00000000000..2bf5ccf0b02 --- /dev/null +++ b/tests/update/update-guarded-ops.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "bun:test"; +import { guardedRun as run, materializeMutationData } from "../../src/update/mutation-guard.mjs"; +import { createUpdateServiceHomePolicy } from "../../src/update/service-home-policy.mjs"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; +import { guardedUpdateIo } from "../helpers/update-guarded-io.mjs"; +import { guardedPublishJob } from "../../src/update/guarded-runtime-ops"; +import { atomicWriteFile, renameAtomicFile, type AtomicWriteHooks } from "../../src/config/atomic-write"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +for (const shape of ["getter", "namedOptions", "spreadOptions", "memberOptions", "proxy"] as const) { + test(`materialize ${shape} before authorization; no native operation after authority changes`, () => { + let home: "same" | "unknown" = "same", calls = 0, reads = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const named = { get stdio() { reads++; home = "unknown"; return "ignore"; } }; + const options = shape === "spreadOptions" ? { ...named } : shape === "memberOptions" ? { stdio: named.stdio } + : shape === "proxy" ? new Proxy({ stdio: "ignore" }, { get(target, key) { reads++; home = "unknown"; return Reflect.get(target, key); } }) : named; + expect(() => run(policy.authorizeMutation, "runtime", (copy: any) => { + // A native implementation consumes options only after entry. + calls++; return copy.stdio; + }, [options])).toThrow(); + expect(calls).toBe(0); expect(reads).toBe(1); + home = "same"; + expect(policy.authorizeMutation("package")).toBe(false); + }); +} + +test("materialization rejects functions in data, including a named accessor returning a callback", () => { + let called = false; + for (const options of [{ callback() {} }, { get stdio() { return () => {}; } }, [() => {}], { [Symbol("callback")]: () => {} }]) { + expect(() => run(() => true, "runtime", () => { called = true; }, [options])).toThrow(); + } + expect(called).toBe(false); +}); + +for (const phase of ["snapshot", "directory", "missing-authorizer"]) test(`job publication checks after ${phase} preparation`, () => { + const source = readFileSync(repoPath("src/update/job.ts"), "utf8"); + const from = source.indexOf("function prepareUpdateJob("), to = source.indexOf("export function updateExecutionCommand", from); + const writer = source.slice(source.indexOf("function writeJob("), source.indexOf("export function readUpdateJob(")); + const body = new Bun.Transpiler({ loader: "ts" }).transformSync(writer + source.slice(from, to)); + let home: "same" | "unknown" = "same", writes = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const execute = new Function("deps", `with(deps){${body}; return writeJob(prepareUpdateJob({id:"fixture",log:[]},{status:"succeeded"}),authorize,"publish");}`); + expect(() => execute({ guardedRun: run, guardedPublishJob: (auth: any, kind: any, path: string, text: string) => run(auth, kind, () => { writes++; }, [path, text]), materializeMutationData, authorize: phase === "missing-authorizer" ? undefined : policy.authorizeMutation, + readUpdateJob: () => { if (phase === "snapshot") home = "unknown"; return null; }, + ensureJobDir: () => { if (phase === "directory") home = "unknown"; }, updateJobPath: () => "/fixture/job", + sanitizePersistedUpdateJob: (job: object) => job, atomicWriteFile: () => { writes++; } })).toThrow(); + expect(writes).toBe(0); +}); + +for (const phase of ["temp-preparation", "rename-retry", "stable"] as const) test(`publication rechecks at the actual atomic rename: ${phase}`, () => { + const root = mkdtempSync(join(tmpdir(), "ocx-publish-gate-")); + let home: "same" | "unknown" = "same", attempts = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const native: typeof atomicWriteFile = (path, text, _io, hooks: AtomicWriteHooks = {}) => atomicWriteFile(path, text, { + write() { if (phase === "temp-preparation") home = "unknown"; }, harden() {}, truncate() {}, unlink() {}, + rename: (from, to) => renameAtomicFile(from, to, { platform: "win32", + rename() { attempts++; if (phase === "rename-retry" && attempts === 1) throw Object.assign(new Error("fixture hold"), { code: "EBUSY" }); }, + sleep() { home = "unknown"; }, + }, "config", hooks), + }, hooks); + try { + const publish = () => guardedPublishJob(policy.authorizeMutation, "publish", join(root, "job.json"), '{"status":"succeeded"}', native); + if (phase === "stable") { publish(); expect(attempts).toBe(1); } + else { expect(publish).toThrow(); expect(attempts).toBe(phase === "temp-preparation" ? 0 : 1); } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +for (const phase of ["liveness", "grace-wait", "stable"] as const) test(`update kill checks immediately before every POSIX signal: ${phase}`, () => { + const source = readFileSync(repoPath("src/update/guarded-runtime-ops.ts"), "utf8"); + const from = source.indexOf("export function guardedKillProxy"), to = source.indexOf("export function guardedPublishJob", from); + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(source.slice(from, to).replace("export ", "")); + let home: "same" | "unknown" = "same", waits = 0; + const signals: string[] = [], policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const invoke = () => new Function("deps", `with(deps){${js};return guardedKillProxy(authorize,"runtime",123);}`)({ + authorize: policy.authorizeMutation, guardedRun: run, materializeMutationData, process: { platform: "linux" }, + isProcessAlive: () => { if (phase === "liveness") home = "unknown"; return true; }, + waitForExit: () => { waits++; if (phase === "grace-wait") home = "unknown"; return waits > 1; }, + guardedKill: (auth: any, kind: any, pid: number, signal: string) => run(auth, kind, (_pid: number, s: string) => { signals.push(s); }, [pid, signal]), + }); + if (phase === "stable") { invoke(); expect(signals).toEqual(["SIGTERM", "SIGKILL"]); } + else { expect(invoke).toThrow(); expect(signals).toEqual(phase === "liveness" ? [] : ["SIGTERM"]); } +}); + +test("an asynchronous authorizer never dispatches a primitive", () => { + let calls = 0; + expect(() => run((() => Promise.resolve(true)) as never, "runtime", () => { calls++; }, [])).toThrow(); + expect(calls).toBe(0); +}); + +test("materialized nested data is detached, null-prototype and read exactly once", () => { + let reads = 0; + const input = { get env() { reads++; return { FLAG: "yes" }; }, args: ["a", "b"] }; + const result = run(() => { expect(reads).toBe(1); return true; }, "runtime", (copy: any) => copy, [input]); + expect(result).not.toBe(input); expect(Object.getPrototypeOf(result)).toBeNull(); + expect(Object.getPrototypeOf(result.env)).toBeNull(); expect(result.args).toEqual(["a", "b"]); + expect(reads).toBe(1); +}); + +test("data materialization discards inherited getters without invoking them", () => { + let inheritedReads = 0; + const env = Object.assign(Object.create({ get HIDDEN() { inheritedReads++; return "bad"; } }), { FLAG: "yes" }); + const result = run(() => true, "runtime", copy => copy, [env]); + expect(result.FLAG).toBe("yes"); expect(result.HIDDEN).toBeUndefined(); expect(inheritedReads).toBe(0); +}); + +test("production pinned start rechecks after command accessors and closes its prepared log on refusal", () => { + const source = readFileSync(repoPath("src/update/job-pinned-start.ts"), "utf8"); + const body = source.slice(source.indexOf("export function spawnWorkerPinnedStart")).replace("export ", ""); + let home: "same" | "unknown" = "same", nativeCalls = 0, closes = 0; + const policy = createUpdateServiceHomePolicy(() => ({ kind: home }), () => {}); + const deps = { + cmd: { get bin() { home = "unknown"; return "fixture-native"; }, args: [], display: "fixture" }, + authorize: policy.authorizeMutation, process: { env: {} }, + withoutSiblingMarker: () => ({}), getConfigDir: () => "/fixture", join: (...parts: string[]) => parts.join("/"), + mkdirSync() {}, openSync: () => 7, writeSync() {}, closeSync: () => { closes++; }, + spawn: () => { nativeCalls++; return { once() {}, unref() {} }; }, + }; + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(body); + const execute = new Function("deps", `with(deps){${js};return spawnWorkerPinnedStart(cmd,authorize,()=>{},String);}`); + expect(() => execute({ ...deps, ...guardedUpdateIo(deps) })).toThrow(); + expect(nativeCalls).toBe(0); expect(closes).toBe(1); +}); diff --git a/tests/update/update-home-launcher.test.ts b/tests/update/update-home-launcher.test.ts new file mode 100644 index 00000000000..641a885e0b0 --- /dev/null +++ b/tests/update/update-home-launcher.test.ts @@ -0,0 +1,357 @@ +import { updateSourceFunction } from "../helpers/update-source-body"; +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 { helperPath, repoPath } from "../helpers/repo-root"; +import { prepareWorkerUpdateHome } from "../../src/update/job-update-home"; +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"; +import { UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION } from "../../src/update/service-home-policy.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); + const code = source.slice(from, to).replaceAll("import.meta.url", JSON.stringify("file:///fixture/ocx.mjs")); + return updateSourceFunction("deps", `with (deps) { ${code}; 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)"); + const end = source.indexOf(" guardedExit(homePolicy.authorizeMutation, \"publish\", 0);", start); + const run = updateSourceFunction("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)"); + const end = source.indexOf(" guardedExit(homePolicy.authorizeMutation, \"publish\", 0);", start); + const run = updateSourceFunction("deps", `with (deps) { if (false) { ${source.slice(start, end)} process.exit(0); }`); + for (const result of ["vetoed", "refused", 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 || result === "vetoed" ? 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 ? "refused" : 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"]); +}); + +for (const changed of [false, true]) test(`Node absent-service install checks home after backend read (${changed})`, () => { + const start = source.indexOf(" function serviceInstallArgs()"), end = source.indexOf(" /**", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const refreshStart = source.indexOf(" function refreshBackgroundServiceOrStartDirect()"); + const refreshEnd = source.indexOf(" let updateLease =", refreshStart); + expect(refreshStart).toBeGreaterThan(0); expect(refreshEnd).toBeGreaterThan(refreshStart); + const run = updateSourceFunction("deps", `with (deps) { ${source.slice(start, end)}\n${source.slice(refreshStart, refreshEnd)}; return 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: {}, execPath: "fixture" }, bakePort: 12345, + serviceRefreshArgs: () => ["repair"], mutationChildEnvironment: () => ({}), + readServiceInstalledFromStatus: () => false, + readServiceState: () => { events.push("backend"); if (changed) home = { kind: "unknown" }; return { kind: "state", state: { backend: "native" } }; }, + spawnSync: (_bin: string, args: string[]) => { events.push(args.includes("install") ? `install:${home.kind}` : "repair"); return { status: 1 }; }, + restartThroughServiceManager: () => { events.push("manager"); return "supervised"; }, + startProxyDirectly: () => { throw new Error("unexpected direct fallback"); }, + }); + expect(result).toBe(changed ? "refused" : undefined); + expect(events).toEqual(changed ? ["repair", "backend"] : ["repair", "backend", "install:same", "manager"]); +}); + +test("the installed test Bun honors --no-env-file while the control loads the fixture dotenv", () => { + const box = createTempHome("ocx-update-home-dotenv-"); + try { + writeFileSync(box.path(".env"), "OCX_UPDATE_PROBE_DOTENV=from-fixture\n"); + writeFileSync(box.path("probe.js"), 'console.log(process.env.OCX_UPDATE_PROBE_DOTENV ?? "absent");'); + const env = { ...process.env, HOME: box.root }; + delete env.OCX_UPDATE_PROBE_DOTENV; + for (const disabled of [false, true]) { + const result = Bun.spawnSync([process.execPath, ...(disabled ? ["--no-env-file"] : []), box.path("probe.js")], + { cwd: box.root, env, stdout: "pipe", stderr: "pipe", timeout: 8000 }); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe(disabled ? "absent" : "from-fixture"); + } + } finally { box.remove(); } +}); + +for (const delegated of [false, true]) test(`Node package-only success reports a ${delegated ? "restricted worker" : "CLI"} result before recovery`, () => { + const start = source.indexOf(" if (homePolicy.packageOnly) {", source.indexOf(" const postInstallPlan")); + const end = source.indexOf(' if (!homePolicy.authorizeMutation("publish")) {', start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const run = updateSourceFunction("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 = updateSourceFunction("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 = updateSourceFunction("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 = updateSourceFunction("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)) }, + UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION, + })).toThrow("exit"); + expect(events).toEqual(extra ? [2] : [{ schemaVersion: 1, packageOnlyProtocolVersion: 2 }, 0]); + } +}); + +for (const [parent, observed, exit] of [ + ["same", "unknown", 1], ["absent", "unknown", 1], ["foreign", "unknown", 1], + ["unknown", "same", 1], ["unknown", "unknown", 75], +] as const) test(`the real Node child preserves ${parent} parent authority when observing ${observed}`, () => { + const box = createTempHome("ocx-node-home-child-"); + try { + const command = { bin: "node", args: [repoPath("bin/ocx.mjs"), "update"], display: "" }; + expect(prepareWorkerUpdateHome(command, "npm", command.args[0]!, "node", "latest", () => {}, + () => ({ kind: parent, ...(parent === "foreign" ? { subject: "a".repeat(64) } : {}) }), () => true)).not.toBeNull(); + const child = Bun.spawnSync(["node", helperPath("update-node-home-child.mjs"), box.root, observed, ...command.args.slice(2)], + { env: { ...process.env }, stdout: "pipe", stderr: "pipe", timeout: 10_000 }); + const log = child.stdout.toString() + child.stderr.toString(); + expect(child.exitCode, log).toBe(exit); + const receipt = child.stdout.toString().split("\n").find(line => line.startsWith("NODE_HOME_PROBE:")); + expect(receipt, log).toBeDefined(); + const trace = JSON.parse(receipt!.slice("NODE_HOME_PROBE:".length)) as string[]; + expect(trace.filter(phase => phase === "replacement")).toHaveLength(exit === 75 ? 1 : 0); + expect(trace[0]).toBe("home"); + if (exit === 75) { expect(trace).toContain("release"); expect(log).toContain("Restart the proxy:"); } + } finally { box.remove(); } +}); + +for (const baseline of [["--update-worker-home"], ["--update-worker-home=same"], + ["--update-worker-home", "same", "--update-worker-home", "unknown"]]) test(`the real Node child refuses malformed baseline ${baseline.join(" ")}`, () => { + const box = createTempHome("ocx-node-home-invalid-"); + try { + const child = Bun.spawnSync(["node", helperPath("update-node-home-child.mjs"), box.root, "unknown", ...baseline], + { env: { ...process.env }, stdout: "pipe", stderr: "pipe", timeout: 10_000 }); + const log = child.stdout.toString() + child.stderr.toString(); + expect(child.exitCode, log).toBe(1); + expect(log).toContain('NODE_HOME_PROBE:["home"]'); + expect(log).toContain("Automatic update restart refused"); + } finally { box.remove(); } +}); + +for (const scenario of ["post-refusal-running", "post-refusal-stopped", "post-refusal-unverified"]) test(`the real Node updater restores only an own stopped tray on ${scenario}`, () => { + const box = createTempHome("ocx-node-home-tray-refusal-"); + try { + const child = Bun.spawnSync(["node", helperPath("update-node-home-child.mjs"), box.root, scenario, + "--update-worker-home", "absent", "--update-worker-result"], + { env: { ...process.env }, stdout: "pipe", stderr: "pipe", timeout: 10_000 }); + const log = child.stdout.toString() + child.stderr.toString(); + expect(child.exitCode, log).toBe(1); + const receipt = child.stdout.toString().split("\n").find(line => line.startsWith("NODE_HOME_PROBE:")); + expect(receipt, log).toBeDefined(); + const trace = JSON.parse(receipt!.slice("NODE_HOME_PROBE:".length)) as string[]; + expect(trace.filter(event => event === "replacement")).toEqual(["replacement"]); + expect(trace.filter(event => event.startsWith("tray:"))).toEqual(scenario === "post-refusal-running" + ? ["tray:stop", "tray:start"] : scenario === "post-refusal-stopped" ? [] : ["tray:stop"]); + expect(log).toContain("Automatic update restart refused"); + } 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..ac23814677f --- /dev/null +++ b/tests/update/update-home-recovery.test.ts @@ -0,0 +1,264 @@ +import { updateSourceFunction } from "../helpers/update-source-body"; +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, afterStart = false) { + const marker = source.indexOf(start); + expect(marker).toBeGreaterThan(0); + const from = marker + (afterStart ? start.length : 0), to = source.indexOf(end, from); + expect(to).toBeGreaterThan(from); + const code = new Bun.Transpiler({ loader: "ts" }).transformSync(`async function run() { + ${source.slice(from, to)} + return ${result}; + }`); + return updateSourceFunction("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; + +for (const changed of [false, true]) test(`Bun direct recovery checks home after runtime provenance I/O (${changed})`, async () => { + let home: UpdateServiceHomeAuthority = { kind: "same" }; + const events: string[] = []; + const result = await direct({ + homePolicy: createUpdateServiceHomePolicy(() => home, () => {}), + mutation: { reacquire() {}, controlEnvironment: () => ({}) }, + resolvedRuntimeOwnership: async () => ({ ownership: null }), sameOwner: () => true, + 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) => { events.push("provenance"); if (changed) home = { kind: "unknown" }; return env; }, + spawn: () => { events.push(`spawn:${home.kind}`); 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(result).toBe(changed ? "refused" : true); + expect(events).toEqual(changed ? ["provenance"] : ["provenance", "spawn:same"]); +}); + +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 = updateSourceFunction("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; + let 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("refused"); + expect(events).not.toContain("spawn"); expect(events).not.toContain("owner"); + home = initial; events.length = 0; + homePolicy = createUpdateServiceHomePolicy(() => { events.push("home"); return home; }, () => {}); + 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"); + } + } +}); + +const postReplacementRefusal = extracted(" // Authority gates every post-replacement control action", " if (!postInstallPlan.mayStopRuntime)", '"continued"'); +for (const running of [false, true]) for (const verified of [false, true]) test(`Bun home refusal restores only a running own tray through a verified launcher (${running}/${verified})`, async () => { + const commands: string[][] = []; + const result = await postReplacementRefusal({ + homePolicy: { check: () => false }, trayWasRunning: running, postUpdateLauncherUsable: verified, + postUpdateLauncher: "/verified/launcher", existsSync: () => true, process: { execPath: "/fixture/bun" }, + mutation: { unprivilegedEnvironment: () => ({ fixture: "unprivileged" }) }, + spawnSync: (_bin: string, args: string[], options: { timeout: number; env: object }) => { + commands.push(args); expect(options.timeout).toBe(15_000); expect(options.env).toEqual({ fixture: "unprivileged" }); + return { status: 0 }; + }, + }); + expect(result).toBe(1); + expect(commands).toEqual(running && verified ? [["/verified/launcher", "tray", "start"]] : []); +}); + +test("Bun home refusal preserves the refusal if the verified tray restore throws or its launcher is absent", async () => { + for (const present of [false, true]) { + let starts = 0; + expect(await postReplacementRefusal({ homePolicy: { check: () => false }, trayWasRunning: true, + postUpdateLauncherUsable: true, postUpdateLauncher: "/verified/launcher", existsSync: () => present, + process: { execPath: "/fixture/bun" }, mutation: { unprivilegedEnvironment: () => ({}) }, + spawnSync: () => { starts++; throw new Error("fixture tray failure"); }, + })).toBe(1); + expect(starts).toBe(present ? 1 : 0); + } +}); + +const postShimImport = extracted(" // Re-enter through the verified active package launcher.", " // The stop above unloaded any managed service", '"continued"'); +const shimGateDeps = (check: () => boolean, events: { checks: number; restored: number; commands: string[][] }) => ({ + homePolicy: { check: () => { events.checks++; return check(); } }, + restoreOwnStoppedTray: () => { events.restored++; }, + trayWasInstalled: true, trayWasRunning: true, postUpdateLauncher: "/verified/launcher", + existsSync: () => true, process: { execPath: "/fixture/bun" }, + mutation: { unprivilegedEnvironment: () => ({ fixture: "unprivileged" }) }, + planWindowsTrayUpdate: () => ({ installArgs: ["tray", "install"] }), + spawnSync: (_bin: string, args: string[]) => { events.commands.push(args); return { status: 0 }; }, + console: { warn() {}, log() {} }, +}); + +test("a home change during the shim import vetoes the shim rewrite and the tray refresh", async () => { + const events = { checks: 0, restored: 0, commands: [] as string[][] }; + expect(await postShimImport(shimGateDeps(() => false, events))).toBe(1); + // Authority was re-read after the import await: the refusal restores only the own tray + // and no control command (shim rewrite, tray refresh) may run. + expect(events.checks).toBe(1); + expect(events.restored).toBe(1); + expect(events.commands).toEqual([]); +}); + +test("a stable home still runs the shim gate and the tray refresh in order", async () => { + const events = { checks: 0, restored: 0, commands: [] as string[][] }; + expect(await postShimImport(shimGateDeps(() => true, events))).toBe("continued"); + expect(events.checks).toBe(4); + expect(events.restored).toBe(0); + // The shim spawn is conditional on a real installation; the tray refresh is the always-on + // control action and must be the last command the gate released. + expect(events.commands[events.commands.length - 1]).toEqual(["/verified/launcher", "tray", "install"]); +}); + +// Extracted after the shim-module import so the probe itself is injectable: filesystem +// failures inside it must degrade inside the guarded block, not escape the update. +const postShimGate = extracted(" // The module load above is the last await", " // The stop above unloaded any managed service", '"continued"'); +const shimProbeDeps = (probe: () => boolean, events: { checks: number; restored: number; commands: string[][]; warnings: number }) => ({ + homePolicy: { check: () => { events.checks++; return true; } }, + restoreOwnStoppedTray: () => { events.restored++; }, + isCodexShimInstalled: probe, + trayWasInstalled: true, trayWasRunning: true, postUpdateLauncher: "/verified/launcher", + process: { execPath: "/fixture/bun" }, + mutation: { unprivilegedEnvironment: () => ({ fixture: "unprivileged" }) }, + planWindowsTrayUpdate: () => ({ installArgs: ["tray", "install"] }), + spawnSync: (_bin: string, args: string[]) => { events.commands.push(args); return { status: 0 }; }, + console: { warn: () => { events.warnings++; }, log() {} }, +}); + +test("a throwing shim probe degrades to a warning and authorized recovery continues", async () => { + const events = { checks: 0, restored: 0, commands: [] as string[][], warnings: 0 }; + const result = await postShimGate(shimProbeDeps(() => { throw new Error("fixture probe failure"); }, events)); + expect(result).toBe("continued"); + expect(events.checks).toBe(4); + expect(events.restored).toBe(0); + expect(events.warnings).toBeGreaterThanOrEqual(1); + // No shim rewrite ran; the authorized tray refresh still runs afterwards. + expect(events.commands).toEqual([["/verified/launcher", "tray", "install"]]); +}); + +test("an installed shim is repaired inside the same guarded block before the tray refresh", async () => { + const events = { checks: 0, restored: 0, commands: [] as string[][], warnings: 0 }; + const result = await postShimGate(shimProbeDeps(() => true, events)); + expect(result).toBe("continued"); + expect(events.warnings).toBe(0); + expect(events.commands).toEqual([ + ["/verified/launcher", "codex-shim", "install"], + ["/verified/launcher", "tray", "install"], + ]); +}); + +// The CLI's reclaim wait gets its own latched gate: a mid-wait denial must stay +// recorded even if the registration re-matches before the wait returns false. +const reclaimLatchSlice = extracted( + ' const { guardedReclaimListenPort: reclaimListenPort } = await import("./guarded-port-reclaim");', + " // \x60service repair\x60 re-activates", + '"continued"', + true, +); + +test("a latched reclaim denial outranks a transient re-match before the wait ends", async () => { + const warnings: string[] = []; + let home: "same" | "foreign" = "same"; + let deniedInWait = false; + const result = await reclaimLatchSlice({ + capturedListen: { port: 12345, hostname: "127.0.0.1", oldPid: 4242 }, + homePolicy: { check: () => home === "same" }, + reclaimListenPort: async (_port: number, _host: string, opts: { mayMutateFn?: () => boolean }) => { + home = "foreign"; + deniedInWait = opts?.mayMutateFn?.() === false; + home = "same"; // a transient re-match must not reopen the recorded denial + return false; + }, + console: { warn: (line: string) => warnings.push(line) }, + }); + expect(result).toBe(1); + expect(deniedInWait).toBe(true); + expect(warnings.some(line => line.includes("still busy"))).toBe(false); +}); + +test("an ordinary reclaim timeout under stable authority still warns and continues", async () => { + const warnings: string[] = []; + const result = await reclaimLatchSlice({ + capturedListen: { port: 12345, hostname: "127.0.0.1", oldPid: null }, + homePolicy: { check: () => true }, + reclaimListenPort: async () => false, + console: { warn: (line: string) => warnings.push(line) }, + }); + expect(result).toBe("continued"); + expect(warnings.some(line => line.includes("still busy"))).toBe(true); +}); + +test("a throwing reclaim authority gate stays denied after the probe recovers", async () => { + const warnings: string[] = []; + let throwProbe = false, deniedInWait = false; + const result = await reclaimLatchSlice({ + capturedListen: { port: 12345, hostname: "127.0.0.1", oldPid: 4242 }, + homePolicy: { check: () => { if (throwProbe) throw new Error("fixture authority unavailable"); return true; } }, + reclaimListenPort: async (_port: number, _host: string, opts: { mayMutateFn?: () => boolean }) => { + throwProbe = true; + try { deniedInWait = opts?.mayMutateFn?.() === false; } + catch { deniedInWait = false; } + throwProbe = false; + return false; + }, + console: { warn: (line: string) => warnings.push(line) }, + }); + expect(result).toBe(1); + expect(deniedInWait).toBe(true); + expect(warnings.some(line => line.includes("still busy"))).toBe(false); +}); diff --git a/tests/update/update-home-worker.test.ts b/tests/update/update-home-worker.test.ts new file mode 100644 index 00000000000..099d0cf3254 --- /dev/null +++ b/tests/update/update-home-worker.test.ts @@ -0,0 +1,313 @@ +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { createTempHome } from "../helpers/temp-home"; +import { automaticUpdateLauncherSupported, packageOnlyUpdateSucceeded, prepareWorkerUpdateHome } from "../../src/update/job-update-home"; +import { join } from "node:path"; +import { readUpdateJob, runGuiUpdateWorker } from "../../src/update/job"; +import type { UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; + +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" }), + serviceHomeAuthorityFn: () => ({ kind: "same" }), + launcherSupportsUpdateHomeFn: (launcher, node) => { + expect(launcher).toMatch(/bin[\\/]ocx\.mjs$/); expect(node).toBeTruthy(); events.push("probe"); return false; + }, + runCommandFn: () => { events.push("update"); return { status: 0, signal: null }; }, + }); + expect(events).toEqual(["probe"]); + expect(readUpdateJob("old-launcher")?.status).toBe("failed"); + expect(readUpdateJob("old-launcher")?.error).toContain("guarded recovery protocol"); + } finally { + if (priorHome === undefined) delete process.env.HOME; else process.env.HOME = priorHome; + box.remove(); + } +}); + +test("package-only capability can be verified without home or Bun inspection", () => { + const box = createTempHome("ocx-update-home-protocol-"); + try { + mkdirSync(join(box.root, "bin")); mkdirSync(join(box.root, "src", "update"), { recursive: true }); + writeFileSync(join(box.root, "src", "update", "service-home-protocol.json"), JSON.stringify({ + schemaVersion: 1, launcherProtocolVersion: 1, entry: "__update-home-probe", + })); + writeFileSync(join(box.root, "src", "update", "service-home-package-only-protocol.json"), JSON.stringify({ + schemaVersion: 1, packageOnlyProtocolVersion: 2, entry: "__update-package-only-probe", + })); + const launcher = join(box.root, "bin", "ocx.mjs"); + writeFileSync(launcher, 'console.log(JSON.stringify({schemaVersion:1,packageOnlyProtocolVersion:2}));'); + expect(automaticUpdateLauncherSupported(launcher, process.execPath)).toBe(true); + // A v1 launcher cannot enforce a pinned baseline; mixed-version delegation fails closed. + writeFileSync(launcher, 'console.log(JSON.stringify({schemaVersion:1,packageOnlyProtocolVersion:1}));'); + expect(automaticUpdateLauncherSupported(launcher, process.execPath)).toBe(false); + writeFileSync(launcher, 'console.log("malformed");'); + expect(automaticUpdateLauncherSupported(launcher, process.execPath)).toBe(false); + writeFileSync(join(box.root, "src", "update", "service-home-package-only-protocol.json"), JSON.stringify({ + schemaVersion: 1, packageOnlyProtocolVersion: 1, entry: "__update-package-only-probe", + })); + const bootMarker = join(box.root, "bootstrap-ran"); + writeFileSync(launcher, `import {writeFileSync} from 'node:fs'; writeFileSync(${JSON.stringify(bootMarker)}, 'executed');`); + expect(automaticUpdateLauncherSupported(launcher, process.execPath)).toBe(false); + expect(existsSync(bootMarker)).toBe(false); // A v1 child's bootstrap is never executed. + } 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-home", "unknown", "--update-worker-result"]); + expect(bin).toBeTruthy(); events.push("update"); return { status: 0, signal: null }; + }, + trayHandoffFn: () => { throw new Error("package-only must never touch the tray"); }, + trayCommandFn: () => { throw new Error("package-only must never run tray commands"); }, + }); + expect(events).toEqual(["update"]); + const job = readUpdateJob(`unknown-${installer}`); + expect(job).toMatchObject({ status: "succeeded", restarted: false }); + // The persisted command must survive renderSafeCommand: bare node name plus the launcher + // path placeholder, like the ordinary npm/pnpm worker display — never an ocx literal that + // would be withheld as an unrecognized shape. + expect(job?.command).toBe("node update --tag latest --update-package-only"); + 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("a spaced Windows launcher path still yields a recognizable package-only display", () => { + // renderSafeCommand splits on \s and only accepts bare tool tokens: a raw `C:\Program Files\...` + // display would withhold on both counts (split path, `node.exe` tool). The persisted display + // must carry the pre-sanitized `` placeholder while bin/args keep the real spaced path. + const command = { bin: "placeholder", args: [] as string[], display: "" }; + const reports: string[] = []; + const policy = prepareWorkerUpdateHome(command, "npm", + "C:\\Program Files\\OpenCodex\\bin\\ocx.mjs", "node.exe", "latest", + message => reports.push(message), + () => ({ kind: "unknown" }), + () => true); + expect(policy?.packageOnly).toBe(true); + expect(command.bin).toBe("node.exe"); + expect(command.args).toEqual([ + "C:\\Program Files\\OpenCodex\\bin\\ocx.mjs", "update", "--tag", "latest", + "--update-package-only", "--update-worker-home", "unknown", "--update-worker-result", + ]); + expect(command.display).toBe("node update --tag latest --update-package-only"); +}); + +test("a home authority change after install persists the refusal as the job error", async () => { + const box = createTempHome("ocx-update-home-late-refusal-"); + mkdirSync(box.codexHome, { recursive: true }); + try { + let flipped = false; + await runGuiUpdateWorker("late-refusal", "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: () => flipped ? { kind: "foreign", subject: "b".repeat(64) } : { kind: "same" }, + runCommandFn: () => { flipped = true; return { status: 0, signal: null }; }, + }); + const job = readUpdateJob("late-refusal"); + expect(job).toMatchObject({ status: "failed", restarted: false }); + // The refusal text the policy reported must reach `error`, not only `log` — the dashboard + // surfaces `error` as the failure reason. + expect(job?.error).toContain("Automatic update restart refused"); + expect(job?.error).toContain("ocx service status"); + } 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); +}); + +test("a home flip during the worker preflight refuses before any command is shaped", () => { + const reports: string[] = []; + let calls = 0; + const command = { bin: "placeholder", args: [] as string[], display: "" }; + const policy = prepareWorkerUpdateHome(command, "npm", "C:\\ocx\\bin\\ocx.mjs", "node", "latest", + message => reports.push(message), + () => (calls++, calls === 1 ? { kind: "same" } : { kind: "foreign", subject: "b".repeat(64) }), + () => true); + // The first probe is the baseline; the flipped second probe already fails checkPackage + // inside prepareWorkerUpdateHome, so this must refuse before any command is shaped. + expect(policy).toBeNull(); + expect(reports.join()).toContain("Automatic update restart refused"); +}); + +test("the guarded launcher argv carries the parent baseline; the raw bun lane stays raw", () => { + const foreign = { kind: "foreign", subject: "b".repeat(64) } as const; + const npm = { bin: "placeholder", args: ["C:\\ocx\\bin\\ocx.mjs", "update", "--tag", "latest"], display: "" }; + expect(prepareWorkerUpdateHome(npm, "npm", "C:\\ocx\\bin\\ocx.mjs", "node", "latest", + () => {}, () => foreign, () => true)?.initial).toEqual(foreign); + expect(npm.args).toEqual(["C:\\ocx\\bin\\ocx.mjs", "update", "--tag", "latest", + "--update-worker-home", `foreign:${foreign.subject}`, "--update-worker-result"]); + const bun = { bin: "bun", args: ["add", "-g", "@bitkyc08/opencodex@latest"], display: "" }; + expect(prepareWorkerUpdateHome(bun, "bun", "C:\\ocx\\bin\\ocx.mjs", "node", "latest", + () => {}, () => ({ kind: "same" }), () => true)?.initial).toEqual({ kind: "same" }); + expect(bun.args).toEqual(["add", "-g", "@bitkyc08/opencodex@latest"]); +}); + +test("a home flip between the tray handoff and the update command refuses before replacement", async () => { + // The raw package-manager lane cannot carry the baseline argv; the worker's own recheck + // at the replacement boundary is the only gate. Failing it must not run the command. + const box = createTempHome("ocx-update-home-boundary-"); + mkdirSync(box.codexHome, { recursive: true }); + try { + let handedOff = false; + const events: string[] = []; + const trayCommands: string[][] = []; + await runGuiUpdateWorker("boundary-refusal", "latest", false, { + checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", channel: "latest", + installer: "bun", updateAvailable: true, canUpdate: true, command: "fixture", releaseNotesUrl: "fixture" }), + integrityFn: () => ({ ok: true, integrity: "sha512-testfixturevalue000000000" }), + cachePreflightFn: () => ({ ok: true, reason: "ok" }), + launcherSupportsUpdateHomeFn: () => true, + serviceHomeAuthorityFn: () => handedOff ? { kind: "foreign", subject: "b".repeat(64) } : { kind: "same" }, + trayHandoffFn: () => { handedOff = true; return { installed: true, running: true, stopBeforeReplacement: true, + refreshAfterReplacement: true, restoreOnFailure: true, installArgs: ["tray", "install"] }; }, + trayCommandFn: (_job, _bin, args) => { trayCommands.push(args.slice(1)); return { status: 0, signal: null }; }, + runCommandFn: () => { events.push("update"); return { status: 0, signal: null }; }, + }); + expect(events).toEqual([]); + expect(trayCommands).toEqual([["tray", "start"]]); // Only the own stopped tray is restored. + const job = readUpdateJob("boundary-refusal"); + expect(job).toMatchObject({ status: "failed", restarted: false }); + expect(job?.error).toContain("Automatic update restart refused"); + } finally { box.remove(); } +}); + +for (const wasRunning of [false, true]) test(`a home flip after a successful update installs no tray artifact (previously running: ${wasRunning})`, async () => { + const box = createTempHome("ocx-update-home-tray-gate-"); + mkdirSync(box.codexHome, { recursive: true }); + try { + let replaced = false; + const trayCommands: string[][] = []; + await runGuiUpdateWorker("tray-gate", "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: () => true, + serviceHomeAuthorityFn: () => replaced ? { kind: "foreign", subject: "b".repeat(64) } : { kind: "same" }, + trayHandoffFn: () => ({ installed: true, running: wasRunning, stopBeforeReplacement: wasRunning, + refreshAfterReplacement: true, restoreOnFailure: wasRunning, installArgs: ["tray", "install"] }), + trayCommandFn: (_job, _bin, args) => { trayCommands.push(args.slice(1)); return { status: 0, signal: null }; }, + runCommandFn: (_job, _bin, args) => { + expect(args.slice(-3)).toEqual(["--update-worker-home", "same", "--update-worker-result"]); + replaced = true; + return { status: 0, signal: null }; + }, + }); + // Refusal restores only the own tray stopped before replacement — no install, no refresh. + expect(replaced).toBe(true); + expect(trayCommands).toEqual(wasRunning ? [["tray", "start"]] : []); + const job = readUpdateJob("tray-gate"); + expect(job).toMatchObject({ status: "failed", restarted: false }); + expect(job?.error).toContain("Automatic update restart refused"); + } finally { box.remove(); } +}); + +test("a stable matching home still refreshes the own tray after the update command", async () => { + const box = createTempHome("ocx-update-home-tray-ok-"); + mkdirSync(box.codexHome, { recursive: true }); + try { + const trayCommands: string[][] = []; + await runGuiUpdateWorker("tray-refresh", "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: () => true, + serviceHomeAuthorityFn: () => ({ kind: "same" }), + trayHandoffFn: () => ({ installed: true, running: true, stopBeforeReplacement: true, + refreshAfterReplacement: true, restoreOnFailure: true, installArgs: ["tray", "install"] }), + trayCommandFn: (_job, _bin, args) => { trayCommands.push(args.slice(1)); return { status: 0, signal: null }; }, + runCommandFn: () => ({ status: 0, signal: null }), + }); + expect(trayCommands).toEqual([["tray", "install"]]); + expect(readUpdateJob("tray-refresh")).toMatchObject({ status: "succeeded", restarted: false }); + } finally { box.remove(); } +}); + +for (const initial of [{ kind: "absent" }, { kind: "foreign", subject: "a".repeat(64) }] as const) test(`the actual worker pins ${initial.kind} authority across the recovery lease boundary`, async () => { + const box = createTempHome("ocx-worker-recovery-baseline-"); + mkdirSync(box.codexHome, { recursive: true }); + try { + let home: UpdateServiceHomeAuthority = initial; + const events: string[] = []; + const controls: string[] = []; + await runGuiUpdateWorker("recovery-baseline", "latest", true, { + checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", channel: "latest", + installer: "bun", updateAvailable: true, canUpdate: true, command: "fixture", releaseNotesUrl: "fixture" }), + integrityFn: () => ({ ok: true, integrity: "sha512-testfixturevalue000000000" }), + cachePreflightFn: () => ({ ok: true, reason: "ok" }), + serviceHomeAuthorityFn: () => home, + trayHandoffFn: () => ({ installed: false, running: false, stopBeforeReplacement: false, + refreshAfterReplacement: false, restoreOnFailure: false, installArgs: ["tray", "install", "--no-start"] }), + runCommandFn: () => { events.push("replacement"); return { status: 0, signal: null }; }, + // This callback runs under the restart lease, AFTER the worker's last home check, + // immediately before finishGuiUpdateRestart calls the real restartAfterUpdate. + resolveOwnershipFn: () => { + events.push("restart-lease"); + home = initial.kind === "absent" ? { kind: "same" } : { kind: "foreign", subject: "b".repeat(64) }; + return { kind: "none", revision: 0 }; + }, + restartIo: { + serviceInstalledFn: () => true, serviceViableFn: () => false, + stopServiceWrappersFn: () => { controls.push("wrappers"); }, + waitForPort: async () => { controls.push("reclaim"); return true; }, + runService: () => { controls.push("repair"); return { status: 1 }; }, + restartServiceManagerFn: async () => { controls.push("manager"); return "failed"; }, + spawnStart: () => { controls.push("start"); }, readPidFn: () => null, listListenPidsFn: () => [], + probeProxy: async () => false, healthTimeoutMs: 0, + }, + }); + expect(events).toEqual(["replacement", "restart-lease"]); + expect(controls).toEqual([]); + expect(readUpdateJob("recovery-baseline")).toMatchObject({ status: "failed", restarted: false }); + expect(readUpdateJob("recovery-baseline")?.error).toContain("Automatic update restart refused"); + } finally { box.remove(); } +}); diff --git a/tests/update/update-job-supervised-restart.test.ts b/tests/update/update-job-supervised-restart.test.ts index ef127b74f62..4c7673c6716 100644 --- a/tests/update/update-job-supervised-restart.test.ts +++ b/tests/update/update-job-supervised-restart.test.ts @@ -1,14 +1,16 @@ +import { sameHomeRestartIo, absentHomeRestartIo } from "../helpers/update-home-fixtures"; /** * Dashboard update worker: manager-first recovery after a failed service refresh (#7043), the * src/update/job.ts port of what #7074 did for `ocx update`. Every manager call is injected. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { finishGuiUpdateRestart, readUpdateJob, restartAfterUpdateForTests, updateJobPath, type RestartIo, type UpdateJobState, } from "../../src/update/job"; +import * as managerRestart from "../../src/update/job-supervised-restart"; import { restartServiceManagerAfterUpdate, type ManagerRestartDeps } from "../../src/update/job-supervised-restart"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -40,6 +42,7 @@ describe("restartServiceManagerAfterUpdate", () => { function deps(over: ManagerRestartDeps = {}): ManagerRestartDeps { let now = 0; return { + serviceHomeAuthorityFn: () => ({ kind: "same" }), hasManager: () => true, restartJob: () => ({ ok: true }), managerPid: () => 50, @@ -116,8 +119,33 @@ describe("restartServiceManagerAfterUpdate", () => { }); describe("dashboard restart after a failed service refresh", () => { + test("the same-home fallback fixture releases its lease without consulting the host manager", async () => { + const events: string[] = []; + expect(sameHomeRestartIo.restartServiceManagerFn).toBeFunction(); + const outcome = await sameHomeRestartIo.restartServiceManagerFn!( + { port: 19100, hostname: "127.0.0.1", release: () => { events.push("release"); } }, + line => events.push(line), + ); + expect(outcome).toBe("failed"); + expect(events).toEqual(["release"]); + const hostManager = spyOn(managerRestart, "restartServiceManagerAfterUpdate").mockImplementation(async () => { + throw new Error("fallback fixture reached the host manager"); + }); + try { + const spawned = { count: 0 }; + const job = freshJob("fixture-manager-isolated"); + const result = await restartAfterUpdateForTests(job, { port: 19100, hostname: "127.0.0.1" }, { + ...managedIo("failed", spawned), ...sameHomeRestartIo, + }); + expect(result).toBe("unsupervised"); + expect(spawned.count).toBe(1); + expect(hostManager).not.toHaveBeenCalled(); + } finally { hostManager.mockRestore(); } + }); + function managedIo(manager: "supervised" | "failed" | "unsettled", spawned: { count: number }): RestartIo { return { + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => false, listListenPidsFn: () => [], @@ -176,6 +204,7 @@ describe("dashboard restart after a failed service refresh", () => { const spawned = { count: 0 }; const result = await restartAfterUpdateForTests(job, { port: 19103, hostname: "127.0.0.1" }, { ...managedIo("failed", spawned), + ...absentHomeRestartIo, serviceInstalledFn: () => false, restartServiceManagerFn: async () => { managerCalls++; return "failed"; }, }); diff --git a/tests/update/update-job.test.ts b/tests/update/update-job.test.ts index 2e6c9b16da9..fe4780a2aa4 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"; @@ -72,6 +73,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: () => [], @@ -586,6 +588,11 @@ describe("GUI update execution decisions", () => { let restartLauncher = ""; let now = 0; await runGuiUpdateWorker("pnpm-active-launcher", "latest", true, { + launcherSupportsUpdateHomeFn: () => true, + // Pin the worker's home policy the way the restart IO already does: without this the + // real inspector answers foreign on any host with a registered service and the job + // fails before the restart path this test measures. + serviceHomeAuthorityFn: () => ({ kind: "absent" }), checkForUpdateFn: () => ({ currentVersion: "2.7.40", latestVersion: "2.7.41", @@ -610,6 +617,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?.() ?? ""; @@ -657,6 +665,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: () => [], @@ -669,6 +678,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: () => [], @@ -703,6 +713,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, @@ -754,6 +765,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) => { @@ -797,6 +809,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) => { @@ -829,6 +842,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], @@ -859,6 +873,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], @@ -889,6 +904,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], @@ -923,6 +939,7 @@ describe("GUI update execution decisions", () => { try { await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 18765, hostname: "127.0.0.1" }, { ...HOST_FREE_RECOVERY_IO, + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, // The service-recovery gate now asks the port before skipping the direct-start @@ -971,6 +988,7 @@ describe("GUI update execution decisions", () => { try { await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19999, hostname: "127.0.0.1" }, { ...HOST_FREE_RECOVERY_IO, + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => false, waitForPort: async () => true, @@ -1017,6 +1035,7 @@ describe("GUI update execution decisions", () => { await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19998, hostname: "127.0.0.1" }, { ...HOST_FREE_RECOVERY_IO, platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, waitForPort: async () => true, @@ -1062,6 +1081,7 @@ describe("GUI update execution decisions", () => { await expect(withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19010, hostname: "127.0.0.1" }, { ...HOST_FREE_RECOVERY_IO, platform: "win32", + ...sameHomeRestartIo, serviceInstalledFn: () => true, serviceViableFn: () => true, waitForPort: async () => true, @@ -1096,6 +1116,7 @@ describe("GUI update execution decisions", () => { try { await withoutHostProcesses(() => restartAfterUpdateForTests(job, { port: 19100, hostname: "127.0.0.1" }, { ...HOST_FREE_RECOVERY_IO, + ...sameHomeRestartIo, serviceInstalledFn: () => true, // Installed but stale/missing assets — the status line users see after a dead update. serviceViableFn: () => false, @@ -1140,6 +1161,7 @@ describe("GUI update execution decisions", () => { { ...HOST_FREE_RECOVERY_IO, // No Background Service installed — interactive dashboard update must still recover. + ...absentHomeRestartIo, serviceInstalledFn: () => false, serviceViableFn: () => false, waitForPort: async () => true, @@ -1297,6 +1319,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], @@ -1345,6 +1368,7 @@ describe("GUI update execution decisions", () => { { port: 10100, hostname: "127.0.0.1", oldPid: 111 }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, listListenPidsFn: () => [222], isAliveFn: () => true, @@ -1385,6 +1409,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, @@ -1436,6 +1461,7 @@ describe("GUI update execution decisions", () => { { port: 10100, hostname: "127.0.0.1", oldPid: 111 }, "npm", { + ...sameHomeRestartIo, serviceInstalledFn: () => true, listListenPidsFn: () => [livePid], isAliveFn: () => true, @@ -1482,6 +1508,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], @@ -1523,6 +1550,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). @@ -1566,6 +1594,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. @@ -1606,6 +1635,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, @@ -1847,12 +1877,11 @@ describe("immutable update target (WP160)", () => { test("GUI worker gates integrity before spawning and fails the job on anomalous metadata", async () => { const source = await Bun.file(new URL("../../src/update/job.ts", import.meta.url)).text(); - - const gateAt = source.indexOf("checkUpdatePackageIntegrity(check.latestVersion, spawnSync, check.installer, pnpmOwner)"); + const gateAt = source.indexOf("checkUpdatePackageIntegrity(check.latestVersion, probeSpawnSync, check.installer, pnpmOwner)"); const cacheGateAt = source.indexOf("const cachePreflight = (io.cachePreflightFn ?? runNpmCachePreflight)();"); - const trayStopAt = source.indexOf("handoffWindowsTrayForUpdate(tray"); + const trayStopAt = source.indexOf("io.trayHandoffFn ?? defaultWorkerTrayHandoff"); const failAt = source.indexOf('updateJob(job, { status: "failed", error: integrity.reason });'); - const spawnAt = source.indexOf("const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS, updateEnv);"); + const spawnAt = source.indexOf('const result = (io.runCommandFn ?? runLoggedCommand)(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS, updateEnv, homePolicy.authorizeMutation, "package");'); expect(gateAt).toBeGreaterThan(-1); expect(cacheGateAt).toBeGreaterThan(-1); expect(trayStopAt).toBeGreaterThan(-1); @@ -1907,6 +1936,7 @@ describe("service recovery is health-gated, not viability-gated", () => { try { await withoutHostProcesses(() => restartAfterUpdateForTests(healthGateJob(id), { port: 18765, hostname: "127.0.0.1" }, { ...HOST_FREE_RECOVERY_IO, + ...sameHomeRestartIo, serviceInstalledFn: () => true, runService: () => ({ status: 0 }), waitForPort: async () => true, diff --git a/tests/update/update-mutation-boundaries.test.ts b/tests/update/update-mutation-boundaries.test.ts new file mode 100644 index 00000000000..58e5efe916e --- /dev/null +++ b/tests/update/update-mutation-boundaries.test.ts @@ -0,0 +1,169 @@ +import { guardedUpdateIo, guardedTrayIo } from "../helpers/update-guarded-io.mjs"; +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"; +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../../src/update/tray-update-plan.mjs"; +import { prepareWorkerUpdateHome, packageOnlyUpdateSucceeded } from "../../src/update/job-update-home"; + +const node = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); +const bun = readFileSync(repoPath("src/update/index.ts"), "utf8"); +const worker = readFileSync(repoPath("src/update/job.ts"), "utf8"); +const tray = readFileSync(repoPath("src/update/job-update-tray.ts"), "utf8"); +function section(source: string, start: string, end: string) { + const from = source.indexOf(start), to = source.indexOf(end, from); + expect(from).toBeGreaterThanOrEqual(0); expect(to).toBeGreaterThan(from); + return source.slice(from, to); +} +async function execute(code: string, deps: object): Promise { + // Execute production bodies; only module loading and external process/store I/O are injected. + code = code.replaceAll('await import("../codex/shim")', "await moduleIo.shim()") + .replaceAll('await import("./guarded-tray-ops")', "guardedTrayIo(await moduleIo.tray())") + .replaceAll("import.meta.url", '"file:///fixture"') + .replaceAll('await import("../service")', "await moduleIo.service()") + .replaceAll('await import("./guarded-port-reclaim")', "await moduleIo.reclaim()"); + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(`async function run(){${code}}`); + return new Function("deps", `with(deps){${js};return run();}`)({ ...deps, guardedTrayIo, ...guardedUpdateIo(deps) }); +} +const quiet = { log() {}, warn() {}, error() {} }; + +for (const lane of ["Node", "Bun"]) for (const phase of ["shim", "tray"]) { + if (lane === "Node" && phase === "tray") continue; // Existing launcher terminal gate already covers this control. + test(`${lane} refuses a cold update when home changes during ${phase} control`, async () => { + for (const change of [false, true]) { + let home: UpdateServiceHomeAuthority = { kind: "absent" }, refusals = 0; + const events: string[] = [], exits: number[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, () => refusals++); + const code = lane === "Node" + ? section(node, "function repairCodexShimIfNeeded(", "\nfunction trayInstallState()") + + section(node, " const postInstallPlan =", "\n if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable") + : section(bun, " if (r.status === 0) {", " } else {\n if (stopAttempted && trayWasRunning") + "}"; + const result = await execute(code, { + homePolicy, planUpdateRuntimeHandling, readOwnership: () => ({ ownership: null }), serviceWasInstalled: false, + observeSupervision: () => false, postInstallPlan: planUpdateRuntimeHandling({ ownership: null, serviceInstalled: false }), + r: { status: 0 }, res: { status: 0 }, latest: "2.0.0", authorityLauncher: "old", postUpdateLauncher: "fixture", + postUpdateLauncherUsable: true, trayBeforeUpdate: { refreshAfterReplacement: true, installArgs: ["tray", "install", "--no-start"] }, + trayWasInstalled: true, trayWasRunning: false, stopAttempted: false, existsSync: () => true, + bakePort: 19001, capturedListen: { port: 19001 }, console: quiet, + process: { argv: [], execPath: "fixture", env: {}, exit: (n: number) => { exits.push(n); throw n; } }, + shouldRepairCodexShim: () => true, planWindowsTrayUpdate, + moduleIo: { shim: async () => ({ isCodexShimInstalled: () => true }) }, + spawnSync: (_bin: string, args: string[]) => { + events.push(args[1]); if (change && args.includes(phase === "shim" ? "codex-shim" : "tray")) home = { kind: "unknown" }; + return { status: 0 }; + }, + mutation: { unprivilegedEnvironment: () => ({}) }, releaseUpdateLease() {}, launcherStartHint: () => "manual", + }).catch(error => { if (typeof error !== "number") throw error; return error; }); + expect(events).toEqual(change && phase === "shim" ? ["codex-shim"] : ["codex-shim", "tray"]); + expect(lane === "Node" ? exits[0] : result).toBe(change ? 1 : lane === "Node" ? 0 : undefined); + expect(refusals).toBe(change ? 1 : 0); + } + }); +} + +test("Bun tray handoff checks authority after its module import", async () => { + for (const change of [false, true]) { + let home: UpdateServiceHomeAuthority = { kind: "same" }, running = true, stops = 0; + const homePolicy = createUpdateServiceHomePolicy(() => home, () => {}); + const result = await execute(section(bun, " let trayWasInstalled =", " // Capture listen target"), { + homePolicy, process: { platform: "win32" }, console: quiet, handoffWindowsTrayForUpdate, + moduleIo: { tray: async () => { + await Promise.resolve(); if (change) home = { kind: "unknown" }; + return { getWindowsTrayStatus: () => ({ installed: true, running }), + stopWindowsTray: () => { stops++; running = false; return { running }; }, startWindowsTray: () => { running = true; } }; + } }, + }); + expect(stops).toBe(change ? 0 : 1); expect(result).toBe(change ? 1 : undefined); + } +}); + +for (const phase of ["handoff-import", "tray-refresh"]) test(`full worker refuses ${phase} home changes`, async () => { + for (const change of [false, true]) { + let home: UpdateServiceHomeAuthority = { kind: "same" }, running = true, job: any; + const events: string[] = []; + const helper = tray.slice(tray.indexOf("export async function defaultWorkerTrayHandoff")).replace("export ", ""); + const entry = worker.slice(worker.indexOf("export async function runGuiUpdateWorker(")).replace("export ", ""); + await execute(helper + entry + 'return runGuiUpdateWorker("probe", "latest", false, io);', { + readUpdateJob: () => null, readRuntimePort: () => null, readPid: () => null, loadConfig: () => ({ port: 19001 }), + packageLauncherPath: () => "fixture", writeJob: (j: any) => { job = j; }, + updateJob: (j: any, patch: any, line?: string) => { Object.assign(j, patch); if (line) j.log.push(line); job = j; return j; }, + updateExecutionCommand: () => ({ bin: "fixture", args: ["update"], display: "fixture" }), + prepareWorkerUpdateHome, packageOnlyUpdateSucceeded, nodeBin: () => "fixture", planWindowsTrayUpdate, handoffWindowsTrayForUpdate, + withUpdateTeardownHandover: (x: any) => x, withoutUpdateTeardownHandover: (x: any) => x, process: { env: {} }, + PKG: "fixture", UPDATE_TIMEOUT_MS: 1000, + moduleIo: { tray: async () => { + await Promise.resolve(); if (change && phase === "handoff-import") home = { kind: "unknown" }; + return { getWindowsTrayStatus: () => ({ installed: true, running }), + stopWindowsTray: () => { events.push("stop"); running = false; return { running }; }, + startWindowsTray: () => { events.push("restore"); running = true; } }; + } }, + io: { platform: "win32", checkForUpdateFn: () => ({ canUpdate: true, currentVersion: "1.0.0", latestVersion: "2.0.0", + channel: "latest", installer: "npm", command: "fixture" }), integrityFn: () => ({ ok: true, integrity: "fixture" }), + cachePreflightFn: () => ({ ok: true }), serviceHomeAuthorityFn: () => home, launcherSupportsUpdateHomeFn: () => true, + runCommandFn: () => { events.push("package"); return { status: 0 }; }, + trayCommandFn: (_j: any, _bin: any, args: string[]) => { + events.push(args[2]); if (change && phase === "tray-refresh") home = { kind: "unknown" }; return { status: 0 }; + } }, + withheldSummary: () => "fixture error", GUI_UPDATE_FAILURE_NEXT_STEP: "fixture", + }); + expect(job.status).toBe(change ? "failed" : "succeeded"); + if (change && phase === "handoff-import") expect(events).toEqual([]); + if (change) expect(job.error).toContain("ocx service status"); + else expect(events).toEqual(["stop", "package", "install"]); + } +}); + +test("worker rechecks service permission after a blocking PID kill", async () => { + for (const changed of ["same", "absent", "unknown"] as const) { + let home: UpdateServiceHomeAuthority = { kind: "same" }, kills = 0; + const wrappers: string[] = []; + const result = await execute(section(worker, "async function restartAfterUpdate(", "/** Compact listen-holder summary") + + 'return restartAfterUpdate({installer:"npm"}, {port:19001,hostname:"127.0.0.1"}, io);', { + createUpdateServiceHomePolicy, loadConfig: () => ({}), restartCommand: () => ({ bin: "fixture", args: [], display: "fixture" }), + updateJob() {}, RESTART_PORT_RECLAIM_MS: 30000, process: { env: { OCX_SERVICE: "1" }, platform: "win32", pid: 99 }, + moduleIo: { service: async () => ({ serviceReinstallArgs: () => ["service", "install"] }) }, + io: { serviceHomeAuthorityFn: () => home, serviceInstalledFn: () => true, packageLauncherPathFn: () => "fixture", + waitForPort: async (_p: any, _h: any, opts: any) => opts.mayMutateFn(), listListenPidsFn: () => [], + verifyOcxFn: () => null, isAliveFn: () => false, stopServiceWrappersFn: () => wrappers.push(home.kind), + restartServiceManagerFn: async () => "failed", reacquireForDirectStartFn: () => null, readPidFn: () => 17, + killProxyFn: () => { kills++; home = { kind: changed }; }, spawnStart() {} }, + }); + expect(kills).toBe(1); expect(wrappers).toEqual(changed === "same" ? ["same", "same"] : ["same"]); + if (changed === "unknown") expect(result).toBe(false); + } +}); + +for (const lane of ["Node", "Bun"]) test(`${lane} manager ownership veto is successful skipped recovery`, async () => { + for (const refusal of [false, true]) { + let reads = 0, repairs = 0, restarts = 0; + let home: UpdateServiceHomeAuthority = { kind: "same" }; + const manager = lane === "Node" + ? section(node, " function restartThroughServiceManager()", ' /**\n * Returns "unsupervised"') + : section(bun, " const restartThroughServiceManager = async", " /** Direct start as the last resort"); + const caller = lane === "Node" + ? section(node, " function refreshBackgroundServiceOrStartDirect()", " let updateLease =") + "return refreshBackgroundServiceOrStartDirect();" + : section(bun, " if (postInstallPlan.mayRestoreService) {", " } else if (postInstallPlan.mayStopRuntime) {") + "}"; + const ownership = () => { + const veto = ++reads === (lane === "Node" ? 3 : 4); + if (veto && refusal) home = { kind: "unknown" }; + return { ownership: { owner: veto ? "desktop" : "cli" }, ownershipUnknown: false }; + }; + const result = await execute(manager + caller, { + homePolicy: createUpdateServiceHomePolicy(() => home, () => {}), hasRestartableServiceManager: () => true, + releaseUpdateLease() {}, mutation: { release() {}, controlEnvironment: () => ({}) }, + resolvedRuntimeOwnership: async () => ownership(), readOwnership: ownership, + ownershipIdentity: (x: any) => x.ownership?.owner, initialOwnership: { ownership: { owner: "cli" } }, + sameOwner: (x: any) => x.ownership?.owner === "cli", observeSupervision: () => false, planUpdateRuntimeHandling, + restartServiceManagerJob: () => { restarts++; return { ok: false }; }, postInstallPlan: { mayRestoreService: true }, + console: quiet, process: { env: {}, execPath: "fixture" }, authorityLauncher: "fixture", postUpdateLauncher: "fixture", + bakePort: 19001, capturedListen: { port: 19001, hostname: "127.0.0.1" }, mutationChildEnvironment: () => ({}), + serviceRefreshArgs: () => ["service", "repair"], readServiceInstalledFromStatus: () => null, + moduleIo: { service: async () => ({ serviceReinstallArgs: () => ["service", "repair"] }), + reclaim: async () => ({ guardedReclaimListenPort: async () => true }) }, + updateChildStdio: () => "inherit", spawnSync: () => { repairs++; return { status: 1 }; }, + }); + expect(repairs).toBe(1); expect(restarts).toBe(0); + expect(result).toBe(refusal ? lane === "Node" ? "refused" : 1 : undefined); + } +}); diff --git a/tests/update/update-mutation-guard.test.ts b/tests/update/update-mutation-guard.test.ts new file mode 100644 index 00000000000..2c9f95e16e7 --- /dev/null +++ b/tests/update/update-mutation-guard.test.ts @@ -0,0 +1,62 @@ +// Scanner/inventory cases are best-effort known-pattern lint, not a security +// boundary or completeness proof. The runtime cases exercise the authority guard. +import { afterAll, expect, test } from "bun:test"; +import { lintTest, openUpdateLintParser } from "../helpers/update-lint-parser"; +import { repoPath } from "../helpers/repo-root"; +import { createUpdateServiceHomePolicy } from "../../src/update/service-home-policy.mjs"; +import { files, rawMutationViolations, updateModuleFiles } from "../helpers/update-mutation-scanner"; +import { helperControlInventory } from "../helpers/update-helper-inventory"; +import { helperControlContracts } from "../helpers/update-helper-control-contracts"; + +const api = openUpdateLintParser(); +const lint = lintTest(api); +afterAll(() => api?.close()); +lint("known-pattern lint finds no unclassified update primitive references", () => { + const scanFiles = ["bin/ocx.mjs", ...updateModuleFiles()]; + const snapshot = api!.updateSnapshot({openFiles:scanFiles.map(f=>repoPath(f))}); + const failures: string[]=[]; + try { for (const file of scanFiles) { + const source=snapshot.getDefaultProjectForFile(repoPath(file))!.program.getSourceFile(repoPath(file))!; + failures.push(...rawMutationViolations(source,file).map(f=>file+": "+f)); + }} finally {snapshot.dispose();} + expect(scanFiles.length).toBeGreaterThan(35); expect(failures).toEqual([]); +}); + +test("one refused mutation closes every later kind despite home re-matching", () => { + for (const throws of [false, true]) { + let home: "same" | "unknown" = "same", broken = false; + const policy = createUpdateServiceHomePolicy(() => { if (broken && throws) throw new Error("fixture probe"); return { kind: home }; }, () => {}); + expect(policy.authorizeMutation("tray")).toBe(true); + home = "unknown"; broken = true; + expect(policy.authorizeMutation("shim")).toBe(false); + home = "same"; broken = false; + for (const kind of ["runtime", "service", "tray", "shim", "package", "publish", "package-publish"] as const) expect(policy.authorizeMutation(kind)).toBe(false); + expect(policy.authorizeMutation("own-child")).toBe(true); expect(policy.authorizeMutation("own-tray")).toBe(true); + } +}); + +test("confirmed service disappearance skips service control while retaining foreground and package-only contracts", () => { + let kind: "same" | "absent" = "same"; + const policy = createUpdateServiceHomePolicy(() => ({ kind }), () => {}); + kind = "absent"; + expect(policy.authorizeMutation("service")).toBe(false); + expect(policy.authorizeMutation("runtime")).toBe(true); expect(policy.authorizeMutation("publish")).toBe(true); + const restricted = createUpdateServiceHomePolicy(() => ({ kind: "unknown" }), () => {}); + expect(restricted.authorizeMutation("package")).toBe(true); expect(restricted.authorizeMutation("package-publish")).toBe(true); + expect(restricted.authorizeMutation("runtime")).toBe(false); expect(restricted.authorizeMutation("package-publish")).toBe(false); +}); + +lint("known-pattern helper inventory has explicit control-site contracts", () => { + const { helpers, sites, policyViolations } = helperControlInventory(api!); + expect(policyViolations).toEqual([]); + const matched = new Set(); + for (const [file, context, prefix, count, contract] of helperControlContracts) { + const indices = sites.flatMap((site, index) => site.file === file && site.context === context && site.call.startsWith(prefix) ? [index] : []); + expect(indices.length, `${file}:${context}: ${prefix} (${contract})`).toBe(count); + for (const index of indices) { expect(matched.has(index)).toBe(false); matched.add(index); } + } + expect(sites.filter((_, index) => !matched.has(index))).toEqual([]); + expect(helpers.length).toBeGreaterThan(30); + expect(files).toContain("src/update/job-windows-restart.ts"); + if (process.env.OCX_TEST_UPDATE_GUARD_TABLE === "1") console.log(JSON.stringify({ helpers, helperSites: sites }, null, 2)); +}); diff --git a/tests/update/update-mutation-scanner.test.ts b/tests/update/update-mutation-scanner.test.ts new file mode 100644 index 00000000000..b263830d765 --- /dev/null +++ b/tests/update/update-mutation-scanner.test.ts @@ -0,0 +1,151 @@ +// Best-effort known-pattern regression lint, not a security boundary or +// completeness proof. Runtime mutation guards enforce update authority. +import { afterAll, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { lintTest, openUpdateLintParser } from "../helpers/update-lint-parser"; +import { rawMutationViolations, updateModuleFiles } from "../helpers/update-mutation-scanner"; +import { capabilityControlCalls } from "../helpers/update-helper-inventory"; + +// Reuse the parser service; each mutant still gets its own file and snapshot. +// Repeated compiler subprocess startup is unrelated to the scanner contract. +const api = openUpdateLintParser(); +const lint = lintTest(api); +afterAll(() => api?.close()); +function scan(text: string, mode: "scanner" | "inventory" = "scanner", target = "src/update/job-pinned-start.ts") { + const dir=mkdtempSync(join(tmpdir(),"ocx-raw-mutation-")), file=join(dir,"mutant.ts"); + writeFileSync(file,text); const snapshot=api!.updateSnapshot({openFiles:[file]}); + try { + const source = snapshot.getDefaultProjectForFile(file)!.program.getSourceFile(file)!; + return mode === "inventory" ? capabilityControlCalls(source).map(node => node.getText()) : rawMutationViolations(source, target); + } + finally {snapshot.dispose();rmSync(dir,{recursive:true,force:true});} +} +// Reviewer round-10 named-object variants: every raw call fails regardless of +// where its options came from. Runtime tests separately prove wrapped refusal. +for (const options of ["startOptions", "{ ...startOptions }", "{ stdio: startOptions.stdio }"]) + lint("raw named-options mutant is rejected: "+options,()=>{ + const mutant='const startOptions = { get stdio(){ return preparePinnedStartStdio(); } }; if(authorizeMutation("runtime")) spawn(cmd.bin,cmd.args,'+options+');'; + expect(scan(mutant).length).toBeGreaterThan(0); + }); +for (const text of [ + 'await Bun.file(target).delete();', + 'import { file } from "bun"; await file(target).delete();', + 'import { cpSync } from "node:fs"; cpSync(source, target, {recursive:true,force:true});', + 'import { openSync, ftruncateSync } from "node:fs"; ftruncateSync(openSync(target,"r+"),0);', + 'import { openSync, writeSync } from "node:fs"; writeSync(openSync(target,"r+"),payload);', + ...["cp", "ftruncate", "write", "rm", "truncate", "writeFile"].flatMap(name => [ + `import { ${name} as mutate } from "node:fs"; mutate(target,payload,callback);`, + `import { ${name} as mutate } from "node:fs/promises"; await mutate(target,payload);`, + `import fs from "node:fs"; await fs.promises.${name}(target,payload);`, + `import * as fs from "node:fs/promises"; const { ${name}: mutate } = fs; await mutate(target,payload);`, + ]), + 'const handle = await fs.promises.open(target,"r+"); await handle.write(payload);', + 'const handle = await fs.promises.open(target,"r+"); await handle.truncate(0);', + 'const writer = Bun.file(target).writer(); writer.write(payload); await writer.end();', + 'import { file as open } from "bun"; const blob = open(target); const { delete: remove } = blob; await remove();', + 'import { futureMutation as mutate } from "node:fs"; mutate(target);', + 'import { dlopen as load } from "bun:ffi"; load(library, symbols);', + 'import { createConnection as connect } from "node:net"; connect(options);', + 'import { createSocket as socket } from "node:dgram"; socket("udp4");', + 'process.futureControl(target);', + 'process.exit(0);', + 'process.on("SIGTERM", handler);', + 'await fetch(target, {method:"POST", body:payload});', + 'Bun.futureControl(target);', + ...["node:fs", "fs", "node:fs/promises", "fs/promises", "node:child_process", "node:net", "node:tls", "node:dgram", "node:worker_threads", "bun", "bun:ffi"].flatMap(module => [ + `import * as runtime from "${module}"; runtime.futureControl(target);`, + `import runtime from "${module}"; const { futureControl: mutate } = runtime; mutate(target);`, + `export { futureControl as mutate } from "${module}";`, + `export * as runtime from "${module}";`, + `const { futureControl: mutate } = await import("${module}"); mutate(target);`, + `const runtime = require("${module}"); runtime.futureControl(target);`, + ]), + 'import "node:fs";', + 'const { file: open } = Bun; await open(target).delete();', + 'await (Bun as Record)[method](target);', + 'globalThis.Bun.file(target).delete();', + 'const runtime = globalThis; runtime.Bun.file(target).delete();', + 'const proc = (process); proc.kill(pid);', + 'import { setPriority as mutate } from "node:os"; mutate(pid, 0);', + 'import { erase as mutate } from "future-filesystem"; mutate(target);', + 'import { futureControl as mutate } from "node:future_runtime"; mutate(target);', + 'import { $ } from "bun"; await $`taskkill /PID ${pid} /T /F`.quiet();', + 'import { $ as shell } from "bun"; await shell`ocx stop`;', + 'import * as runtime from "bun"; await runtime.$`ocx stop`;', + 'import runtime from "bun"; await runtime.$`ocx stop`;', + 'await Bun.$`ocx stop`;', + 'const shell = Bun.$; await shell`ocx stop`;', + 'const { $: shell } = Bun; await shell`ocx stop`;', + 'const runtime = Bun; const shell = runtime["$"];', + 'const { $: shell } = await import("bun");', + 'export { $ as shell } from "bun";', + 'export * as runtime from "bun";', + 'const runtime = require("bun");', + 'await shell`ocx stop`;', + 'await Bun.write(livePackage, payload);', + 'import { fork as launch } from "child_process"; launch(file);', + 'new Worker(workerUrl);', + 'import { writeFileSync as replace } from "node:fs"; replace(livePackage, payload);', + 'fs.copyFileSync(source, livePackage);', + 'import { spawn as launch } from "node:child_process"; launch(file,args,options);', + 'import * as cp from "node:child_process"; cp.spawn(file,args,options);', + 'export { spawn as launch } from "node:child_process";', + 'const launch=spawn; launch(file,args,options);', + 'import { kill as terminate } from "node:process"; terminate(pid,"SIGTERM");', + 'const { kill: terminate } = process; terminate(pid,"SIGTERM");', + 'const { [method]: terminate } = process; terminate(pid,"SIGTERM");', + 'const proc = process; proc.kill(pid);', + 'const {kill: terminate} = await import("node:process"); terminate(pid);', + 'export * as proc from "node:process";', + 'import fs from "node:fs"; fs.unlink(state);', + 'import { rm as remove } from "node:fs/promises"; remove(state);', + 'process.kill(pid);', 'fs.rename(from,to);', 'fs.unlink(state);', 'killProxy(pid);', + 'spawn(file,args,{get stdio(){return blocking();}});', + 'if(authorizeMutation("runtime")){await ready();spawn(file,args,options);}', +]) lint("scanner rejects raw reference/alias: "+text,()=>expect(scan(text).length).toBeGreaterThan(0)); + +for (const text of [ + 'await Bun.file(target).delete();', + 'import { file } from "bun"; await file(target).delete();', + ...["cpSync", "ftruncateSync", "writeSync", "cp", "ftruncate", "write"].map(name => + `import { ${name} as mutate } from "node:fs"; mutate(target,payload);`), + ...["rm", "cp", "truncate", "writeFile"].flatMap(name => [ + `import { ${name} as mutate } from "node:fs/promises"; await mutate(target,payload);`, + `import fs from "node:fs"; await fs.promises.${name}(target,payload);`, + ]), + 'import { open } from "node:fs/promises"; const handle = await open(target,"r+"); await handle.write(payload);', + 'import { open } from "node:fs/promises"; const handle = await open(target,"r+"); await handle.truncate(0);', + 'const writer = Bun.file(target).writer(); writer.write(payload);', + 'import { futureMutation as mutate } from "node:fs"; const alias = mutate; alias(target);', + 'import * as sockets from "node:net"; sockets.createConnection(options);', + 'process.futureControl(target);', +]) lint("inventory includes sensitive capability acquisition/call: " + text, () => expect(scan(text, "inventory").length).toBeGreaterThan(0)); + +lint("explicit read-only imports and runtime observations remain allowed", () => { + expect(scan('import { readFileSync as read, statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { sleep } from "bun"; read(target); statSync(target); await readFile(target); await sleep(1); process.cwd(); process.getuid?.(); process.env.HOME; Bun.sleep(1);')).toEqual([]); + expect(scan('import type { FileHandle } from "node:fs/promises"; import type { ChildProcess } from "node:child_process"; type T = typeof Bun;')).toEqual([]); +}); + +lint("exact log exemptions reject another target, descriptor, or occurrence", () => { + const prefix = 'import { closeSync, mkdirSync, openSync, writeSync } from "node:fs"; function spawnWorkerPinnedStart(){'; + expect(scan(prefix + 'closeSync(logFd);}', "scanner")).toEqual([]); + expect(scan(prefix + 'openSync(livePackage,"w");}', "scanner").length).toBeGreaterThan(0); + expect(scan(prefix + 'closeSync(otherFd);}', "scanner").length).toBeGreaterThan(0); + expect(scan(prefix + 'closeSync(logFd); closeSync(logFd); closeSync(logFd);}', "scanner")).toContain('extra allowlisted capability occurrence: closeSync(logFd)'); +}); +lint("wrapped options require no static argument grammar",()=>{ + expect(scan('guardedSpawn(authorizeMutation,"runtime",cmd.bin,cmd.args,startOptions);')).toEqual([]); + expect(scan('guardedSpawn(authorizeMutation,"runtime",cmd.bin,cmd.args,{ ...startOptions });')).toEqual([]); +}); + +lint("update inventory descends into nested module directories", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-update-inventory-")); + try { + mkdirSync(join(root, "nested", "deeper"), { recursive: true }); + for (const name of ["entry.ts", "nested/helper.mjs", "nested/deeper/control.js", "nested/deeper/control.d.ts", "nested/deeper/types.d.mts"]) + writeFileSync(join(root, name), ""); + expect(updateModuleFiles(root)).toEqual(["src/update/entry.ts", "src/update/nested/deeper/control.js", "src/update/nested/helper.mjs"]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/update/update-npm-invocation.test.ts b/tests/update/update-npm-invocation.test.ts index f96cb27092e..6eba9dd8ec3 100644 --- a/tests/update/update-npm-invocation.test.ts +++ b/tests/update/update-npm-invocation.test.ts @@ -6,6 +6,7 @@ import { npmInvocation, resolveNpmCommand, } from "../../src/update/npm-invocation.mjs"; +import { spawnedNpmOperation } from "../helpers/npm-operation"; const cwd = "C:\\work\\untrusted-project"; const trustedNpm = "C:\\Program Files\\nodejs\\npm.cmd"; @@ -313,4 +314,43 @@ describe("Windows npm update invocation", () => { exists: path => path === only, })).toBeNull(); }); + + describe("registry-query classification against real npmInvocation shapes", () => { + const query = ["view", "@bitkyc08/opencodex@latest", "version"]; + const winEnv = { + PATH: "C:\\Program Files\\nodejs", PATHEXT: ".CMD", SystemRoot: "C:\\Windows", + }; + const winDeps = { cwd, exists: (path: string) => path === trustedNpm }; + + test("the cmd /d /s /c wrapper still reports 'view' as the npm operation", () => { + const invocation = npmInvocation(query, "win32", winEnv, winDeps); + expect(invocation?.options.windowsVerbatimArguments).toBe(true); + expect(spawnedNpmOperation(invocation?.args ?? [], invocation?.options)).toBe("view"); + }); + + test("a spaced npm path holding a bare 'view' word cannot forge the operation", () => { + const spacedViewNpm = "C:\\Program Files\\tools view area\\npm.cmd"; + const env = { + PATH: "C:\\Program Files\\tools view area", + ProgramFiles: "C:\\Program Files", PATHEXT: ".CMD", SystemRoot: "C:\\Windows", + }; + const deps = { cwd, exists: (path: string) => path === spacedViewNpm }; + const invocation = npmInvocation(query, "win32", env, deps); + expect(invocation?.args.at(-1)).toContain("tools^ view^ area"); + expect(spawnedNpmOperation(invocation?.args ?? [], invocation?.options)).toBe("view"); + const install = npmInvocation(["install", "-g", "@bitkyc08/opencodex@latest"], "win32", env, deps); + expect(spawnedNpmOperation(install?.args ?? [], install?.options)).toBe("install"); + }); + + test("installs — including a package literally named 'view' — are not queries", () => { + for (const args of [["install", "-g", "@bitkyc08/opencodex@latest"], ["install", "view"]]) { + const wrapped = npmInvocation(args, "win32", winEnv, winDeps); + expect(spawnedNpmOperation(wrapped?.args ?? [], wrapped?.options)).toBe("install"); + } + // Bare POSIX argv: the operation is argv[0], so a 'view' value later cannot match. + expect(spawnedNpmOperation(npmInvocation(query, "linux")?.args ?? [])).toBe("view"); + expect(spawnedNpmOperation(["install", "view"])).toBe("install"); + expect(spawnedNpmOperation(["install", "-g", "pkg@latest"])).toBe("install"); + }); + }); }); diff --git a/tests/update/update-npm-supervisor-exit.test.ts b/tests/update/update-npm-supervisor-exit.test.ts index 812c64a6950..6540aa685ef 100644 --- a/tests/update/update-npm-supervisor-exit.test.ts +++ b/tests/update/update-npm-supervisor-exit.test.ts @@ -1,3 +1,4 @@ +import { pathToFileURL } from "node:url"; import { expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -24,7 +25,7 @@ async function runner(root: string, transform: (source: string) => string): Prom const source = readFileSync(repoPath("src/update/npm-install.mjs"), "utf8"); const anchor = 'const helper = join(dirname(fileURLToPath(import.meta.url)), "npm-install-job.ps1");'; expect(source.split(anchor)).toHaveLength(2); - return (await import("data:text/javascript;base64," + Buffer.from(source.replace(anchor, `const helper = ${JSON.stringify(helper)};`)).toString("base64"))).runNpmInstall; + return (await import("data:text/javascript;base64," + Buffer.from(source.replace(anchor, `const helper = ${JSON.stringify(helper)};`).replaceAll('"./mutation-guard.mjs"', JSON.stringify(pathToFileURL(repoPath("src/update/mutation-guard.mjs")).href))).toString("base64"))).runNpmInstall; } test.skipIf(process.platform !== "win32").each(["created", "tree"])("abrupt supervisor exit at %s kills installer and retains unproven stage", async point => { @@ -52,7 +53,7 @@ test.skipIf(process.platform !== "win32").each(["created", "tree"])("abrupt supe pending = transactionalNpmUpdateAsync({ packageDir, pkgName: "@fixture/opencodex", tag: "latest", runNpm: (args: string[]) => { stage = args[args.indexOf("--prefix") + 1]!; - return install({ file: Bun.which("node")!, args: [fixturePath("npm-timeout-tree.mjs"), "tree", stage, leafReceipt], options: {} }); + return install({ file: Bun.which("node")!, args: [fixturePath("npm-timeout-tree.mjs"), "tree", stage, leafReceipt], options: {} }, { authorizeMutation: () => true }); }, deps: { rm: () => { removals++; } }, }); expect(await until(() => existsSync(receipt), 10000), "CreateProcess handshake").toBe(true); @@ -115,7 +116,7 @@ test.skipIf(process.platform !== "win32").each(["0x0002000D", "0x00020002"])("un return source.replace(anchor, "new IntPtr(0x0002FFFF)"); }); const result = await install({ file: Bun.which("node")!, - args: ["-e", `require('fs').writeFileSync(${JSON.stringify(launched)}, 'ran')`], options: {} }); + args: ["-e", `require('fs').writeFileSync(${JSON.stringify(launched)}, 'ran')`], options: {} }, { authorizeMutation: () => true }); expect(result.status).toBeNull(); expect(result.processTreeExited).toBe(true); expect(result.error?.code).toBe("EINSTALLJOIN"); diff --git a/tests/update/update-npm-timeout.test.ts b/tests/update/update-npm-timeout.test.ts index 0d3716a619f..e6292f7bde1 100644 --- a/tests/update/update-npm-timeout.test.ts +++ b/tests/update/update-npm-timeout.test.ts @@ -1,3 +1,4 @@ +import { pathToFileURL } from "node:url"; import { expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -36,7 +37,7 @@ async function readyTimeoutRunner(root: string, receipt: string): Promise { @@ -85,7 +86,7 @@ test.skipIf(process.platform !== "win32").each(["tree", "orphan"])("Windows cmd- const env = { ...process.env, PATH: shimDir, PATHEXT: ".CMD" }; const invocation = npmInvocation([mode, stage, receipt], "win32", env, { cwd: packageDir }); expect(invocation?.file.toLowerCase()).toEndWith("system32\\cmd.exe"); - const install = await runReadyInstall(invocation, { env, timeoutMs: 2000 }); + const install = await runReadyInstall(invocation, { env, timeoutMs: 2000, authorizeMutation: () => true }); observedInstall = install; // Receipt is a handshake from the leaf after acquiring its cwd/file. // A startup failure cannot masquerade as a passing timeout test. @@ -128,14 +129,14 @@ test.skipIf(process.platform !== "win32").each(["tree", "orphan"])("Windows cmd- }, 20000); test.skipIf(process.platform !== "win32").each([["success", 0], ["failure", 23]] as const)("installer preserves %s exit status", async (mode, status) => { - const result = await runNpmInstall({ file: Bun.which("node")!, args: [fixturePath("npm-timeout-tree.mjs"), mode], options: {} }); + const result = await runNpmInstall({ file: Bun.which("node")!, args: [fixturePath("npm-timeout-tree.mjs"), mode], options: {} }, { authorizeMutation: () => true }); expect(result.status).toBe(status); expect(result.processTreeExited).toBe(true); expect(result.error).toBeFalsy(); }, 20000); test.skipIf(process.platform !== "win32")("spawn failure is explicit and has no running installer", async () => { - const result = await runNpmInstall({ file: repoPath(".tmp", "missing-npm-executable"), args: [], options: {} }); + const result = await runNpmInstall({ file: repoPath(".tmp", "missing-npm-executable"), args: [], options: {} }, { authorizeMutation: () => true }); expect(result.status).toBeNull(); expect(result.processTreeExited).toBe(true); expect(result.error).toBeTruthy(); diff --git a/tests/update/update-reclaim-authorizer.test.ts b/tests/update/update-reclaim-authorizer.test.ts new file mode 100644 index 00000000000..3e194918475 --- /dev/null +++ b/tests/update/update-reclaim-authorizer.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; +import { guardedReclaimListenPort, type UpdateReclaimOptions } from "../../src/update/guarded-port-reclaim"; +import type { ReclaimListenPortOptions } from "../../src/server/port-reclaim"; + +function fixture(gate?: unknown) { + const effects: string[] = []; + const options: ReclaimListenPortOptions = { + timeoutMs: 100, intervalMs: 0, scanIntervalMs: 0, + killOcxHolders: true, onlyKillPids: [4242], dropTcpRows: true, + listListenPidsFn: () => [4242], isAliveFn: () => true, verifyOcxFn: pid => pid, + killFn: () => { effects.push("kill"); }, dropTcpFn: () => { effects.push("tcp"); return 1; }, + isAvailableFn: async () => { effects.push("probe"); return true; }, + sleepMs: async () => {}, + ...(gate === "omit" ? {} : { mayMutateFn: gate as () => boolean }), + }; + return { effects, options: options as UpdateReclaimOptions }; +} + +for (const gate of ["omit", undefined, null, false, 1]) + test(`update reclaim refuses a missing or invalid authorizer (${gate}) before I/O`, async () => { + const { effects, options } = fixture(gate); + // Exercise runtime callers that bypass the mandatory TypeScript parameter. + expect(await guardedReclaimListenPort(19001, "127.0.0.1", options)).toBe(false); + expect(effects).toEqual([]); + }); + +for (const gate of [() => false, () => { throw new Error("fixture"); }, async () => true]) + test("update reclaim refuses denial, throwing and asynchronous authorizers", async () => { + const { effects, options } = fixture(gate); + expect(await guardedReclaimListenPort(19001, "127.0.0.1", options)).toBe(false); + expect(effects).toEqual([]); + }); + +test("update reclaim with a synchronous authorizer retains the available-port result", async () => { + const { effects, options } = fixture(() => true); + expect(await guardedReclaimListenPort(19001, "127.0.0.1", options)).toBe(true); + expect(effects).toEqual(["probe"]); +}); + +test("update reclaim refuses an inherited authorizer", async () => { + const { effects, options } = fixture("omit"); + Object.setPrototypeOf(options, { mayMutateFn: () => true }); + expect(await guardedReclaimListenPort(19001, "127.0.0.1", options)).toBe(false); + expect(effects).toEqual([]); +}); + +test("update reclaim checks authority after option getters and after availability awaits", async () => { + for (const phase of ["options", "availability"]) { + let allowed = true; + const { effects, options } = fixture(() => allowed); + if (phase === "options") Object.defineProperty(options, "timeoutMs", { + enumerable: true, get() { allowed = false; return 100; }, + }); + else options.isAvailableFn = async () => { effects.push("probe"); allowed = false; return true; }; + expect(await guardedReclaimListenPort(19001, "127.0.0.1", options)).toBe(false); + expect(effects).toEqual(phase === "options" ? [] : ["probe"]); + } +}); diff --git a/tests/update/update-recovery-authority.test.ts b/tests/update/update-recovery-authority.test.ts new file mode 100644 index 00000000000..6a879dcf386 --- /dev/null +++ b/tests/update/update-recovery-authority.test.ts @@ -0,0 +1,328 @@ +import { updateSourceFunction } from "../helpers/update-source-body"; +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, planStoppedRuntimeRecovery } from "../../src/update/runtime-ownership.mjs"; + +const bunUpdate = readFileSync(repoPath("src/update/index.ts"), "utf8"); +const nodeUpdate = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); +function section(source: string, start: string, end: string) { + const from = source.indexOf(start), to = source.indexOf(end, from); + expect(from).toBeGreaterThan(0); expect(to).toBeGreaterThan(from); + return source.slice(from, to); +} +function runUpdateBody(code: string) { + // Only replace module-loading I/O; execute the unchanged production function bodies. + code = code.replaceAll('await import("../service")', "await deps.service()") + .replaceAll('await import("./guarded-port-reclaim")', "await deps.reclaim()"); + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(`async function run() { ${code} }`); + return updateSourceFunction("deps", `with (deps) { ${js}; return run(); }`); +} + +for (const lane of ["service refresh", "failed-update repair", "proxy stop"] as const) { + for (const changed of ["unknown", "foreign", "absent"] as const) { + test(`production ${lane} rechecks ${changed} home after final ownership await`, async () => { + let home: UpdateServiceHomeAuthority = { kind: "same" }, reads = 0, directStarts = 0; + const commands: string[][] = [], warnings: string[] = []; + const flipAt = lane === "failed-update repair" ? 3 : lane === "service refresh" ? 2 : 1; + const homePolicy = createUpdateServiceHomePolicy(() => home, message => warnings.push(message)); + const code = lane === "service refresh" + ? section(bunUpdate, " if (postInstallPlan.mayRestoreService) {", " } else if (postInstallPlan.mayStopRuntime) {") + "}" + : lane === "failed-update repair" + ? section(bunUpdate, " const recoverStoppedRuntime = async", " try {\n // Never replace package files") + 'return await recoverStoppedRuntime("fixture failure");' + : section(bunUpdate, " if (!homePolicy.packageOnly && runtimePlan.mayStopRuntime", ' if (stopStdio === "pipe") logSpawnOutput') + "}"; + const result = await runUpdateBody(code)({ + deps: { service: async () => ({ serviceReinstallArgs: () => ["service", "repair"], isServiceViable: () => true }), + reclaim: async () => ({ guardedReclaimListenPort: async () => true }) }, + homePolicy, postInstallPlan: { mayRestoreService: true }, planUpdateRuntimeHandling, planStoppedRuntimeRecovery, + resolvedRuntimeOwnership: async () => { + if (++reads === flipAt) home = changed === "foreign" ? { kind: changed, subject: "a".repeat(64) } : { kind: changed }; + return { ownership: null, ownershipUnknown: false }; + }, + observeSupervision: () => false, sameOwner: () => true, + mutation: { release() {}, controlEnvironment: () => ({}) }, + serviceWasInstalled: true, stopAttempted: true, hadRuntimeState: true, + postUpdateLauncherUsable: true, postUpdateLauncher: "/fixture/launcher", existsSync: () => true, + currentPackageRuntimeLiveness: () => "dead", capturedListen: { port: 19001, hostname: "127.0.0.1" }, + process: { execPath: "/fixture/bun", env: {}, platform: "linux" }, + console: { log() {}, warn: (message: string) => warnings.push(message), error() {} }, updateChildStdio: () => "inherit", + spawnSync: (_bin: string, args: string[]) => { commands.push(args); return { status: 0 }; }, + runtimePlan: { mayStopRuntime: true }, readPid: () => 200, readRuntimePort: () => null, + pendingTeardownOutstanding: () => false, supervisionLatch: { observe: () => false }, + inspectDesktopSupervision: () => false, runtimeTrusted: false, selfLaunchArgv: (args: string[]) => args, + startProxyDirectly: async () => { directStarts++; return true; }, + }); + expect(reads).toBe(flipAt); + expect(commands).toEqual([]); + if (changed === "absent" && lane !== "proxy stop") expect(directStarts).toBe(1); + else { + expect(directStarts).toBe(0); + expect(warnings.some(message => message.includes("ocx service status"))).toBe(true); + if (lane !== "failed-update repair") expect(result).toBe(1); + } + }); + } +} + +for (const lane of ["Bun", "Node"] as const) for (const boundary of ["lease", "owner"] as const) { + test(`${lane} successful-update caller fails on direct home refusal after ${boundary}`, async () => { + const source = lane === "Bun" ? bunUpdate : nodeUpdate; + const direct = section(source, lane === "Bun" ? " const startProxyDirectly = async" : " function startProxyDirectly()", " // #7043:"); + let code: string; + if (lane === "Bun") { + code = direct + section(source, " const startUnsupervisedFallback = async", " const recoverStoppedRuntime = async") + + section(source, " if (postInstallPlan.mayRestoreService) {", " } else if (postInstallPlan.mayStopRuntime) {") + "}"; + } else { + const from = source.indexOf(" if (postInstallPlan.mayRestoreService) {"); + const to = source.indexOf(" guardedExit(homePolicy.authorizeMutation, \"publish\", 0);", from) + " guardedExit(homePolicy.authorizeMutation, \"publish\", 0);".length; + code = direct + section(source, " function refreshBackgroundServiceOrStartDirect()", " let updateLease =") + source.slice(from, to); + } + const invoke = runUpdateBody(code); + // The same caller keeps ordinary ownership vetoes successful. + for (const homeRefusal of [true, false]) { + let home: UpdateServiceHomeAuthority = { kind: "same" }, starts = 0, acquiring = false, refusals = 0; + const exits: number[] = [], warnings: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, message => { refusals++; warnings.push(message); }); + const owner = () => { + if (acquiring && boundary === "owner" && homeRefusal) home = { kind: "unknown" }; + return { ownership: !homeRefusal && acquiring ? { owner: "desktop" } : null, ownershipUnknown: false }; + }; + const acquire = () => { acquiring = true; if (homeRefusal && boundary === "lease") home = { kind: "unknown" }; }; + const result = await invoke({ + deps: { service: async () => ({ serviceReinstallArgs: () => ["service", "repair"] }), + reclaim: async () => ({ guardedReclaimListenPort: async () => true }) }, + homePolicy, authorityLauncher: "/fixture/launcher", postUpdateLauncher: "/fixture/launcher", postUpdateLauncherUsable: true, + postInstallPlan: { mayRestoreService: true }, observeSupervision: () => false, planUpdateRuntimeHandling, + readOwnership: owner, resolvedRuntimeOwnership: async () => owner(), initialOwnership: { ownership: null }, + ownershipIdentity: (value: { ownership: unknown }) => JSON.stringify(value.ownership), sameOwner: () => homeRefusal || !acquiring, + process: { env: {}, execPath: "/fixture/runtime", platform: "linux", exit: (code: number) => { exits.push(code); throw code; } }, + console: { log() {}, warn: (message: string) => warnings.push(message) }, bakePort: 19001, + mutationChildEnvironment: () => ({}), serviceRefreshArgs: () => ["service", "repair"], + spawnSync: () => ({ status: 1 }), readServiceInstalledFromStatus: () => true, + restartThroughServiceManager: () => "failed", releaseUpdateLease() {}, reacquireUpdateLease: acquire, + mutation: { release() {}, reacquire: acquire, controlEnvironment: () => ({}) }, + capturedListen: { port: 19001, hostname: "127.0.0.1" }, updateChildStdio: () => "inherit", + spawn: () => { starts++; throw new Error("unauthorized spawn"); }, + }).catch((error: unknown) => { if (typeof error !== "number") throw error; return error; }); + expect(starts).toBe(0); + expect(refusals).toBe(homeRefusal ? 1 : 0); + expect(lane === "Node" ? exits : [result]).toEqual([homeRefusal ? 1 : lane === "Node" ? 0 : undefined]); + if (homeRefusal) expect(warnings.some(message => message.includes("ocx service status"))).toBe(true); + } + }); +} + +// Execute the production bodies with OS/process I/O injected, as in update-home-recovery. +function extract(file: string, start: string, end: string, invoke: string) { + const source = readFileSync(repoPath(file), "utf8"); + 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(source.slice(from, to)); + return updateSourceFunction("deps", `with (deps) { ${code}; return ${invoke}; }`); +} +const ghost = extract("src/update/job.ts", "async function waitForGhostListenClear(", "function packageLauncherPath(", "waitForGhostListenClear"); +const restart = extract("src/update/job.ts", "async function restartAfterUpdate(", "/** Compact listen-holder summary", "restartAfterUpdate"); +const prepare = extract("src/update/job.ts", "function preparePortForPinnedStart(", "function spawnDetachedStart(", "preparePortForPinnedStart"); + +test("worker skips service-wrapper stop when registration disappears during service import", async () => { + let home: UpdateServiceHomeAuthority = { kind: "same" }, wrappers = 0, waits = 0; + const job: { status?: string; restarted?: boolean } = {}; + const code = section(readFileSync(repoPath("src/update/job.ts"), "utf8"), "async function restartAfterUpdate(", "/** Compact listen-holder summary"); + const invoke = await runUpdateBody(code + "return restartAfterUpdate;")({ + deps: { service: async () => { home = { kind: "absent" }; return { serviceReinstallArgs: () => ["service", "repair"] }; } }, + createUpdateServiceHomePolicy, inspectUpdateServiceHome: () => home, + updateJob: (_job: object, state: object) => Object.assign(job, state), + loadConfig: () => ({}), restartCommand: () => ({}), RESTART_PORT_RECLAIM_MS: 100, + process: { pid: 100 }, + }); + expect(await invoke(job, { port: 19001, hostname: "127.0.0.1" }, { + serviceHomeAuthorityFn: () => home, serviceInstalledFn: () => true, + packageLauncherPathFn: () => "fixture launcher", listListenPidsFn: () => [], + verifyOcxFn: () => null, isAliveFn: () => false, + stopServiceWrappersFn: () => { wrappers++; }, + waitForPort: async () => { waits++; home = { kind: "unknown" }; return false; }, + })).toBe(false); + expect(waits).toBe(1); expect(wrappers).toBe(0); + expect(job).toMatchObject({ status: "failed", restarted: false }); +}); + +const preparationBoundaries = [ + "entry", "wrappers", "pid read", "pid kill", "pid removal", "empty pid read", + "empty pid removal", "runtime removal", "holder scan", "holder verification", "holder kill", "final scan", +] as const; +for (const changed of ["foreign", "unknown"] as const) for (const boundary of preparationBoundaries) { + test(`real pinned preparation refuses ${changed} authority at ${boundary}`, () => { + let home: UpdateServiceHomeAuthority = { kind: "absent" }; + let flipped = false, scans = 0; + const unauthorized: string[] = [], actions: string[] = []; + const flipAt = (at: string) => { + if (boundary !== at) return; + flipped = true; + home = changed === "foreign" ? { kind: changed, subject: "a".repeat(64) } : { kind: changed }; + }; + const mutate = (action: string) => { + if (flipped) unauthorized.push(action); + actions.push(action); + flipAt(action); + }; + const policy = createUpdateServiceHomePolicy(() => home, () => {}, false); + let denied = false; + const gate = () => { denied ||= !policy.check(); return !denied; }; + const run = prepare({ + process: { pid: 100, platform: "win32" }, updateJob: () => {}, + readPid: () => { flipAt(boundary === "empty pid read" ? "empty pid read" : "pid read"); return boundary.startsWith("empty") ? null : 200; }, + killProxy: (pid: number) => mutate(pid === 200 ? "pid kill" : "holder kill"), + removePid: (pid?: number) => mutate(pid ? "pid removal" : "empty pid removal"), + removeRuntimePort: () => mutate("runtime removal"), + dropWindowsTcpRowsForLocalPort: () => mutate("TCP drop"), + }); + flipAt("entry"); + const result = run({}, 19001, + () => { flipAt(++scans === 1 ? "holder scan" : "final scan"); return scans === 1 ? [300, 400] : []; }, + () => true, + (pid: number) => { flipAt("holder verification"); return pid; }, + () => mutate("wrappers"), gate); + expect(flipped).toBe(true); // Every case must reach its injected authority transition. + expect(unauthorized).toEqual([]); // Includes kills, shared-state removals, wrappers and TCP drops. + expect(result).toBe(false); + expect(actions).not.toContain("TCP drop"); + }); +} + +test("real pinned preparation still cleans an authorized home", () => { + const actions: string[] = []; + const run = prepare({ + process: { pid: 100, platform: "win32" }, updateJob: () => {}, readPid: () => 200, + killProxy: (pid: number) => actions.push(`kill ${pid}`), removePid: () => actions.push("pid removal"), + removeRuntimePort: () => actions.push("runtime removal"), dropWindowsTcpRowsForLocalPort: () => actions.push("TCP drop"), + }); + let scans = 0; + expect(run({}, 19001, () => ++scans === 1 ? [100, 300, 400, 500] : [], + (pid: number) => pid !== 500, (pid: number) => pid === 300 ? pid : null, + () => actions.push("wrappers"), () => true)).not.toBe(false); + expect(actions).toEqual(["wrappers", "kill 200", "pid removal", "runtime removal", "kill 300", "TCP drop"]); +}); + +for (const changed of ["foreign", "unknown"] as const) test(`restart propagates real preparation's ${changed} refusal before ghost wait`, async () => { + let home: UpdateServiceHomeAuthority = { kind: "absent" }; + let preparing = false, scans = 0, kills = 0, drops = 0, waits = 0, starts = 0; + const job: { status?: string; restarted?: boolean; error?: string } = {}; + const updateJob = (_job: object, state: object) => Object.assign(job, state); + const preparePortForPinnedStart = prepare({ + process: { pid: 100, platform: "win32" }, updateJob, + readPid: () => { preparing = true; return null; }, removePid: () => {}, removeRuntimePort: () => {}, + killProxy: () => { kills++; }, dropWindowsTcpRowsForLocalPort: () => { drops++; }, + }); + const run = restart({ + createUpdateServiceHomePolicy, inspectUpdateServiceHome: () => home, updateJob, + loadConfig: () => ({}), restartCommand: () => ({}), RESTART_PORT_RECLAIM_MS: 100, + process: { pid: 100 }, preparePortForPinnedStart, + waitForGhostListenClear: async () => { waits++; return { ok: true, accessDenied: false }; }, + }); + const result = await run(job, { port: 19001, hostname: "127.0.0.1" }, { + serviceHomeAuthorityFn: () => home, packageLauncherPathFn: () => "fixture launcher", readPidFn: () => null, + listListenPidsFn: () => { + if (preparing && ++scans === 1) home = changed === "foreign" ? { kind: changed, subject: "a".repeat(64) } : { kind: changed }; + return preparing && scans === 1 ? [300] : []; + }, + verifyOcxFn: (pid: number) => pid, isAliveFn: () => true, + waitForPort: async () => true, probeProxyIdentity: async () => null, + spawnDetachedStartFn: () => { starts++; throw new Error("unauthorized spawn"); }, + }); + expect(result).toBe(false); + expect(job).toMatchObject({ status: "failed", restarted: false }); + expect(job.error).toContain("ocx service status"); + expect({ scans, kills, drops, waits, starts }).toEqual({ scans: 1, kills: 0, drops: 0, waits: 0, starts: 0 }); +}); + +for (const reclaimed of [false, true]) for (const changed of ["foreign", "unknown"] as const) { + test(`ghost wait refuses ${changed} authority before TCP mutation (${reclaimed ? "pinned attempt" : "reclaim timeout"})`, async () => { + let now = 0, drops = 0, waits = 0, starts = 0, prepares = 0; + let home: UpdateServiceHomeAuthority = { kind: "absent" }; + const job: { status?: string; restarted?: boolean; error?: string } = {}; + const logs: string[] = []; + const waitForGhost = ghost({ + process: { pid: 100, platform: "win32" }, Date: { now: () => now }, + isProcessAlive: () => false, + dropWindowsTcpRowsForLocalPort: () => { drops++; return { accessDenied: 0 }; }, + strictRuntimePortAvailable: async () => false, + }); + const run = restart({ + createUpdateServiceHomePolicy, inspectUpdateServiceHome: () => home, + updateJob: (_job: object, state: object, log?: string) => { Object.assign(job, state); if (log) logs.push(log); }, + loadConfig: () => ({}), restartCommand: () => ({}), RESTART_PORT_RECLAIM_MS: 100, + process: { pid: 100 }, waitForGhostListenClear: waitForGhost, + formatPortHolders: () => "fixture holders", + }); + const io = { + serviceHomeAuthorityFn: () => home, + packageLauncherPathFn: () => "fixture launcher", readPidFn: () => null, + listListenPidsFn: () => [9999], verifyOcxFn: () => null, isAliveFn: () => false, + waitForPort: async () => reclaimed, probeProxyIdentity: async () => null, + probeProxy: async () => true, preparePortForPinnedStartFn: () => { prepares++; }, + spawnDetachedStartFn: () => { starts++; throw new Error("unauthorized spawn"); }, + sleepMs: async () => { + waits++; now += waits === 1 ? 500 : 100_000; + home = changed === "foreign" ? { kind: changed, subject: "a".repeat(64) } : { kind: changed }; + }, + }; + const result = await run(job, { port: 19001, hostname: "127.0.0.1" }, io).catch((error: unknown) => error); + expect(drops).toBe(1); // The first drop was authorized; the next iteration must refuse. + expect(result).toBe(false); + expect(job).toMatchObject({ status: "failed", restarted: false }); + expect(job.error).toContain("ocx service status"); + expect(waits).toBe(1); expect(starts).toBe(0); expect(prepares).toBe(reclaimed ? 1 : 0); + expect(logs.some(line => /did not clear|not bindable|access denied/.test(line))).toBe(false); + }); +} + +for (const reclaimed of [false, true]) test(`ghost denial stays latched after a transient re-match (${reclaimed})`, async () => { + let denied = false, checks = 0, prepares = 0; + const job: { status?: string; error?: string } = {}; + const run = restart({ + createUpdateServiceHomePolicy, inspectUpdateServiceHome: () => ({ kind: "absent" }), + updateJob: (_job: object, state: object) => Object.assign(job, state), + loadConfig: () => ({}), restartCommand: () => ({}), RESTART_PORT_RECLAIM_MS: 100, + process: { pid: 100 }, formatPortHolders: () => "fixture holders", + waitForGhostListenClear: async (...args: unknown[]) => { + // Re-match before the wait returns: only the gate's latch remembers the denial. + const gate = args[6] as (() => boolean) | undefined; + denied = true; const refused = gate?.() === false; denied = false; + expect(refused).toBe(true); + return { ok: false, accessDenied: false }; + }, + }); + expect(await run(job, { port: 19001, hostname: "127.0.0.1" }, { + serviceHomeAuthorityFn: () => { checks++; return { kind: denied ? "unknown" : "absent" }; }, + packageLauncherPathFn: () => "fixture launcher", readPidFn: () => null, + listListenPidsFn: () => [9999], verifyOcxFn: () => null, isAliveFn: () => false, + waitForPort: async () => reclaimed, probeProxyIdentity: async () => null, + spawnDetachedStartFn: () => { throw new Error("unauthorized spawn"); }, + preparePortForPinnedStartFn: () => { prepares++; }, sleepMs: async () => {}, + })).toBe(false); + expect(job.status).toBe("failed"); expect(job.error).toContain("ocx service status"); + expect(checks).toBeGreaterThan(1); expect(prepares).toBe(reclaimed ? 1 : 0); +}); + +for (const lane of ["Bun", "Node"] as const) test(`${lane} direct recovery reports a retained lease without throwing or probing/spawning`, async () => { + const direct = lane === "Bun" + ? extract("src/update/index.ts", " const startProxyDirectly = async", " // #7043:", "startProxyDirectly") + : extract("bin/ocx.mjs", " function startProxyDirectly()", " // #7043:", "startProxyDirectly"); + const warnings: string[] = []; + let acquisitions = 0, probes = 0, spawns = 0; + const acquire = () => { + acquisitions++; + throw Object.assign(new Error("OWNERSHIP_MUTATION_LEASE_BUSY private fixture details"), { code: "OWNERSHIP_MUTATION_LEASE_BUSY" }); + }; + const result = await direct({ + mutation: { reacquire: acquire }, reacquireUpdateLease: acquire, + homePolicy: { check: () => { probes++; return true; } }, + readOwnership: () => { probes++; }, resolvedRuntimeOwnership: async () => { probes++; }, + spawn: () => { spawns++; }, console: { warn: (line: string) => warnings.push(line), error: (line: string) => warnings.push(line) }, + })(); + expect(result).toBe(false); expect(acquisitions).toBe(1); expect(probes).toBe(0); expect(spawns).toBe(0); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("ocx service repair"); expect(warnings[0]).toContain("ocx start"); + expect(warnings[0]).not.toContain("private fixture details"); expect(warnings[0]).not.toContain("OWNERSHIP_MUTATION_LEASE_BUSY"); +}); diff --git a/tests/update/update-recovery-boundaries.test.ts b/tests/update/update-recovery-boundaries.test.ts new file mode 100644 index 00000000000..28015a6ca57 --- /dev/null +++ b/tests/update/update-recovery-boundaries.test.ts @@ -0,0 +1,371 @@ +import { updateSourceFunction } from "../helpers/update-source-body"; +import { expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { repoPath } from "../helpers/repo-root"; +import { createTempHome } from "../helpers/temp-home"; +import { createUpdateServiceHomePolicy, parseUpdateWorkerHome, UPDATE_PACKAGE_ONLY_EXIT_CODE, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; +import { inspectPackageRuntimeLiveness, planUpdateRuntimeHandling, planStoppedRuntimeRecovery } from "../../src/update/runtime-ownership.mjs"; +import { npmUpdateFailureGuidance } from "../../src/update/update-failure-guidance.mjs"; +import { prepareWorkerUpdateHome } from "../../src/update/job-update-home"; +import { createSupervisionLatch } from "../../src/service/desktop-supervision.mjs"; +import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../../src/update/tray-update-plan.mjs"; +import { transactionalNpmUpdate, transactionalNpmUpdateAsync, launcherUsableAfterNpmUpdate } from "../../src/update/transactional-install.mjs"; + +const node = readFileSync(repoPath("bin/ocx.mjs"), "utf8"); +const bun = readFileSync(repoPath("src/update/index.ts"), "utf8"); +const worker = readFileSync(repoPath("src/update/job.ts"), "utf8"); +function section(source: string, start: string, end: string) { + const from = source.indexOf(start), to = source.indexOf(end, from); + expect(from).toBeGreaterThan(0); expect(to).toBeGreaterThan(from); + return source.slice(from, to); +} +function execute(code: string, deps: object) { + code = code.replaceAll('await import("../service")', "await moduleIo.service()") + .replaceAll('await import("./guarded-port-reclaim")', "await moduleIo.reclaim()"); + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(`async function run() { ${code} }`); + return updateSourceFunction("deps", `with (deps) { ${js}; return run(); }`)(deps); +} +function tree(path: string, version: string) { + mkdirSync(join(path, "bin"), { recursive: true }); + mkdirSync(join(path, "node_modules", "zod"), { recursive: true }); + writeFileSync(join(path, "package.json"), JSON.stringify({ version, dependencies: { zod: "1" } })); + writeFileSync(join(path, "bin", "ocx.mjs"), "// fixture\n".repeat(200)); + writeFileSync(join(path, "node_modules", "zod", "package.json"), "{}"); +} + +// Entire launcher + real transaction. Only registry, installer, home and process I/O change. +for (const platform of ["win32", "linux"] as const) { + for (const changed of ["stable", "home", "owner", "supervision", "liveness", "liveness-home", "package-only-stable", "package-only-changed"] as const) { + test(`production ${platform} npm swap rechecks ${changed} after installer completion`, async () => { + const temp = createTempHome("ocx-swap-authority-"); + try { + const live = temp.path("scope", "opencodex"); tree(live, "1.0.0"); + const packageOnly = changed.startsWith("package-only"); + const success = changed === "stable" || changed === "package-only-stable"; + let installed = false, home: UpdateServiceHomeAuthority = { kind: packageOnly ? "unknown" : "absent" }; + const exits: number[] = [], logs: string[] = []; + const launcher = repoPath("bin/ocx.mjs"); + const command = { bin: "fixture-bun", args: ["add", "-g", "@bitkyc08/opencodex@2.0.0"], display: "fixture" }; + if (packageOnly) { + // The Bun worker's real caller delegates restricted installs to the guarded launcher. + expect(prepareWorkerUpdateHome(command, "bun", launcher, "fixture-node", "latest", () => {}, () => home, () => true)).not.toBeNull(); + expect(command.args).toContain("--update-package-only"); + expect(command.args).toContain("--update-worker-home"); + } + const body = section(node, "async function runPackageManagerSelfUpdate(manager) {", "function bunBinDir(") + .replaceAll("import.meta.url", JSON.stringify(new URL("../../bin/ocx.mjs", import.meta.url).href)); + const stage = (args: string[]) => { + tree(join(args[args.indexOf("--prefix") + 1]!, "node_modules", "@bitkyc08", "opencodex"), "2.0.0"); + installed = true; + if (changed === "home") home = { kind: "unknown" }; + if (changed === "package-only-changed") home = { kind: "same" }; + return { status: 0, processTreeExited: true }; + }; + await execute(body + 'return runPackageManagerSelfUpdate("npm");', { + process: { argv: packageOnly ? [process.execPath, ...command.args] : [process.execPath, launcher, "update"], env: {}, execPath: process.execPath, platform, + exit: (code: number) => { exits.push(code); throw code; } }, + console: { log: (line: string) => logs.push(line), warn: (line: string) => logs.push(line), error: (line: string) => logs.push(line) }, + PKG: "@bitkyc08/opencodex", here: join(live, "bin"), dirname, join, resolve, fileURLToPath, + currentPackageVersion: () => "1.0.0", updateTag: () => "latest", + npmInvocation: (args: string[]) => ({ file: "fixture-npm", args }), + spawnSync: (_bin: string, args: string[]) => args[0] === "view" ? { status: 0, stdout: "2.0.0\n" } : stage(args), + checkRegistryPackageIntegrity: () => ({ ok: true, integrity: "sha512-fixture" }), + resolveNpmCachePath: () => ({ ok: true, path: temp.root }), runNpmCachePreflight: () => ({ ok: true }), + unprivilegedOwnershipMutationEnvironment: (env: object) => env, + serviceStateFilesFor: () => [], configDir: () => temp.root, homedir: () => temp.root, + createUpdateServiceHomePolicy, parseUpdateWorkerHome, UPDATE_PACKAGE_ONLY_EXIT_CODE, + readNodeUpdateHome: () => home, existsSync, readFileSync, readdirSync, + selectAuthoritativeServiceState: () => installed && changed === "owner" + ? { kind: "state", revision: 1, state: { ownership: { owner: "desktop", installId: "other" } } } + : { kind: "none", revision: 0 }, + inspectInstallStateBytes: () => ({ kind: "absent" }), + createSupervisionLatch: () => ({ observe: (value: boolean) => value }), + inspectDesktopSupervision: () => installed && changed === "supervision", + planUpdateRuntimeHandling, planStoppedRuntimeRecovery, inspectPackageRuntimeLiveness, handoffWindowsTrayForUpdate, planWindowsTrayUpdate, + trayInstallState: () => ({ installed: false, running: false }), + probeProxyLiveness: () => { + if (installed && changed === "liveness-home") home = { kind: "unknown" }; + return installed && changed === "liveness" ? "live" : "dead"; + }, hasPendingTeardownIn: () => false, + acquireOwnershipMutationLease: () => ({ token: "fixture", release() {} }), + transactionalNpmUpdateAsync, transactionalNpmUpdateSync: transactionalNpmUpdate, + runNpmInstall: async (invocation: { args: string[] }) => { await Promise.resolve(); return stage(invocation.args); }, + launcherUsableAfterNpmUpdate, npmUpdateFailureGuidance, repairCodexShimIfNeeded() {}, launcherStartHint: () => "fixture start", + }).catch((error: unknown) => { if (typeof error !== "number") throw error; }); + expect(installed).toBe(true); + expect(exits).toEqual([success ? packageOnly ? UPDATE_PACKAGE_ONLY_EXIT_CODE : 0 : 1]); + expect(JSON.parse(readFileSync(join(live, "package.json"), "utf8")).version).toBe(success ? "2.0.0" : "1.0.0"); + expect(readdirSync(dirname(live)).filter(name => name.startsWith(".ocx-staging-"))).toEqual([]); + if (!success) expect(readdirSync(dirname(live)).filter(name => name.startsWith(".ocx-backup-"))).toEqual([]); + } finally { temp.remove(); } + }); + } +} + +test("npm authorization refusal preserves the usable launcher and previous-version guidance", () => { + const failure = { ok: false, phase: "authorize" }; + expect(launcherUsableAfterNpmUpdate(failure)).toBe(true); + expect(npmUpdateFailureGuidance({ ...failure, pkgName: "@fixture/opencodex", tag: "latest" }).previousVersionKept).toBe(true); +}); + +for (const outcome of ["false", "throw", "promise"] as const) test(`async transaction fails closed on ${outcome} authorization`, async () => { + const temp = createTempHome("ocx-swap-refusal-"); + try { + const live = temp.path("scope", "opencodex"); tree(live, "1.0.0"); + const result = await transactionalNpmUpdateAsync({ packageDir: live, pkgName: "@fixture/opencodex", tag: "latest", + mayReplacePackage: (() => { + if (outcome === "throw") throw new Error("private authority detail"); + return outcome === "promise" ? Promise.resolve(true) : false; + }) as () => boolean, + runNpm: async args => { + tree(join(args[args.indexOf("--prefix") + 1]!, "node_modules", "@fixture", "opencodex"), "2.0.0"); + return { status: 0, processTreeExited: true }; + }, + }); + expect(result).toMatchObject({ ok: false, phase: "authorize" }); + expect(JSON.stringify(result)).not.toContain("private authority detail"); + expect(JSON.parse(readFileSync(join(live, "package.json"), "utf8")).version).toBe("1.0.0"); + expect(readdirSync(dirname(live))).toEqual(["opencodex"]); + } finally { temp.remove(); } +}); + +test("production pinned caller checks current service permission inside its wrapper callback", async () => { + const prepare = section(worker, "function preparePortForPinnedStart(", "function spawnDetachedStart("); + const caller = section(worker, ' if (!homePolicy.authorizeMutation("runtime")) return false;\n if (preparePort(', " const ready = await waitForGhost("); + for (const changed of ["same", "absent", "unknown"] as const) { + let reads = 0, wrappers = 0, cleanup = 0; + const homePolicy = createUpdateServiceHomePolicy(() => ({ kind: ++reads < 3 ? "same" : changed }), () => {}); + const result = await execute(prepare + "const preparePort = preparePortForPinnedStart;" + caller, { + homePolicy, reclaimGate: () => homePolicy.authorizeMutation("runtime"), job: {}, port: 19001, + listPids: () => [], aliveFn: () => false, verifyOcx: () => null, serviceInstalled: true, + io: { stopServiceWrappersFn: () => { wrappers++; } }, + process: { pid: 1, platform: "linux" }, readPid: () => null, + removePid: () => { cleanup++; }, removeRuntimePort: () => { cleanup++; }, + }); + expect(wrappers).toBe(changed === "same" ? 1 : 0); + expect(cleanup).toBe(changed === "unknown" ? 0 : 2); + expect(result).toBe(changed === "unknown" ? false : undefined); + } +}); + +for (const mutation of ["shim", "tray"] as const) test(`production Node foreground fails on home refusal during ${mutation}`, async () => { + let kind: "absent" | "unknown" = "absent", mutations = 0; + const exits: number[] = [], warnings: string[] = []; + const change = () => { mutations++; kind = "unknown"; }; + const code = section(node, " const postInstallPlan =", "\n if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable"); + await execute(code, { + planUpdateRuntimeHandling, readOwnership: () => ({ ownership: null }), serviceWasInstalled: false, observeSupervision: () => false, + res: { status: 0 }, latest: "2.0.0", authorityLauncher: "old", postUpdateLauncher: "new", + homePolicy: createUpdateServiceHomePolicy(() => ({ kind }), line => warnings.push(line)), + trayBeforeUpdate: { refreshAfterReplacement: mutation === "tray", installArgs: ["tray", "install"] }, postUpdateLauncherUsable: true, + stopAttempted: true, existsSync: () => true, bakePort: 19001, + console: { log() {}, warn() {} }, process: { argv: [], exit: (code: number) => { exits.push(code); throw code; } }, + repairCodexShimIfNeeded: mutation === "shim" ? change : () => {}, + spawnSync: () => { change(); return { status: 0 }; }, mutationChildEnvironment: () => ({}), + releaseUpdateLease() {}, + }).catch((error: unknown) => { if (typeof error !== "number") throw error; }); + expect(mutations).toBe(1); expect(warnings).toHaveLength(1); expect(exits).toEqual([1]); +}); + +const callers = ["Node foreground", "Node absent entry", "Node absent after repair", "Bun foreground", + "Bun absent after reclaim", "Bun absent before repair", "Bun absent after repair"] as const; +for (const caller of callers) for (const outcome of ["vetoed", "refused", true] as const) { + test(`production ${caller} direct recovery ${outcome} -> ${outcome === "refused" ? 1 : 0}`, async () => { + const isNode = caller.startsWith("Node"); + let acquiring = false, ownerReads = 0, starts = 0, repairs = 0; + let home: UpdateServiceHomeAuthority = { kind: caller.includes("foreground") || caller.endsWith("entry") ? "absent" : "same" }; + const warnings: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, line => warnings.push(line)); + const acquire = () => { acquiring = true; if (outcome === "refused") home = { kind: "unknown" }; }; + const ownership = () => { + ownerReads++; + if (caller === "Bun absent before repair" && ownerReads === 2) home = { kind: "absent" }; + return { ownership: acquiring && outcome === "vetoed" ? { owner: "desktop", installId: "other" } : null, ownershipUnknown: false }; + }; + let code: string; + if (isNode) { + code = section(node, " const ownershipIdentity =", " const supervisionLatch =") + + section(node, " function startProxyDirectly()", " // #7043:"); + if (caller === "Node foreground") { + const from = node.indexOf(" } else if (postInstallPlan.mayStopRuntime"); + const end = node.indexOf(" guardedExit(homePolicy.authorizeMutation, \"publish\", 0);", from) + " guardedExit(homePolicy.authorizeMutation, \"publish\", 0);".length; + code += "if (false) {" + node.slice(from, end); + } else code += section(node, " function refreshBackgroundServiceOrStartDirect()", " let updateLease =") + + 'const result = refreshBackgroundServiceOrStartDirect(); return result === undefined ? 0 : 1;'; + } else { + code = section(bun, " const sameOwner =", " // #7043:"); + code += caller === "Bun foreground" + ? section(bun, " if (!homePolicy.serviceAllowed && postInstallPlan.mayStopRuntime)", " if (postInstallPlan.mayRestoreService)") + : section(bun, " if (postInstallPlan.mayRestoreService) {", " } else if (postInstallPlan.mayStopRuntime) {") + "}"; + } + const result = await execute(code, { + homePolicy, initialOwnership: { ownership: null }, readOwnership: ownership, resolvedRuntimeOwnership: async () => ownership(), + observeSupervision: () => false, planUpdateRuntimeHandling, + postInstallPlan: { mayStopRuntime: true, mayRestoreService: true }, stopAttempted: true, + authorityLauncher: "old", postUpdateLauncher: "new", postUpdateLauncherUsable: true, + existsSync: () => true, launcherStartHint: () => "fixture start", bakePort: 19001, + mutation: { reacquire: acquire, release() {}, controlEnvironment: () => ({}) }, + reacquireUpdateLease: acquire, releaseUpdateLease() {}, mutationChildEnvironment: () => ({}), + process: { env: {}, execPath: "fixture-runtime", exit: (code: number) => { throw code; } }, + console: { log() {}, warn: (line: string) => warnings.push(line), error() {} }, + capturedListen: { port: 19001, hostname: "127.0.0.1" }, + spawn: () => { starts++; return { on() {}, once() {}, unref() {} }; }, + withoutSiblingMarker: (env: object) => env, withProcessRuntimeProvenance: (env: object) => env, + readCurrentRuntimeTarget: () => ({ kind: "target", target: { port: 19001, hostname: "127.0.0.1" } }), + probeProxyLiveness: () => "live", UPDATE_RECOVERY_READY_MS: 1000, + readUpdateRuntimeTarget: () => ({ kind: "target", target: { port: 19001, hostname: "127.0.0.1", pid: 200 } }), + getRuntimePortPath: () => "fixture", proxyIdentityAt: async () => ({}), + moduleIo: { + service: async () => ({ serviceReinstallArgs: () => ["service", "repair"] }), + reclaim: async () => ({ guardedReclaimListenPort: async () => { + if (caller === "Bun absent after reclaim") home = { kind: "absent" }; + return true; + } }), + }, + updateChildStdio: () => "inherit", serviceRefreshArgs: () => ["service", "repair"], + spawnSync: () => { repairs++; home = { kind: "absent" }; return { status: 0 }; }, + }).catch((error: unknown) => { if (typeof error !== "number") throw error; return error; }); + expect(acquiring).toBe(true); + expect(result ?? 0).toBe(outcome === "refused" ? 1 : 0); + expect(starts).toBe(outcome === true ? 1 : 0); + expect(repairs).toBe(caller.endsWith("after repair") ? 1 : 0); + expect(warnings.some(line => typeof line === "string" && line.includes("ocx service status"))).toBe(outcome === "refused"); + }); +} + +test("production Node full stop refuses lost service permission before Windows wrapper cleanup", async () => { + const stop = section(node, " const stopNeeded =", " const replacementOwnership ="); + const childStop = section(readFileSync(repoPath("src/service/orchestration.ts"), "utf8"), + "export function stopServiceIfInstalledDetailed()", "/** Delete install-state files").replace("export ", ""); + const guard = section(readFileSync(repoPath("src/service/guards.ts"), "utf8"), + "export function assertServiceEnvironmentMatchesInstall(", "/**\n * Refuse a management").replace("export ", ""); + for (const [captured, changed] of [[true, "same"], [true, "absent"], [true, "unknown"], [false, "absent"]] as const) { + let home: UpdateServiceHomeAuthority = { kind: captured ? "same" : "absent" }, stops = 0, wrappers = 0; + const allowed = changed === "same" || !captured; + const warnings: string[] = [], exits: number[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, line => warnings.push(line)); + home = { kind: changed }; + const deps = { + homePolicy, serviceWasInstalled: captured, hasRuntimeState: !captured, hasPendingTeardown: false, + runtimePlan: { mayStopRuntime: true }, readOwnership: () => ({ ownership: null, ownershipUnknown: false }), + observeSupervision: () => false, planUpdateRuntimeHandling, stopAttempted: false, + releaseUpdateLease() {}, console: { log() {}, warn: (line: string) => warnings.push(line), error() {} }, + process: { platform: "win32", execPath: "fixture", exit: (code: number) => { exits.push(code); throw code; } }, + launcher: "fixture", mutationChildEnvironment: () => ({}), + resolveServiceState: () => ({ kind: changed === "same" ? "state" : "none", state: {} }), + currentCodexHome: () => "", serviceCodexHomeMatchesInstall: () => true, + currentOpenCodexHome: () => "", servicePathMatchesInstall: () => true, + probeWindowsSchedulerTask: () => ({ status: "absent" }), statusWinswRaw: () => "nonexistent", + killWindowsServiceWrapperProcesses: () => { wrappers++; }, classifyWindowsServiceStop: () => "absent", + spawnSync: () => { + stops++; + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(guard + childStop); + updateSourceFunction("deps", `with (deps) { ${js}; return stopServiceIfInstalledDetailed(); }`)(deps); + throw "stop-observed"; // End at the real child service-stop boundary, before package I/O. + }, + }; + await execute(stop, deps).catch((error: unknown) => { if (error !== "stop-observed" && error !== 1) throw error; }); + expect(stops).toBe(allowed ? 1 : 0); + expect(wrappers).toBe(allowed ? 1 : 0); + expect(exits).toEqual(allowed ? [] : [1]); + expect(warnings.some(line => line.includes("ocx service status"))).toBe(!allowed); + } +}); + +for (const lane of ["Bun entry", "Bun before repair", "Bun before manager", "Node entry", "Node before install"] as const) { + test(`production ${lane} refresh preserves home refusal over ownership veto`, async () => { + for (const changed of ["same", "unknown", "foreign"] as const) { + let home: UpdateServiceHomeAuthority = { kind: "same" }, reads = 0, repairs = 0, imports = 0; + const warnings: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, line => warnings.push(line)); + const isNode = lane.startsWith("Node"); + const vetoAt = lane.endsWith("entry") ? 1 : lane.endsWith("manager") ? 3 : 2; + const ownership = () => { + if (++reads !== vetoAt) return { ownership: null, ownershipUnknown: false }; + home = changed === "foreign" ? { kind: "foreign", subject: "a".repeat(64) } : { kind: changed }; + return { ownership: { owner: "desktop", installId: "other" }, ownershipUnknown: false }; + }; + const code = isNode + ? section(node, " function refreshBackgroundServiceOrStartDirect()", " let updateLease =") + + "return refreshBackgroundServiceOrStartDirect();" + : section(bun, " if (postInstallPlan.mayRestoreService) {", " } else if (postInstallPlan.mayStopRuntime) {") + "}"; + const result = await execute(code, { + homePolicy, readOwnership: ownership, resolvedRuntimeOwnership: async () => { await Promise.resolve(); return ownership(); }, + observeSupervision: () => false, planUpdateRuntimeHandling, postInstallPlan: { mayRestoreService: true }, + console: { log() {}, warn: (line: string) => warnings.push(line), error() {} }, + process: { env: {}, execPath: "fixture" }, postUpdateLauncher: "fixture", authorityLauncher: "fixture", bakePort: 19001, + mutation: { release() {}, controlEnvironment: () => ({}) }, mutationChildEnvironment: () => ({}), + capturedListen: { port: 19001, hostname: "127.0.0.1" }, releaseUpdateLease() {}, + serviceRefreshArgs: () => ["service", "repair"], readServiceInstalledFromStatus: () => false, + moduleIo: { + service: async () => { imports++; return { serviceReinstallArgs: () => ["service", "repair"] }; }, + reclaim: async () => ({ guardedReclaimListenPort: async () => true }), + }, + updateChildStdio: () => "inherit", spawnSync: () => { repairs++; return { status: 1 }; }, + }); + expect(reads).toBe(vetoAt); + expect(repairs).toBe(lane.endsWith("manager") || lane.endsWith("install") ? 1 : 0); + expect(imports).toBe(isNode || lane.endsWith("entry") ? 0 : 1); + expect(result).toBe(changed === "same" ? undefined : isNode ? "refused" : 1); + expect(warnings.some(line => line.includes("ocx service status"))).toBe(changed !== "same"); + } + }); +} + +for (const lane of ["Bun", "Node"] as const) test(`production ${lane} final ownership veto preserves home refusal`, async () => { + for (const changed of ["same", "unknown", "foreign"] as const) { + let home: UpdateServiceHomeAuthority = { kind: "same" }; + const warnings: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, line => warnings.push(line)); + const ownership = () => { + home = changed === "foreign" ? { kind: "foreign", subject: "a".repeat(64) } : { kind: changed }; + return { ownership: { owner: "desktop" }, ownershipUnknown: false }; + }; + const code = lane === "Bun" + ? section(bun, " const fallbackOwnership =", " console.warn(\n serviceRefreshed") + : section(node, " const supervision = observeSupervision();\n const nowOwned =", " // #7043: the manager first."); + const result = await execute(code + 'return "continued";', { + homePolicy, readOwnership: ownership, resolvedRuntimeOwnership: async () => { await Promise.resolve(); return ownership(); }, + observeSupervision: () => false, planUpdateRuntimeHandling, console: { warn: (line: string) => warnings.push(line) }, + }); + expect(result).toBe(changed === "same" ? undefined : lane === "Bun" ? 1 : "refused"); + expect(warnings.some(line => line.includes("ocx service status"))).toBe(changed !== "same"); + } +}); + +for (const site of ["entry", "reacquire"] as const) test(`production worker ${site} ownership veto preserves home refusal`, async () => { + const leaseSource = readFileSync(repoPath("src/update/restart-ownership.ts"), "utf8"); + const helpers = section(leaseSource, "export function updateRestartVeto(", "/**\n * The lease boundary").replace("export ", "") + + "const REACQUIRE_WAIT_MS = 10000;" + + leaseSource.slice(leaseSource.indexOf("export async function runUpdateRestartWithOwnershipLease<")).replace("export ", ""); + const refuse = section(worker, " const refuseWorkerHome = () => {", " if (!homePolicy.packageOnly &&"); + const gate = site === "entry" + ? section(worker, ' if (outcome.kind === "veto")', " if (!outcome.value)") + : section(worker, " const restartVeto = io.reacquireForDirectStartFn?.();", " const pid = (io.readPidFn"); + for (const changed of ["same", "unknown", "foreign"] as const) { + let home: UpdateServiceHomeAuthority = { kind: "same" }, reads = 0, status = "running", refused = false; + const homePolicy = createUpdateServiceHomePolicy(() => home, () => { refused = true; status = "failed"; }); + const resolveOwnership = () => { + if (site === "reacquire" && ++reads === 1) return { kind: "none" }; + home = changed === "foreign" ? { kind: "foreign", subject: "a".repeat(64) } : { kind: changed }; + return { kind: "owned", ownership: { owner: "desktop" } }; + }; + const code = site === "entry" + ? helpers + refuse + 'const outcome = await runUpdateRestartWithOwnershipLease(resolveOwnership, async () => { throw Error("unexpected restart"); });' + + gate + : helpers + "return runUpdateRestartWithOwnershipLease(resolveOwnership, async lease => { const io = { reacquireForDirectStartFn: lease.reacquireForDirectStart };" + + gate + "});"; + await execute(code, { + homePolicy, resolveOwnership, createSupervisionLatch, planUpdateRuntimeHandling, + inspectDesktopSupervision: () => ({ kind: "none" }), + acquireOwnershipMutationLease: () => ({ token: "fixture", release() {} }), serviceStatePaths: () => [], + process: { env: {} }, OWNERSHIP_MUTATION_LEASE_TOKEN_ENV: "fixtureLease", job: {}, + updateJob: (_job: object, patch: { status?: string }) => { if (patch.status) status = patch.status; }, + trayWasRunning: false, activeLauncherVerified: true, homeRefusal: "fixture refusal", + }); + expect(refused).toBe(changed !== "same"); + expect(status).toBe(changed === "same" ? "succeeded" : "failed"); + } +}); 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..a9d039ca39a --- /dev/null +++ b/tests/update/update-service-home-policy.test.ts @@ -0,0 +1,227 @@ +import { expect, test } from "bun:test"; +import { createUpdateServiceHomePolicy, isUpdateHomeProbe, parseUpdateHomeProbe, parseUpdateWorkerHome, UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION, UPDATE_WORKER_HOME_FLAG, updateWorkerHomeArgv, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; +import { probeUpdatePackageOnlyLauncher, probeUpdateServiceHome, probeUpdateLauncherHome, updateHomeProbeEnvironment } from "../../src/update/service-home-probe.mjs"; + +const subject = "a".repeat(64); +const other = "b".repeat(64); +test("narrow update-home command requires exact ordered argv", () => { + expect(isUpdateHomeProbe(["__update-home-probe"])).toBe(true); + for (const argv of [["status", "--json"], ["status", "--update-home-only", "--json"], + ["status", "--json", "--update-home-only", "extra"], ["update", "--json", "--update-home-only"]]) { + expect(isUpdateHomeProbe(argv)).toBe(false); + } +}); + +test("child schema is not evidence of its Node parent's protocol", () => { + const child = { schemaVersion: 1, kind: "foreign", subject }; + expect(parseUpdateHomeProbe(child)).toEqual({ kind: "foreign", subject }); + // An old launcher can forward the new CLI response verbatim. + expect(parseUpdateHomeProbe(child, { requireLauncher: true })).toEqual({ kind: "unknown" }); + expect(parseUpdateHomeProbe({ ...child, launcherProtocolVersion: 1 }, { requireLauncher: true })) + .toEqual({ kind: "foreign", subject }); +}); + +test("unsupported or malformed protocol is unknown, never absence", () => { + for (const value of [undefined, null, "", [], {}, { schemaVersion: 1, kind: "foreign" }, + { schemaVersion: 2, kind: "absent" }, { schemaVersion: 1, kind: "same", subject }, + { schemaVersion: 1, kind: "absent", launcherProtocolVersion: 2 }, + { schemaVersion: 1, kind: "foreign", subject: "private-path" }, + { schemaVersion: 1, kind: "same", startup: {} }]) { + expect(parseUpdateHomeProbe(value)).toEqual({ kind: "unknown" }); + } + for (const kind of ["same", "absent", "unknown"] as const) { + expect(parseUpdateHomeProbe({ schemaVersion: 1, kind })).toEqual({ kind }); + } +}); + +test("transition policy preserves only stable foreign foreground authority", () => { + const cases: [UpdateServiceHomeAuthority, UpdateServiceHomeAuthority, boolean][] = [ + [{ kind: "same" }, { kind: "same" }, true], [{ kind: "same" }, { kind: "absent" }, true], + [{ kind: "same" }, { kind: "foreign", subject }, false], [{ kind: "same" }, { kind: "unknown" }, false], + [{ kind: "absent" }, { kind: "absent" }, true], [{ kind: "absent" }, { kind: "same" }, false], + [{ kind: "foreign", subject }, { kind: "foreign", subject }, true], + [{ kind: "foreign", subject }, { kind: "foreign", subject: other }, false], + [{ kind: "foreign", subject }, { kind: "absent" }, false], + [{ kind: "foreign" }, { kind: "foreign" }, false], [{ kind: "unknown" }, { kind: "absent" }, false], + ]; + for (const [initial, current, allowed] of cases) { + const messages: string[] = []; + const policy = createUpdateServiceHomePolicy(() => initial, message => messages.push(message)); + expect(policy.check(current)).toBe(allowed); + expect(policy.serviceAllowed).toBe(allowed && current.kind === "same"); + expect(messages.length).toBe(allowed ? 0 : 1); + expect(messages.join()).not.toContain(subject); + } +}); + +test("probe rejects old CLI, timeout, nonzero and oversized output without another command", () => { + for (const result of [{ status: 1, stdout: "unknown flag" }, { status: null, error: new Error("timeout") }, + { status: 0, stdout: "{" }, { status: 0, stdout: " ".repeat(4097) }, + { status: 0, stdout: '{"schemaVersion":1,"kind":"absent"}', signal: "SIGTERM" }]) { + let calls = 0; + expect(probeUpdateServiceHome((_bin, args, options) => { + calls++; + expect(args).toEqual(["/fixed/launcher", "__update-home-probe"]); + expect(options).toMatchObject({ timeout: 8000, maxBuffer: 4096, windowsHide: true }); + return result; + }, "/fixed/node", ["/fixed/launcher"], { requireLauncher: true })).toEqual({ kind: "unknown" }); + expect(calls).toBe(1); + } + expect(probeUpdateServiceHome(() => { throw new Error("spawn failed"); }, "/node", [])) + .toEqual({ kind: "unknown" }); +}); + +test("missing or invalid package capability never executes the old launcher", () => { + for (const read of [() => { throw new Error("missing"); }, () => "{}", () => '{"schemaVersion":2}']) { + let calls = 0; + expect(probeUpdateLauncherHome("/fixed/launcher", () => { calls++; return { status: 0 }; }, "/node", {}, read)) + .toEqual({ kind: "unknown" }); + expect(calls).toBe(0); + } + const read = () => '{"schemaVersion":1,"launcherProtocolVersion":1,"entry":"__update-home-probe"}'; + for (const marker of [undefined, 1]) { + expect(probeUpdateLauncherHome("/fixed/launcher", () => ({ status: 0, + stdout: JSON.stringify({ schemaVersion: 1, kind: "same", launcherProtocolVersion: marker }) }), "/node", {}, read)) + .toEqual({ kind: marker ? "same" : "unknown" }); + } +}); + +test("probe pins homes, keeps authoritative SQLite/auth inputs and scrubs only internal tokens", () => { + const env = updateHomeProbeEnvironment({ CODEX_HOME: "old", OPENCODEX_HOME: "old", CODEX_SQLITE_HOME: "sqlite", + ANTHROPIC_API_KEY: "fixture", OCX_USER_CHOICE: "keep", OCX_UPDATE_RESTART_CHILD: "remove", + OCX_OWNERSHIP_MUTATION_LEASE_TOKEN: "remove", OCX_SERVICE: "remove", OCX_NODE_LAUNCH_CONTEXT: "remove" }, + { codexHome: "codex", opencodexHome: "proxy" }); + expect(env).toEqual({ CODEX_HOME: "codex", OPENCODEX_HOME: "proxy", CODEX_SQLITE_HOME: "sqlite", + ANTHROPIC_API_KEY: "fixture", OCX_USER_CHOICE: "keep" }); +}); + +test("initial unknown permits package replacement but never grants runtime control", () => { + let home: UpdateServiceHomeAuthority = { kind: "unknown" }; + const messages: string[] = []; + const policy = createUpdateServiceHomePolicy(() => home, message => messages.push(message)); + expect(policy.packageOnly).toBe(true); + expect(policy.checkPackage()).toBe(true); + expect(policy.check()).toBe(false); + expect(policy.serviceAllowed).toBe(false); + for (const kind of ["same", "absent", "foreign"] as const) { + home = { kind, ...(kind === "foreign" ? { subject } : {}) }; + expect(policy.checkPackage()).toBe(false); + expect(policy.packageOnly).toBe(true); + expect(policy.serviceAllowed).toBe(false); + } + const known = createUpdateServiceHomePolicy(() => ({ kind: "same" }), () => {}); + expect(known.checkPackage({ kind: "unknown" })).toBe(false); +}); + +test("package-only capability rejects old declarations and malformed or failed responses", () => { + const declaration = (path: string) => path.endsWith("service-home-protocol.json") + ? '{"schemaVersion":1,"launcherProtocolVersion":1,"entry":"__update-home-probe"}' + : '{"schemaVersion":1,"packageOnlyProtocolVersion":2,"entry":"__update-package-only-probe"}'; + const response = { schemaVersion: 1, packageOnlyProtocolVersion: UPDATE_PACKAGE_ONLY_PROTOCOL_VERSION }; + for (const value of [response, { ...response, extra: true }, { ...response, packageOnlyProtocolVersion: 1 }, 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); +}); + +test("worker-home argv encodes every baseline kind and refuses the unencodable", () => { + expect(updateWorkerHomeArgv({ kind: "same" })).toBe("same"); + expect(updateWorkerHomeArgv({ kind: "absent" })).toBe("absent"); + expect(updateWorkerHomeArgv({ kind: "unknown" })).toBe("unknown"); + expect(updateWorkerHomeArgv({ kind: "foreign", subject })).toBe(`foreign:${subject}`); + for (const home of [undefined, { kind: "foreign" }, { kind: "foreign", subject: "short" }, + { kind: "invalid" }] as const) { + expect(updateWorkerHomeArgv(home as UpdateServiceHomeAuthority | undefined)).toBeNull(); + } +}); + +test("worker-home argv parses only exact shapes; malformed fails closed", () => { + expect(parseUpdateWorkerHome([])).toBeUndefined(); + expect(parseUpdateWorkerHome(["update", "--tag", "latest"])).toBeUndefined(); + for (const kind of ["same", "absent", "unknown"] as const) { + expect(parseUpdateWorkerHome(["update", UPDATE_WORKER_HOME_FLAG, kind])).toEqual({ kind }); + } + expect(parseUpdateWorkerHome([UPDATE_WORKER_HOME_FLAG, `foreign:${subject}`])) + .toEqual({ kind: "foreign", subject }); + for (const argv of [ + [UPDATE_WORKER_HOME_FLAG], + [UPDATE_WORKER_HOME_FLAG, "--update-worker-result"], + [UPDATE_WORKER_HOME_FLAG, "garbage"], + [`${UPDATE_WORKER_HOME_FLAG}=same`], + [UPDATE_WORKER_HOME_FLAG, "same", UPDATE_WORKER_HOME_FLAG, "absent"], + [UPDATE_WORKER_HOME_FLAG, `foreign:${subject.slice(1)}`], + ]) { + expect(parseUpdateWorkerHome(argv)).toEqual({ kind: "invalid" }); + } +}); + +test("a delegated baseline cannot be laundered by the child's fresh inspection", () => { + // The reviewed gap: the worker verified same, the child observes unknown — a fresh + // baseline would have granted the child package-only replacement and unknown→unknown + // stability against an authority change the worker never authorized. + let inspects = 0; + const messages: string[] = []; + const policy = createUpdateServiceHomePolicy(() => { inspects++; return { kind: "unknown" }; }, + message => messages.push(message), true, { kind: "same" }); + expect(inspects).toBe(0); // The pinned baseline replaces inspection; it does not probe again. + expect(policy.initial).toEqual({ kind: "same" }); + expect(policy.checkPackage()).toBe(false); + expect(messages.length).toBe(1); +}); + +test("a delegated unknown baseline keeps only the stable package-only replacement", () => { + let home: UpdateServiceHomeAuthority = { kind: "unknown" }; + const policy = createUpdateServiceHomePolicy(() => home, () => {}, true, { kind: "unknown" }); + expect(policy.packageOnly).toBe(true); + expect(policy.checkPackage()).toBe(true); // Stable unknown→unknown is the intended INITIAL behavior. + home = { kind: "same" }; + expect(policy.checkPackage()).toBe(false); // A registration appearing mid-update still refuses. + home = { kind: "foreign", subject }; + expect(policy.checkPackage()).toBe(false); +}); + +test("a malformed delegated baseline fails closed for every check", () => { + const policy = createUpdateServiceHomePolicy(() => ({ kind: "same" }), () => {}, false, { kind: "invalid" }); + expect(policy.checkPackage()).toBe(false); + expect(policy.check()).toBe(false); + expect(policy.serviceAllowed).toBe(false); + expect(policy.packageOnly).toBe(false); +}); + +test("a refused foreign or absent baseline cannot acquire service authority from a new matching observation", () => { + for (const initial of [{ kind: "absent" }, { kind: "foreign", subject }] as const) { + const policy = createUpdateServiceHomePolicy(() => ({ kind: "same" }), () => {}, false, initial); + expect(policy.checkPackage()).toBe(false); + expect(policy.serviceAllowed).toBe(false); + } +}); + +test("package-only protocol v2 refuses v1 counterparts in both directions", () => { + const declaration = (version: number) => (path: string) => path.endsWith("service-home-protocol.json") + ? '{"schemaVersion":1,"launcherProtocolVersion":1,"entry":"__update-home-probe"}' + : `{"schemaVersion":1,"packageOnlyProtocolVersion":${version},"entry":"__update-package-only-probe"}`; + for (const response of [1, 2]) { + const result = probeUpdatePackageOnlyLauncher("/launcher", () => ({ status: 0, + stdout: JSON.stringify({ schemaVersion: 1, packageOnlyProtocolVersion: response }) }), "/node", {}, declaration(2)); + expect(result).toBe(response === 2); + } + let calls = 0; + expect(probeUpdatePackageOnlyLauncher("/launcher", () => { calls++; return { status: 0 }; }, "/node", {}, declaration(1))).toBe(false); + expect(calls).toBe(0); // A v1 launcher's declaration is refused before the old code is executed. +}); diff --git a/tests/update/update-service-home.test.ts b/tests/update/update-service-home.test.ts new file mode 100644 index 00000000000..d40b1c57033 --- /dev/null +++ b/tests/update/update-service-home.test.ts @@ -0,0 +1,311 @@ +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("a home change during the service-args import refuses before wrapper stop or port reclaim", async () => { + record(b); const { calls, io } = workerIo(); + // The launcher read runs immediately after `await import("../service")`: staging the + // flip there models authority changing while that import was in flight, before the + // wrapper stop and port reclaim that must not run under foreign authority. + io.packageLauncherPathFn = () => { record(a); return "/fixture/launcher"; }; + 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 }); + const persisted = readUpdateJob("home-guard"); + expect(persisted).toMatchObject({ status: "failed", restarted: false }); + expect(persisted?.error).toContain("ocx service status"); +}); + +test("a home change during the port reclaim wait refuses the repair and every later side effect", async () => { + record(b); const { calls, io } = workerIo(); + // The reclaim itself started under the entry authority and cannot be preempted + // mid-wait; the check after the wait must refuse the repair and the manager. + io.waitForPort = async () => { calls.reclaim++; record(a); return true; }; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 1, kill: 0, spawn: 0, wrappers: 1 }); + expect(readUpdateJob("home-guard")).toMatchObject({ status: "failed", restarted: false }); +}); + +test("the reclaim wait hands the real reclaimer a live home-authority gate", async () => { + record(b); const { calls, io } = workerIo(); + // The real reclaimer consults this gate immediately before every kill and + // TCP-row drop; the injected wait flips the registration mid-wait and exercises + // the gate itself, the same way an in-flight availability await would. + let gateDenied = false; + io.waitForPort = async (_port, _hostname, opts) => { + calls.reclaim++; + record(a); + gateDenied = opts?.mayMutateFn?.() === false; + return false; + }; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(gateDenied).toBe(true); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 1, kill: 0, spawn: 0, wrappers: 1 }); + const persisted = readUpdateJob("home-guard"); + expect(persisted).toMatchObject({ status: "failed", restarted: false }); + expect(persisted?.error).toContain("ocx service status"); + // A denied reclaim must not be misclassified as a busy-port timeout. + expect(JSON.stringify(persisted)).not.toContain("still busy"); +}); + +test("a latched reclaim denial outranks a transient re-match before the wait ends", async () => { + record(b); const { calls, io } = workerIo(); + // The mid-wait denial latches; restoring B's record before the wait returns must + // not re-arm the busy-port repair path — the recorded refusal stands. + let deniedInWait = false; + io.waitForPort = async (_port, _hostname, opts) => { + calls.reclaim++; + record(a); + deniedInWait = opts?.mayMutateFn?.() === false; + record(b); // transient re-match before the wait returns + return false; + }; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(deniedInWait).toBe(true); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 1, kill: 0, spawn: 0, wrappers: 1 }); + const persisted = readUpdateJob("home-guard"); + expect(persisted).toMatchObject({ status: "failed", restarted: false }); + expect(persisted?.error).toContain("ocx service status"); + expect(JSON.stringify(persisted)).not.toContain("still busy"); +}); + +test("the direct recovery reclaim also keeps a transient authority denial latched", async () => { + record(a); const { calls, io } = workerIo(); + let deniedInWait = false; + io.waitForPort = async (_port, _hostname, opts) => { + calls.reclaim++; + record(b); + deniedInWait = opts?.mayMutateFn?.() === false; + record(a); + return false; + }; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(deniedInWait).toBe(true); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 1, kill: 1, spawn: 0, wrappers: 0 }); + const persisted = readUpdateJob("home-guard"); + expect(persisted).toMatchObject({ status: "failed", restarted: false }); + expect(JSON.stringify(persisted)).not.toContain("still busy"); +}); + +test("confirmed absence after lease release skips repair and every later global service operation", async () => { + record(b); const { calls, io } = workerIo(); + io.releaseForServiceManagerFn = () => unlinkSync(statePath); + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe("unsupervised"); + expect(calls).toEqual({ repair: 0, manager: 0, reclaim: 2, kill: 1, spawn: 1, wrappers: 1 }); +}); + +test("a manager's explicit refusal cannot turn into an ordinary fallback", async () => { + record(b); const { calls, io } = workerIo(); + io.restartServiceManagerFn = async () => "refused"; + expect(await restartAfterUpdateForTests(fixtureJob(), { port: 19001, hostname: "127.0.0.1" }, io)).toBe(false); + expect(calls.kill).toBe(0); expect(calls.spawn).toBe(0); + expect(readUpdateJob("home-guard")).toMatchObject({ status: "failed", restarted: false }); +}); + +test("refusal guidance is static and points to manual service status", () => { + for (const kind of ["foreign", "unknown"] as const) expect(updateServiceHomeRefusal({ kind })).toContain("ocx service status"); + for (const kind of ["same", "absent"] as const) expect(updateServiceHomeRefusal({ kind })).toBeNull(); +}); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index d16ae44cafb..0dbc658d27f 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -196,6 +196,8 @@ function recoveryInstrumentationPrelude(directory: string): string { // Reuse exactly the projector exercised by the in-process redaction tests. return ` import { spawn as fixtureSpawn, spawnSync } from "node:child_process"; +const probeSpawnSync = spawnSync; +const probeSpawn = spawn; import { openSync as fixtureOpen, closeSync as fixtureClose, appendFileSync as fixtureAppend } from "node:fs"; const fixtureDiagnosticDir = ${JSON.stringify(directory)}; const RECOVERY_ERROR_CODES = new Set(${JSON.stringify([...RECOVERY_ERROR_CODES])}); @@ -238,15 +240,15 @@ function instrumentRecoveryLauncher(source: string, directory: string): string { if (source.split(needle).length !== 2) throw new Error("recovery diagnostic fixture: launcher seam changed"); source = source.replace(needle, () => replacement); }; - replaceOnce('import { spawn, spawnSync } from "node:child_process";', recoveryInstrumentationPrelude(directory)); + replaceOnce('import { probeSpawn, probeSpawnSync } from "../src/update/mutation-guard.mjs";', recoveryInstrumentationPrelude(directory)); const boot = 'const probe = bootRestoreProbe(resolve(here, ".."));'; replaceOnce(boot, `fixtureStatus("boot-restore-enter");\n ${boot}\n fixtureStatus("boot-restore-result", { action: probe.action });`); replaceOnce('} catch { /* the probe must never block launch */ }', '} catch (error) { fixtureStatus("boot-restore-error", recoveryErrorFields(error)); /* the probe must never block launch */ }'); const runtime = 'const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection });'; replaceOnce(runtime, `fixtureStatus("runtime-resolution-enter");\n${runtime}\nfixtureStatus("runtime-resolved", { source: bunRuntime.source });`); - const install = 'const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });'; + const install = 'const r = probeSpawnSync(process.execPath, [installJs], { stdio: "inherit" });'; replaceOnce(install, `fixtureStatus("runtime-install-enter");\n ${install}\n fixtureStatus("runtime-install-result", { exitCode: r.status, signal: r.signal, ...recoveryErrorFields(r.error) });`); - replaceOnce('const child = spawn(bun,', 'fixtureStatus("runtime-spawn-call");\nconst child = spawn(bun,'); + replaceOnce('const child = probeSpawn(bun,', 'fixtureStatus("runtime-spawn-call");\nconst child = probeSpawn(bun,'); // The updater exits before its detached child, so observe the Bun child from the // recovery launcher itself, BEFORE the existing handler mirrors its exit/signal. replaceOnce('child.on("exit", (code, signal) => {', `child.on("exit", (code, signal) => { @@ -340,12 +342,12 @@ describe("bounded recovery diagnostics", () => { test("instrumentation fails closed if any selected launcher seam disappears or duplicates", () => { const seams = [ - 'import { spawn, spawnSync } from "node:child_process";', + 'import { probeSpawn, probeSpawnSync } from "../src/update/mutation-guard.mjs";', 'const probe = bootRestoreProbe(resolve(here, ".."));', '} catch { /* the probe must never block launch */ }', 'const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection });', - 'const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });', - 'const child = spawn(bun,', 'child.on("exit", (code, signal) => {', + 'const r = probeSpawnSync(process.execPath, [installJs], { stdio: "inherit" });', + 'const child = probeSpawn(bun,', 'child.on("exit", (code, signal) => {', 'child.on("error", err => {', 'const clearHandlers = () => {', ]; for (const seam of seams) { @@ -369,7 +371,7 @@ describe("bounded recovery diagnostics", () => { let nextFd = 10; // Evaluate ONLY the generated prelude with inert dependencies, never the real updater. const prelude = recoveryInstrumentationPrelude("/fixture").replace(/^import .*;\n/gm, ""); - const fixture = new Function("fixtureSpawn", "fixtureOpen", "fixtureClose", "fixtureAppend", "process", + const fixture = new Function("fixtureSpawn", "fixtureOpen", "fixtureClose", "fixtureAppend", "process", "spawnSync", `${prelude}\nreturn { spawn, fixtureStatus };`)( (...args: unknown[]) => { calls.push(args); if (failSpawn) throw failure; return child; }, (_path: string, flags: string, mode: number) => { @@ -384,6 +386,7 @@ describe("bounded recovery diagnostics", () => { records.push(record); }, { argv: ["node", "fixture", "start"], pid: 123, on: () => {} }, + () => { throw new Error("unexpected sync spawn in capture-only fixture"); }, ) as { spawn: (bin: string, args: string[], options: Record) => typeof child; fixtureStatus: (event: string, fields?: object) => void }; const args = ["launcher", "start", "--port", "1234"]; const env = { FIXTURE: "unchanged" }; @@ -421,17 +424,17 @@ describe("bounded recovery diagnostics", () => { }); test("instrumented inert launcher records milestones without changing error/exit handlers", () => { - const inertSource = `import { spawn, spawnSync } from "node:child_process"; + const inertSource = `import { probeSpawn, probeSpawnSync } from "../src/update/mutation-guard.mjs"; try { const probe = bootRestoreProbe(resolve(here, "..")); } catch { /* the probe must never block launch */ } function resolveBun() { - const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); + const r = probeSpawnSync(process.execPath, [installJs], { stdio: "inherit" }); return { source: "bundled", path: "fixture-bun" }; } const bunRuntime = resolveBun({ allowInstall: !codexCliUpdateInspection }); const bun = bunRuntime.path; -const child = spawn(bun, ["fixture-cli", "start"], childOptions); +const child = probeSpawn(bun, ["fixture-cli", "start"], childOptions); const clearHandlers = () => { events.push("clear"); }; child.on("error", err => { clearHandlers(); events.push(err); process.exit(1); }); child.on("exit", (code, signal) => { @@ -528,19 +531,19 @@ describe("update stops the running proxy before replacing files", () => { }); test("bun/source update path gates on the pid file and spawns 'stop' before the package manager", () => { - expect(updateSource).toContain('spawnSync(process.execPath, selfLaunchArgv(["stop"])'); + expect(updateSource).toContain('guardedSpawnSync(homePolicy.authorizeMutation, serviceWasInstalled ? "service" : "runtime", process.execPath, selfLaunchArgv(["stop"])'); const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); - const updateAt = updateSource.indexOf("spawnSync(target.bin, target.args", stopAt); + const updateAt = updateSource.indexOf('guardedSpawnSync(homePolicy.authorizeMutation, "package", target.bin, target.args', stopAt); expect(stopAt).toBeGreaterThan(-1); expect(updateAt).toBeGreaterThan(-1); 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", () => { - const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner);"); + const gateAt = updateSource.indexOf("const integrity = checkUpdatePackageIntegrity(latest, probeSpawnSync, installer, owner);"); const abortAt = updateSource.indexOf("aborting the update before stopping the proxy"); const stopAt = updateSource.indexOf('selfLaunchArgv(["stop"])'); expect(gateAt).toBeGreaterThan(-1); @@ -553,7 +556,7 @@ describe("update stops the running proxy before replacing files", () => { const runtimeGate = updateSource.indexOf("const cachePreflight = runNpmCachePreflight();"); const runtimeStop = updateSource.indexOf('selfLaunchArgv(["stop"])'); const launcherGate = launcherSource.indexOf("runNpmCachePreflight({ cachePath: npmCache.path })"); - const launcherTrayStop = launcherSource.indexOf('runTrayLifecycle(launcher, "stop")'); + const launcherTrayStop = launcherSource.indexOf('runTrayLifecycle(launcher, "stop", homePolicy.authorizeMutation)'); const launcherProxyStop = launcherSource.indexOf('[launcher, "stop"]'); expect(runtimeGate).toBeGreaterThan(-1); @@ -566,7 +569,7 @@ describe("update stops the running proxy before replacing files", () => { }); test("npm launcher update path stops via its own launcher path before npm install", () => { - expect(launcherSource).toContain('spawnSync(process.execPath, [launcher, "stop"]'); + expect(launcherSource).toContain('guardedSpawnSync(homePolicy.authorizeMutation, serviceWasInstalled ? "service" : "runtime", process.execPath, [launcher, "stop"]'); const stopAt = launcherSource.indexOf('[launcher, "stop"]'); // #1942: the destructive step is now the transactional staged update, not a direct // global npm install. The stop must still precede it. @@ -793,8 +796,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 +808,7 @@ esac }); test("the Node updater holds one authority from stop permission through replacement", () => { - const leaseAt = launcherSource.indexOf("const updateLease = acquireOwnershipMutationLease("); + const leaseAt = launcherSource.indexOf("let updateLease = acquireOwnershipMutationLease("); const lockedPlanAt = launcherSource.indexOf("const lockedPlan = planUpdateRuntimeHandling(", leaseAt); const stopAt = launcherSource.indexOf('[launcher, "stop"]', lockedPlanAt); const replacementAt = launcherSource.indexOf("const replacementOwnership = readOwnership()", stopAt); diff --git a/tests/update/update-supervised-recovery.test.ts b/tests/update/update-supervised-recovery.test.ts index 6c3e354465f..b7a831377eb 100644 --- a/tests/update/update-supervised-recovery.test.ts +++ b/tests/update/update-supervised-recovery.test.ts @@ -32,6 +32,11 @@ function recorder(reply: { ok: boolean; stdout?: string; stderr?: string }) { } describe("service manager commands", () => { + test("a final authority refusal is returned without a manager dispatch", () => { + const { calls, run } = recorder({ ok: true }); + expect(restartServiceManagerJob({ platform: "linux", run }, () => false)).toMatchObject({ ok: false, refused: true }); + expect(calls).toEqual([]); + }); test("the shared names match the service layer's label and unit", () => { expect(LAUNCHD_SERVICE_LABEL).toBe(LABEL); expect(SYSTEMD_SERVICE_UNIT).toBe(TASK); @@ -39,20 +44,20 @@ describe("service manager commands", () => { test("macOS restarts the loaded job in place with kickstart -k", () => { const { calls, run } = recorder({ ok: true }); - expect(restartServiceManagerJob({ platform: "darwin", uid: 501, run })).toEqual({ ok: true }); + expect(restartServiceManagerJob({ platform: "darwin", uid: 501, run }, () => true)).toEqual({ ok: true }); expect(calls).toEqual([{ file: "/bin/launchctl", args: ["kickstart", "-k", `gui/501/${LABEL}`] }]); }); test("Linux restarts the user unit through systemctl", () => { const { calls, run } = recorder({ ok: true }); - expect(restartServiceManagerJob({ platform: "linux", uid: 1000, run })).toEqual({ ok: true }); + expect(restartServiceManagerJob({ platform: "linux", uid: 1000, run }, () => true)).toEqual({ ok: true }); expect(calls).toEqual([{ file: "systemctl", args: ["--user", "restart", TASK] }]); }); test("a refused restart and an unsupported platform are failures, never thrown", () => { const { run } = recorder({ ok: false, stderr: "Could not find service\nmore" }); - expect(restartServiceManagerJob({ platform: "darwin", uid: 501, run })).toEqual({ ok: false, reason: "Could not find service" }); - expect(restartServiceManagerJob({ platform: "win32", run }).ok).toBe(false); + expect(restartServiceManagerJob({ platform: "darwin", uid: 501, run }, () => true)).toEqual({ ok: false, reason: "Could not find service" }); + expect(restartServiceManagerJob({ platform: "win32", run }, () => true).ok).toBe(false); }); test("manager PIDs are parsed from launchctl print and systemctl show", () => { @@ -72,7 +77,7 @@ describe("service manager commands", () => { describe("a restart the manager may still finish", () => { test("a client timeout is unsettled, never a plain failure", () => { const run = () => ({ ok: false, stdout: "", stderr: "spawnSync systemctl ETIMEDOUT", timedOut: true }); - const result = restartServiceManagerJob({ platform: "linux", run }); + const result = restartServiceManagerJob({ platform: "linux", run }, () => true); expect(result).toMatchObject({ ok: false, unsettled: true }); expect(unsettledManagerWarning(10100)).toContain("no second proxy was started"); }); @@ -120,7 +125,7 @@ describe("armed test processes never reach the live manager", () => { test("the default runner refuses launchctl and systemctl", () => { process.env.OCX_TEST_HOME_GUARD = "1"; for (const platform of ["darwin", "linux"] as const) { - const result = restartServiceManagerJob({ platform, uid: 501 }); + const result = restartServiceManagerJob({ platform, uid: 501 }, () => true); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toContain("armed test process"); } @@ -135,7 +140,7 @@ describe("the Bun updater tries the manager before any detached start", () => { expect(managerAt).toBeGreaterThan(-1); expect(fallbackAt).toBeGreaterThan(managerAt); const tail = source.slice(fallbackAt, source.indexOf("} finally {", fallbackAt)); - expect(tail).toContain('if (await startUnsupervisedFallback() === "vetoed") return;'); + expect(tail).toContain('if (await startUnsupervisedFallback() === "vetoed") return homePolicy.authorizeMutation("publish") ? undefined : 1;'); expect(tail).toContain("return 1;"); expect(tail).not.toContain("startProxyDirectly()"); }); diff --git a/tests/update/update-tray-handoff.test.ts b/tests/update/update-tray-handoff.test.ts index 6250e591da9..53dd334f9df 100644 --- a/tests/update/update-tray-handoff.test.ts +++ b/tests/update/update-tray-handoff.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { mkdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate, windowsTrayStopConfirmed } from "../../src/update/tray-update-plan.mjs"; -import { repoRoot } from "../helpers/repo-root"; +import { helperPath, repoRoot } from "../helpers/repo-root"; +import { createTempHome } from "../helpers/temp-home"; describe("Windows tray update handoff contract", () => { test("preserves an installed, running tray across replacement", () => { @@ -56,8 +57,66 @@ describe("Windows tray update handoff contract", () => { for (const path of ["src/update/index.ts", "src/update/job.ts", "bin/ocx.mjs"]) { const source = readFileSync(join(root, path), "utf8"); expect(source).toContain("planWindowsTrayUpdate"); - expect(source).toContain("handoffWindowsTrayForUpdate"); + if (path === "src/update/job.ts") { + expect(source).toContain('import { defaultWorkerTrayHandoff } from "./job-update-tray";'); + expect(source).toContain("(io.trayHandoffFn ?? defaultWorkerTrayHandoff)(plan => {"); + const handoff = readFileSync(join(root, "src/update/job-update-tray.ts"), "utf8"); + expect(handoff).toContain('import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";'); + expect(handoff).toContain("return handoffWindowsTrayForUpdate(status, {"); + } else { + expect(source).toContain("handoffWindowsTrayForUpdate"); + } expect(source).toContain("installArgs"); } }); }); + +function defaultWorker(scenario: string) { + const box = createTempHome("ocx-default-worker-tray-"); + try { + mkdirSync(box.codexHome); + const child = Bun.spawnSync([process.execPath, helperPath("update-worker-tray-child.ts"), box.root, scenario], + { env: { ...process.env }, stdout: "pipe", stderr: "pipe", timeout: 12_000 }); + const log = child.stdout.toString() + child.stderr.toString(); + expect(child.exitCode, log).toBe(0); + const receipt = child.stdout.toString().split("\n").find(line => line.startsWith("TRAY_WORKER_PROBE:")); + expect(receipt, log).toBeDefined(); + return JSON.parse(receipt!.slice("TRAY_WORKER_PROBE:".length)) as { + calls: string[]; commands: string[][]; replacements: number; status: string; restarted: boolean; error?: string; + }; + } finally { box.remove(); } +} + +test("default Windows worker recovers its stopped tray after status and first start both fail", () => { + const result = defaultWorker("running-status-start-fail"); + expect(result.calls).toEqual(["status", "stop", "status", "native-start"]); + expect(result.commands).toEqual([["tray", "start"]]); + expect(result.replacements).toBe(0); + expect(result).toMatchObject({ status: "failed", restarted: false }); + expect(result.error).toContain("Could not stop the Windows tray"); +}, 15_000); + +test("default Windows worker preserves the handoff failure if launcher cleanup also fails", () => { + const result = defaultWorker("running-cleanup-fail"); + expect(result.commands).toEqual([["tray", "start"]]); + expect(result.replacements).toBe(0); + expect(result.status).toBe("failed"); + expect(result.error).toContain("Could not stop the Windows tray"); +}, 15_000); + +for (const scenario of ["running-success", "idle", "absent"]) test(`default Windows worker behavior for ${scenario}`, () => { + const result = defaultWorker(scenario); + expect(result.calls).toEqual(scenario === "running-success" ? ["status", "stop", "status"] : ["status"]); + expect(result.commands).toEqual(scenario === "running-success" ? [["tray", "install"]] + : scenario === "idle" ? [["tray", "install", "--no-start"]] : []); + expect(result.replacements).toBe(1); + expect(result.status).toBe("succeeded"); +}, 15_000); + +test("default Windows worker with no initial tray evidence performs no cleanup or replacement", () => { + const result = defaultWorker("initial-status-fail"); + expect(result.calls).toEqual(["status"]); + expect(result.commands).toEqual([]); + expect(result.replacements).toBe(0); + expect(result.status).toBe("failed"); +}, 15_000); diff --git a/tests/update/update-windows-wrapper-authority.test.ts b/tests/update/update-windows-wrapper-authority.test.ts new file mode 100644 index 00000000000..1b6456d71ba --- /dev/null +++ b/tests/update/update-windows-wrapper-authority.test.ts @@ -0,0 +1,73 @@ +import { guardedUpdateIo } from "../helpers/update-guarded-io.mjs"; +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; +import { createUpdateServiceHomePolicy, type UpdateServiceHomeAuthority } from "../../src/update/service-home-policy.mjs"; + +const helper = readFileSync(repoPath("src/update/job-windows-restart.ts"), "utf8"); +const worker = readFileSync(repoPath("src/update/job.ts"), "utf8"); +const helperBody = helper.slice(helper.indexOf("export function stopWindowsServiceWrappersBestEffort")).replace("export ", ""); +function execute(body: string, deps: object): any { + // Complete production bodies; module loading and external OS/store observations are injected. + const js = new Bun.Transpiler({ loader: "ts" }).transformSync(body.replaceAll('await import("../service")', "await moduleIo.service()")); + return new Function("deps", `with(deps){${js}}`)({ ...deps, ...guardedUpdateIo(deps) }); +} +const authority = (kind: "same" | "absent" | "unknown" | "foreign"): UpdateServiceHomeAuthority => + kind === "foreign" ? { kind, subject: "a".repeat(64) } : { kind }; + +for (const phase of ["stop", "throwing-stop", "backend-native", "backend-throw"] as const) { + for (const changed of ["same", "absent", "unknown", "foreign"] as const) { + test(`Windows wrapper helper checks ${changed} service authority after ${phase}`, () => { + let home = authority("same"); + const events: string[] = []; + const homePolicy = createUpdateServiceHomePolicy(() => home, () => {}); + execute(helperBody + ';stopWindowsServiceWrappersBestEffort(homePolicy.authorizeMutation);', { + homePolicy, process: { platform: "win32" }, join, getConfigDir: () => "/fixture/own", + readServiceBackend: () => { + if (phase.startsWith("backend")) home = authority(changed); + if (phase === "backend-throw") throw new Error("fixture backend read"); + return phase === "backend-native" ? "native" : "scheduler"; + }, + stopWinswService: () => events.push(`native:${home.kind}`), + stopWindows: () => { + events.push(`stop:${home.kind}`); home = authority(changed); + if (phase === "throwing-stop") throw new Error("fixture scheduler stop"); + }, + killWindowsSchedulerWrappers: () => events.push(`kill:${home.kind}`), + }); + const stable = phase === "backend-native" ? ["native:same"] : phase === "backend-throw" ? ["kill:same"] : ["stop:same", "kill:same"]; + expect(events).toEqual(changed === "same" ? stable : phase.startsWith("backend") ? [] : ["stop:same"]); + // The same policy must retain unknown/foreign refusal even if registration re-matches. + home = authority("same"); + expect(homePolicy.authorizeMutation("service")).toBe(changed === "same" || changed === "absent"); + }); + } +} + +for (const changed of ["same", "absent", "unknown", "foreign"] as const) { + test(`complete worker passes its latched authorizer through Windows wrapper recovery: ${changed}`, async () => { + let home = authority("same"); + const events: string[] = []; + const from = worker.indexOf("async function restartAfterUpdate("), to = worker.indexOf("/** Compact listen-holder summary", from); + expect(from).toBeGreaterThan(0); expect(to).toBeGreaterThan(from); + const result = await execute(helperBody + worker.slice(from, to) + + ';return restartAfterUpdate({installer:"npm"},{port:19001,hostname:"127.0.0.1"},io);', { + createUpdateServiceHomePolicy, join, getConfigDir: () => "/fixture/own", + process: { platform: "win32", env: {}, pid: 99 }, loadConfig: () => ({}), + restartCommand: () => ({ bin: "fixture", args: [], display: "fixture" }), updateJob() {}, + RESTART_PORT_RECLAIM_MS: 30000, WINDOWS_SERVICE_REPAIR_TIMEOUT_MS: 1000, RESTART_TIMEOUT_MS: 1000, + moduleIo: { service: async () => ({ serviceReinstallArgs: () => ["service", "repair"] }) }, + readServiceBackend: () => "scheduler", stopWinswService() {}, + stopWindows: () => { events.push(`stop:${home.kind}`); home = authority(changed); }, + killWindowsSchedulerWrappers: () => events.push(`kill:${home.kind}`), + io: { serviceHomeAuthorityFn: () => home, serviceInstalledFn: () => true, packageLauncherPathFn: () => "fixture", + waitForPort: async (_p: unknown, _h: unknown, options: any) => options.mayMutateFn(), + listListenPidsFn: () => [], verifyOcxFn: () => null, isAliveFn: () => false, + serviceViableFn: () => false, runService: () => ({ status: 1 }), restartServiceManagerFn: async () => "failed", + reacquireForDirectStartFn: () => ({ notice: "fixture ownership veto", failed: false }), readPidFn: () => null, spawnStart: () => events.push("spawn") }, + }); + expect(events).toEqual(changed === "same" ? ["stop:same", "kill:same"] : ["stop:same"]); + if (changed === "unknown" || changed === "foreign") expect(result).toBe(false); + }); +} diff --git a/tests/windows/windows-deploy-close-regressions.test.ts b/tests/windows/windows-deploy-close-regressions.test.ts index fd15e906c2d..d6ea97d0b92 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)(homePolicy.authorizeMutation);"); + const restart = read("src/update/job-windows-restart.ts"); + expect(restart).toContain("export function stopWindowsServiceWrappersBestEffort(authorizeMutation: UpdateMutationAuthorizer)"); + 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*if \(!authorizeMutation\("service"\)\) return;\s*guardedStopWinsw\(authorizeMutation\);\s*return;\s*\}\s*if \(!authorizeMutation\("service"\)\) return;\s*guardedStopWindows\(authorizeMutation\);/); + expect(restart.indexOf("killWindowsServiceWrapperProcesses(authorizeMutation);")).toBeGreaterThan(restart.indexOf("guardedStopWindows(authorizeMutation);")); // 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).toMatch(/const paths = \{\s*scriptPath: join\(getConfigDir\(\), "opencodex-service.cmd"\),\s*launcherPath: join\(getConfigDir\(\), "opencodex-service-launcher.vbs"\),\s*\};\s*if \(!authorizeMutation\("service"\)\) return;\s*guardedKillWindowsWrappers\(authorizeMutation, paths\);/); 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 b8f3b4163b4..3e91d5d0a61 100644 --- a/tests/windows/windows-service-wrappers.test.ts +++ b/tests/windows/windows-service-wrappers.test.ts @@ -131,9 +131,12 @@ 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"); + if (rel === "src/update/job-windows-restart.ts") { + expect(src).toContain("killWindowsServiceWrapperProcesses(authorizeMutation);"); + expect(src).toMatch(/const paths = \{[\s\S]*?scriptPath: join\(getConfigDir\(\), "opencodex-service.cmd"\),[\s\S]*?launcherPath: join\(getConfigDir\(\), "opencodex-service-launcher.vbs"\),\s*\};\s*if \(!authorizeMutation\("service"\)\) return;\s*guardedKillWindowsWrappers\(authorizeMutation, paths\);/); + } else expect(src).toContain("killWindowsSchedulerWrappers({"); expect(src).not.toContain("-like ('*' + $p + '*')"); expect(src).not.toContain("$pats = @('opencodex-service.cmd'"); } diff --git a/tests/windows/winsw-stop-hardening.test.ts b/tests/windows/winsw-stop-hardening.test.ts index 9da5396810f..4d081d7b3fd 100644 --- a/tests/windows/winsw-stop-hardening.test.ts +++ b/tests/windows/winsw-stop-hardening.test.ts @@ -10,7 +10,7 @@ describe("WinSW lifecycle stop hardening", () => { const end = source.indexOf("export function uninstallWinswService", start); const stop = source.slice(start, end); - expect(stop).toContain('runWinsw(["stopwait"])'); + expect(stop).toContain('runWinsw(["stopwait"], gate)'); expect(stop).toContain("const status = statusWinswRaw();"); expect(stop).toContain('status === "stopped" || status === "nonexistent"'); expect(stop).toContain('status === "unknown"');