From f8bdbc1103a07e03b583a4b2411cf26875faf4b5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 11:58:34 +0900 Subject: [PATCH 1/5] fix(service): refuse service activation while OpenCodex Desktop runs the proxy --- src/service/cli.ts | 24 +- src/service/desktop-command-guard.ts | 32 ++ src/service/orchestration.ts | 20 +- src/service/repair.ts | 9 + .../service-ownership-handover.test.ts | 293 ++++++++++++++++++ 5 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 src/service/desktop-command-guard.ts diff --git a/src/service/cli.ts b/src/service/cli.ts index 7fde155a7ed..2efb0b86345 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -26,6 +26,7 @@ import type { WindowsSchedulerTaskProbe } from "./windows-scheduler"; import { win32 } from "node:path"; import { serviceDiagnosticsSummary } from "./diagnostics"; import { runServiceClaim } from "./claim"; +import { createSupervisionLatch, desktopServiceCommandRefusal } from "./desktop-command-guard"; /** * `restart` is NO LONGER folded into `repair`. @@ -214,6 +215,7 @@ export function removeServiceTokenAfterUninstall( /** Execute a service verb while preserving client-owned credentials during uninstall. */ export async function serviceCommand(...args: (string | undefined)[]): Promise { const filteredArgs = args.filter((a): a is string => Boolean(a)); + const supervisionLatch = createSupervisionLatch(); const execute = async (): Promise => { // `claim` is not an install verb: it is deliberately outside planServiceCommand (whose // backend/installation checks do not apply to an ownership write) and outside @@ -231,6 +233,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise; +export type SupervisionInspector = (deps?: Parameters[0]) => DesktopSupervision; +export type SupervisionLatch = ReturnType; + +export function desktopServiceRefusal(evidence: DesktopSupervision, blocked: boolean): string | null { + if (!blocked) return null; + const runtime = evidence.kind === "desktop" ? ` (pid ${evidence.runtimePid}, ${evidence.app})` : ""; + return `OpenCodex Desktop supervises the running proxy${runtime}. ` + + "Quit OpenCodex, then rerun 'ocx service install' to move startup management to this CLI."; +} + +export function assertNoDesktopSupervision( + inspect: SupervisionInspector = inspectDesktopSupervision, + latch: SupervisionLatch = createSupervisionLatch(), +): void { + const evidence = inspect(); + const refusal = desktopServiceRefusal(evidence, latch.observe(evidence)); + if (refusal) throw new Error(refusal); +} + +export function desktopServiceCommandRefusal( + command: string, + inspect: SupervisionInspector = inspectDesktopSupervision, + latch: SupervisionLatch = createSupervisionLatch(), +): string | null { + if (!["install", "repair", "start", "restart"].includes(command)) return null; + const evidence = inspect(); + return desktopServiceRefusal(evidence, latch.observe(evidence)); +} diff --git a/src/service/orchestration.ts b/src/service/orchestration.ts index 2223b6d6fd0..907c90051ca 100644 --- a/src/service/orchestration.ts +++ b/src/service/orchestration.ts @@ -21,6 +21,7 @@ import { windowsTaskRegistrationOwnedByAttempt, windowsTaskRegistrationHealthy } import { win32 } from "node:path"; import { launchdGuiDomain } from "./launchd"; import { LABEL } from "./state"; +import { assertNoDesktopSupervision, createSupervisionLatch, type SupervisionInspector, type SupervisionLatch } from "./desktop-command-guard"; type ServiceOps = { install: () => void | Promise; start: () => void; stop: () => void; @@ -230,6 +231,8 @@ export async function stopTrackedProxyForServiceCommand(): Promise ServiceDiagnostic; managerOps?: (backend: ServiceBackend) => ServiceInstallCleanupOps | null; stopTrackedProxy?: () => Promise; @@ -245,6 +248,8 @@ export async function prepareServiceInstall( requestedBackend: ServiceBackend, deps: ServiceInstallPreparationDeps = {}, ): Promise { + const latch = deps.supervisionLatch ?? createSupervisionLatch(); + assertNoDesktopSupervision(deps.inspectSupervision, latch); const diagnostic = (deps.diagnose ?? diagnoseService)(); const platform = deps.platform ?? process.platform; const resolveOps = deps.managerOps ?? platformServiceInstallCleanupOps; @@ -279,11 +284,15 @@ export async function installServiceSafely( install: () => void | Promise, deps: ServiceInstallPreparationDeps = {}, ): Promise { - await prepareServiceInstall(requestedBackend, deps); + const latch = deps.supervisionLatch ?? createSupervisionLatch(); + await prepareServiceInstall(requestedBackend, { ...deps, supervisionLatch: latch }); + assertNoDesktopSupervision(deps.inspectSupervision, latch); await install(); } export interface FreshWindowsSchedulerInstallDeps { + inspectSupervision?: SupervisionInspector; + supervisionLatch?: SupervisionLatch; stageRegistrationXml?: (attemptNonce: string) => string; register?: (xmlPath: string, attemptNonce: string) => Promise; recordOwnership?: () => boolean; @@ -312,10 +321,14 @@ export interface FreshWindowsSchedulerInstallDeps { export async function installFreshWindowsSchedulerSafely( deps: FreshWindowsSchedulerInstallDeps = {}, ): Promise { + const latch = deps.supervisionLatch ?? createSupervisionLatch(); + assertNoDesktopSupervision(deps.inspectSupervision, latch); const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml; const register = deps.register ?? registerFreshWindowsSchedulerTask; const recordOwnership = deps.recordOwnership ?? recordWindowsSchedulerOwnership; - const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler")); + const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler", { + inspectSupervision: deps.inspectSupervision, supervisionLatch: latch, + })); const removeNativeService = deps.removeNativeService ?? removeNativeWindowsServiceForScheduler; const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets; const verifyBeforeRun = deps.verifyBeforeRun ?? ((nonce: string) => ( @@ -348,6 +361,7 @@ export async function installFreshWindowsSchedulerSafely( stagedXml = stage(attemptNonce); await register(stagedXml, attemptNonce); registered = true; + assertNoDesktopSupervision(deps.inspectSupervision, latch); // The destructive boundary begins only after Task Scheduler accepted the definition. // The registration has consumed its temporary XML. Remove it before claiming a newly @@ -362,9 +376,11 @@ export async function installFreshWindowsSchedulerSafely( ); } await prepare(); + assertNoDesktopSupervision(deps.inspectSupervision, latch); removeNativeService(); publishAssets(); await verifyBeforeRun(attemptNonce); + assertNoDesktopSupervision(deps.inspectSupervision, latch); runTask(); started = true; writeState(); diff --git a/src/service/repair.ts b/src/service/repair.ts index b3908c6e779..7dd8ed54db1 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -14,6 +14,7 @@ import type { WindowsSchedulerTaskProbe } from "./windows-scheduler"; import { taskXmlSection, taskXmlWithoutCommentsAndCdata, taskXmlElementCount, taskXmlOptionalValueEquals, windowsTaskRegistrationOwnedByAttempt, windowsTaskHasSessionRecoveryTriggers, windowsTaskRegistrationHealthy, windowsTaskRegistrationRefreshableLegacy } from "./windows-taskxml"; import type { ExpectedWindowsTaskUserId } from "./windows-taskxml"; import { win32 } from "node:path"; +import { assertNoDesktopSupervision, createSupervisionLatch, type SupervisionInspector, type SupervisionLatch } from "./desktop-command-guard"; /** * The two CLI verbs `repairService` serves. They differ on ONE platform and ONE case: a @@ -23,6 +24,8 @@ import { win32 } from "node:path"; export type ServiceRepairVerb = "repair" | "restart"; export interface RepairServiceDeps { + inspectSupervision?: SupervisionInspector; + supervisionLatch?: SupervisionLatch; diagnose?: () => ServiceDiagnostic; assertEnv?: () => void; assertAuth?: () => void; @@ -148,6 +151,8 @@ export async function assertSchedulerRegistrationBeforeStart( * macOS/Linux: re-run the user-level install/reload path. */ export async function repairService(deps: RepairServiceDeps = {}): Promise { + const latch = deps.supervisionLatch ?? createSupervisionLatch(); + assertNoDesktopSupervision(deps.inspectSupervision, latch); const diagnose = deps.diagnose ?? diagnoseService; const platform = deps.platform ?? process.platform; const diag = diagnose(); @@ -183,6 +188,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise if (platform === "win32") { if (diag.backend === "native") { await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(serviceSourceDir))))(); + assertNoDesktopSupervision(deps.inspectSupervision, latch); (deps.writeNativeState ?? (() => writeServiceInstallState("native")))(); return; } @@ -311,6 +317,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise } if (probe.status === "absent") { try { + assertNoDesktopSupervision(deps.inspectSupervision, latch); await (deps.restoreSchedulerIfAbsent ?? restoreWindowsSchedulerTaskIfAbsent)(registeredXml); restartExpectedXml = registeredXml; } catch (error) { @@ -333,6 +340,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise "The Task Scheduler registration changed again before restart; the newer definition was preserved and not started.", "Task Scheduler state remained unreadable before restart; the registration was preserved and not started.", ); + assertNoDesktopSupervision(deps.inspectSupervision, latch); (deps.startScheduler ?? startWindows)(); } catch (error) { recoveryErrors.push(error); @@ -358,6 +366,7 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise "Task Scheduler registration changed before restart; the current definition was preserved and not started.", "Task Scheduler registration became unreadable before restart; it was preserved and not started.", ); + assertNoDesktopSupervision(deps.inspectSupervision, latch); (deps.startScheduler ?? startWindows)(); (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))(); return; diff --git a/tests/service/service-ownership-handover.test.ts b/tests/service/service-ownership-handover.test.ts index c79a907711a..a16232cdba7 100644 --- a/tests/service/service-ownership-handover.test.ts +++ b/tests/service/service-ownership-handover.test.ts @@ -13,6 +13,9 @@ import { repoPath } from "../helpers/repo-root"; import { foreignServiceOwnerRefusal, repairService, unknownServiceOwnerRefusal } from "../../src/service/repair"; import type { ServiceDiagnostic } from "../../src/service/diagnostics"; import type { ServiceOwnershipResolution } from "../../src/service/state"; +import { assertNoDesktopSupervision, createSupervisionLatch, desktopServiceCommandRefusal, desktopServiceRefusal, type DesktopSupervision } from "../../src/service/desktop-command-guard"; +import { installFreshWindowsSchedulerSafely, installServiceSafely, prepareServiceInstall, type FreshWindowsSchedulerInstallDeps } from "../../src/service/orchestration"; +import { buildWindowsTaskXml } from "../../src/service/windows-taskxml"; const INSTALLED: ServiceDiagnostic = { supported: true, installed: true, enabled: true, running: true, viable: true, @@ -27,6 +30,7 @@ describe("repair under an owner that is not this CLI", () => { test("refuses before it asserts, writes, stops or starts anything", async () => { const touched: string[] = []; await expect(repairService({ + inspectSupervision: () => ({ kind: "none" }), platform: "darwin", diagnose: () => INSTALLED, readOwnership: () => DESKTOP, @@ -42,6 +46,7 @@ describe("repair under an owner that is not this CLI", () => { test("restart refuses on the same terms", async () => { await expect(repairService({ + inspectSupervision: () => ({ kind: "none" }), platform: "darwin", verb: "restart", diagnose: () => INSTALLED, @@ -58,6 +63,7 @@ describe("repair under an owner that is not this CLI", () => { test("an unreadable or contradictory record refuses too, rather than reading as CLI-owned", async () => { const touched: string[] = []; await expect(repairService({ + inspectSupervision: () => ({ kind: "none" }), platform: "darwin", diagnose: () => INSTALLED, readOwnership: () => UNKNOWN, @@ -90,6 +96,8 @@ describe("repair under an owner that is not this CLI", () => { for (const resolution of resolutions) { let repaired = false; await repairService({ + inspectSupervision: () => ({ kind: "none" }), + launcherPathDiagnostic: () => null, platform: "darwin", diagnose: () => INSTALLED, readOwnership: () => resolution, @@ -103,6 +111,7 @@ describe("repair under an owner that is not this CLI", () => { test("an uninstalled service still reports that first", async () => { await expect(repairService({ + inspectSupervision: () => ({ kind: "none" }), platform: "darwin", diagnose: () => ({ ...INSTALLED, installed: false }), readOwnership: () => DESKTOP, @@ -110,6 +119,290 @@ describe("repair under an owner that is not this CLI", () => { }); }); +const SUPERVISION: DesktopSupervision = { + kind: "desktop", runtimePid: 4242, supervisorPid: 4200, + app: "/Applications/OpenCodex.app/Contents/MacOS/opencodex-desktop", + proxy: "/Applications/OpenCodex.app/Contents/MacOS/ocx", +}; +const SEEN_UNKNOWN: DesktopSupervision = { kind: "unknown", reason: "probe-timeout", desktopSeen: true }; +const PASS_THROUGH: DesktopSupervision[] = [ + { kind: "none" }, { kind: "unsupported" }, + { kind: "unknown", reason: "probe-timeout", desktopSeen: false }, +]; +const REFUSAL = "OpenCodex Desktop supervises the running proxy (pid 4242, /Applications/OpenCodex.app/Contents/MacOS/opencodex-desktop). " + + "Quit OpenCodex, then rerun 'ocx service install' to move startup management to this CLI."; + +function freshWindowsDeps(touched: string[], inspect: () => DesktopSupervision): FreshWindowsSchedulerInstallDeps { + return { + inspectSupervision: inspect, + stageRegistrationXml: () => { touched.push("stage"); return "injected-stage.xml"; }, + register: async () => { touched.push("register"); }, + recordOwnership: () => { touched.push("ownership"); return true; }, + prepare: async () => { touched.push("prepare"); }, + removeNativeService: () => { touched.push("removeNative"); }, + publishAssets: () => { touched.push("assets"); }, + verifyBeforeRun: () => { touched.push("verify"); }, + runTask: () => { touched.push("run"); }, + writeState: () => { touched.push("state"); }, + rollbackTask: async () => { touched.push("rollback"); return null; }, + removeStagedXml: () => { touched.push("removeStage"); }, + }; +} + +describe("live Desktop service command guards", () => { + test("live Desktop supervision refuses every activating service verb before CLI mutation", () => { + for (const evidence of [SUPERVISION, SEEN_UNKNOWN]) { + for (const command of ["install", "repair", "start", "restart"]) { + let probes = 0; + const touched: string[] = []; + const refusal = desktopServiceCommandRefusal(command, () => { probes++; return evidence; }); + if (refusal === null) touched.push(command); + expect(refusal).toBe(desktopServiceRefusal(evidence, true)); + if (evidence.kind === "desktop") expect(refusal).toBe(REFUSAL); + expect(probes).toBe(1); + expect(touched).toEqual([]); + } + } + const cli = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); + const preflight = cli.indexOf("desktopServiceCommandRefusal(preliminary.sub"); + expect(preflight).toBeGreaterThan(0); + expect(preflight).toBeLessThan(cli.indexOf("await withWindowsServiceMutationLock(execute)")); + const dispatch = cli.indexOf("desktopServiceCommandRefusal(command"); + expect(dispatch).toBeGreaterThan(cli.indexOf("const { parsed, command } = plan;")); + expect(dispatch).toBeLessThan(cli.indexOf("assertServiceEnvironmentMatchesInstall();")); + for (const from of [preflight, dispatch]) { + const block = cli.slice(from, cli.indexOf("\n }", from) + 6); + expect(block).toContain("process.exitCode = 1;"); + expect(block).toContain("return;"); + } + expect(cli.match(/const supervisionLatch = createSupervisionLatch\(\)/g)).toHaveLength(1); + expect(cli).toContain("repairService({ verb, supervisionLatch })"); + expect(cli).toContain("installFreshWindowsSchedulerSafely({ supervisionLatch })"); + expect(cli).toContain("installServiceSafely(backend, ops.install, { supervisionLatch })"); + }); + + test("deactivating and identity service verbs do not probe Desktop supervision", () => { + for (const command of ["stop", "status", "uninstall", "remove", "claim"]) { + expect(desktopServiceCommandRefusal(command, () => { throw new Error("must not probe"); })).toBeNull(); + } + }); + + test("repair and restart refuse live supervision even without a durable claim", async () => { + for (const evidence of [SUPERVISION, SEEN_UNKNOWN]) { + for (const verb of ["repair", "restart"] as const) { + for (const [platform, backend] of [["darwin", "launchd"], ["linux", "systemd"], ["win32", "native"], ["win32", "scheduler"]] as const) { + const touched: string[] = []; + const mutate = () => { touched.push("mutation"); }; + await expect(repairService({ + inspectSupervision: () => evidence, verb, platform, + diagnose: () => { touched.push("diagnose"); return { ...INSTALLED, backend }; }, + readOwnership: () => { touched.push("ownership"); return { kind: "none", revision: 0 }; }, + assertEnv: mutate, assertAuth: mutate, repairLaunchd: mutate, restartLaunchd: mutate, + repairSystemd: mutate, repairNative: mutate, writeNativeState: mutate, + writeSchedulerAssets: mutate, writeSchedulerState: mutate, stopScheduler: mutate, startScheduler: mutate, + })).rejects.toThrow(desktopServiceRefusal(evidence, true)!); + expect(touched).toEqual([]); + } + } + } + }); + + test("safe install stops before cleanup and installation under live Desktop supervision", async () => { + for (const evidence of [SUPERVISION, SEEN_UNKNOWN]) { + const touched: string[] = []; + const deps = { + inspectSupervision: () => evidence, + diagnose: () => { touched.push("diagnose"); return INSTALLED; }, + managerOps: () => { touched.push("manager"); return { status: () => "running", stop: () => { touched.push("stop"); } }; }, + stopTrackedProxy: async () => { touched.push("tracked"); }, + }; + await expect(prepareServiceInstall("scheduler", deps)).rejects.toThrow(desktopServiceRefusal(evidence, true)!); + await expect(installServiceSafely("scheduler", () => { touched.push("install"); }, deps)).rejects.toThrow(desktopServiceRefusal(evidence, true)!); + expect(touched).toEqual([]); + } + }); + + test("fresh Windows install refuses before registration XML staging", async () => { + for (const evidence of [SUPERVISION, SEEN_UNKNOWN]) { + const touched: string[] = []; + await expect(installFreshWindowsSchedulerSafely(freshWindowsDeps(touched, () => evidence))) + .rejects.toThrow(desktopServiceRefusal(evidence, true)!); + expect(touched).toEqual([]); + } + }); + + test("unknown unsupported and absent supervision retain service preparation and repair", async () => { + for (const evidence of PASS_THROUGH) { + for (const command of ["install", "repair", "start", "restart"]) { + expect(desktopServiceCommandRefusal(command, () => evidence)).toBeNull(); + } + const touched: string[] = []; + await installServiceSafely("scheduler", () => { touched.push("install"); }, { + inspectSupervision: () => evidence, platform: "darwin", diagnose: () => INSTALLED, + managerOps: () => ({ + status: () => { touched.push("status"); return "running"; }, + stop: () => { touched.push("stop"); }, + }), + stopTrackedProxy: async () => { touched.push("tracked"); }, + }); + expect(touched).toEqual(["status", "stop", "tracked", "install"]); + for (const ownership of [ + { kind: "none", revision: 0 }, + { kind: "owned", ownership: { owner: "cli", installId: "npm-install", consentGeneration: 4 }, revision: 8 }, + ] as const) { + for (const verb of ["repair", "restart"] as const) { + const repairs: string[] = []; + await repairService({ + inspectSupervision: () => evidence, platform: "darwin", verb, + diagnose: () => INSTALLED, readOwnership: () => ownership, + assertEnv: () => {}, assertAuth: () => {}, launcherPathDiagnostic: () => null, + repairLaunchd: () => { repairs.push("repair"); return { reloaded: false }; }, + restartLaunchd: () => { repairs.push("restart"); }, + }); + expect(repairs).toEqual(verb === "restart" ? ["repair", "restart"] : ["repair"]); + } + } + const windows: string[] = []; + await installFreshWindowsSchedulerSafely(freshWindowsDeps(windows, () => evidence)); + expect(windows).toEqual(["stage", "register", "removeStage", "ownership", "prepare", "removeNative", "assets", "verify", "run", "state"]); + } + }); + + test("a fresh Windows install rechecks supervision after registration before cleanup", async () => { + for (const blocked of [SUPERVISION, SEEN_UNKNOWN]) { + const touched: string[] = []; + let reads = 0; + const deps = freshWindowsDeps(touched, () => reads++ === 0 ? { kind: "none" } : blocked); + delete deps.prepare; // Exercise the production preparation closure without reaching real cleanup. + await expect(installFreshWindowsSchedulerSafely(deps)).rejects.toThrow(desktopServiceRefusal(blocked, true)!); + expect(reads).toBe(2); + expect(touched).toEqual(["stage", "register", "rollback", "removeStage"]); + } + }); + + test("service operations retain a prior Desktop veto until a positive none observation", async () => { + for (const inconclusive of [PASS_THROUGH[1]!, PASS_THROUGH[2]!]) { + const latch = createSupervisionLatch(); + expect(latch.observe(SUPERVISION)).toBe(true); + const touched: string[] = []; + await expect(repairService({ + supervisionLatch: latch, inspectSupervision: () => inconclusive, + diagnose: () => { touched.push("diagnose"); return INSTALLED; }, + })).rejects.toThrow("OpenCodex Desktop supervises"); + await expect(installServiceSafely("scheduler", () => { touched.push("install"); }, { + supervisionLatch: latch, inspectSupervision: () => inconclusive, + diagnose: () => { touched.push("diagnose"); return INSTALLED; }, + })).rejects.toThrow("OpenCodex Desktop supervises"); + await expect(installFreshWindowsSchedulerSafely({ + ...freshWindowsDeps(touched, () => inconclusive), supervisionLatch: latch, + })).rejects.toThrow("OpenCodex Desktop supervises"); + expect(desktopServiceCommandRefusal("start", () => inconclusive, latch)).not.toBeNull(); + expect(touched).toEqual([]); + expect(() => assertNoDesktopSupervision(() => ({ kind: "none" }), latch)).not.toThrow(); + expect(desktopServiceCommandRefusal("restart", () => inconclusive, latch)).toBeNull(); + await repairService({ + supervisionLatch: latch, inspectSupervision: () => inconclusive, platform: "darwin", + diagnose: () => INSTALLED, readOwnership: () => ({ kind: "none", revision: 0 }), + assertEnv: () => {}, assertAuth: () => {}, launcherPathDiagnostic: () => null, + repairLaunchd: () => { touched.push("repair"); }, + }); + expect(touched).toEqual(["repair"]); + } + }); + + test("safe install checks fresh supervision after asynchronous cleanup", async () => { + for (const blocked of [SUPERVISION, SEEN_UNKNOWN]) { + let reads = 0; + const touched: string[] = []; + await expect(installServiceSafely("scheduler", () => { touched.push("install"); }, { + inspectSupervision: () => reads++ === 0 ? { kind: "none" } : blocked, + platform: "darwin", diagnose: () => INSTALLED, + managerOps: () => ({ status: () => null, stop: () => { touched.push("stop"); } }), + stopTrackedProxy: async () => { touched.push("tracked"); }, + })).rejects.toThrow(desktopServiceRefusal(blocked, true)!); + expect(reads).toBe(2); + expect(touched).toEqual(["tracked"]); + } + }); + + test("fresh Windows install rechecks after preparation and registration verification", async () => { + for (const boundary of ["prepare", "verify"] as const) { + for (const blocked of [SUPERVISION, SEEN_UNKNOWN]) { + const touched: string[] = []; + let current: DesktopSupervision = { kind: "none" }; + const deps = freshWindowsDeps(touched, () => current); + if (boundary === "prepare") deps.prepare = async () => { touched.push("prepare"); current = blocked; }; + else deps.verifyBeforeRun = () => { touched.push("verify"); current = blocked; }; + await expect(installFreshWindowsSchedulerSafely(deps)).rejects.toThrow(desktopServiceRefusal(blocked, true)!); + expect(touched).toEqual(boundary === "prepare" + ? ["stage", "register", "removeStage", "ownership", "prepare", "rollback"] + : ["stage", "register", "removeStage", "ownership", "prepare", "removeNative", "assets", "verify", "rollback"]); + } + } + }); + + test("native Windows repair checks fresh supervision before publishing install state", async () => { + for (const blocked of [SUPERVISION, SEEN_UNKNOWN]) { + const touched: string[] = []; + let current: DesktopSupervision = { kind: "none" }; + await expect(repairService({ + inspectSupervision: () => current, platform: "win32", + diagnose: () => ({ ...INSTALLED, backend: "native" }), + readOwnership: () => ({ kind: "none", revision: 0 }), + assertEnv: () => {}, assertAuth: () => {}, launcherPathDiagnostic: () => null, + repairNative: async () => { touched.push("native"); current = blocked; }, + writeNativeState: () => { touched.push("state"); }, + })).rejects.toThrow(desktopServiceRefusal(blocked, true)!); + expect(touched).toEqual(["native"]); + } + }); + + test("scheduler repair checks supervision before normal and recovery starts or restoration", async () => { + const sid = "S-1-5-21-111-222-333-1001"; + const launcher = "C:\\fixture\\launcher.vbs"; + const healthy = buildWindowsTaskXml("injected.cmd", launcher, undefined, sid); + const stale = healthy.replace(/[\s\S]*?<\/SessionStateChangeTrigger>\s*/gi, ""); + for (const boundary of ["start", "recovery-start", "recovery-restore"] as const) { + for (const blocked of [SUPERVISION, SEEN_UNKNOWN]) { + const touched: string[] = []; + let current: DesktopSupervision = { kind: "none" }; + let failedRefresh = false; + let reads = 0; + let error: unknown; + try { + await repairService({ + inspectSupervision: () => current, platform: "win32", + diagnose: () => ({ ...INSTALLED, backend: "scheduler" }), + readOwnership: () => ({ kind: "none", revision: 0 }), + assertEnv: () => {}, assertAuth: () => {}, launcherPathDiagnostic: () => null, + resolveExpectedUserId: () => sid, schedulerLauncher: launcher, + stopScheduler: () => { touched.push("stop"); }, + writeSchedulerAssets: () => { touched.push("assets"); }, + readSchedulerXml: () => { + reads++; + if (boundary === "start") { if (reads > 1) current = blocked; return healthy; } + return failedRefresh && boundary === "recovery-restore" ? "" : stale; + }, + reregisterScheduler: async () => { + touched.push("refresh"); failedRefresh = true; current = blocked; + throw new Error("injected refresh failure"); + }, + probeScheduler: () => ({ status: "absent" }), + restoreSchedulerIfAbsent: async () => { touched.push("restore"); }, + settleSchedulerRead: () => { throw new Error("must not wait"); }, + startScheduler: () => { touched.push("start"); }, + writeSchedulerState: () => { touched.push("state"); }, + }); + } catch (caught) { error = caught; } + expect(error).toBeInstanceOf(Error); + const refusals = error instanceof AggregateError ? error.errors : [error]; + expect(refusals.some(cause => cause instanceof Error && cause.message === desktopServiceRefusal(blocked, true))).toBe(true); + expect(touched).toEqual(boundary === "start" ? ["stop", "assets"] : ["stop", "assets", "refresh"]); + } + } + }); +}); + describe("which service verbs are gated", () => { const cli = readFileSync(repoPath("src", "service", "cli.ts"), "utf8"); const between = (from: string, to: string): string => cli.slice(cli.indexOf(from), cli.indexOf(to)); From 39c742c3ad12ba6715acc8ad74a3afc9218b7288 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 11:58:34 +0900 Subject: [PATCH 2/5] fix(update): leave a Desktop-supervised runtime and its package alone --- bin/ocx.mjs | 52 +++++- src/update/index.ts | 66 +++++++- src/update/restart-ownership.ts | 13 +- src/update/runtime-ownership.d.mts | 6 + src/update/runtime-ownership.mjs | 30 +++- tests/cli/cli-update-restart.test.ts | 12 ++ tests/cli/ocx-launcher-runtime.test.ts | 55 +++++++ tests/cli/ocx-launcher-source.test.ts | 20 +++ tests/update/update-desktop-owner.test.ts | 192 ++++++++++++++++++++++ tests/update/update-restart-lease.test.ts | 47 ++++++ 10 files changed, 466 insertions(+), 27 deletions(-) diff --git a/bin/ocx.mjs b/bin/ocx.mjs index ded5539a34a..7aed87005e3 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -9,6 +9,7 @@ * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) */ import { spawn, spawnSync } from "node:child_process"; +import { createSupervisionLatch, inspectDesktopSupervision } from "../src/service/desktop-supervision.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"; @@ -333,11 +334,13 @@ function runPackageManagerSelfUpdate(manager) { : JSON.stringify(observation.ownership ? ["owned", observation.ownership.owner, observation.ownership.installId, observation.ownership.consentGeneration] : ["none"]); + const supervisionLatch = createSupervisionLatch(); + const observeSupervision = () => supervisionLatch.observe(inspectDesktopSupervision()); const initialOwnership = readOwnership(); - let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); + let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (runtimePlan.notice) console.log(runtimePlan.notice); if (!runtimePlan.mayReplacePackage) { - console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime authority does not permit it."); process.exit(1); } const trayBeforeUpdate = planWindowsTrayUpdate( @@ -466,6 +469,14 @@ function runPackageManagerSelfUpdate(manager) { : unprivilegedOwnershipMutationEnvironment(process.env); function startProxyDirectly() { + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: false, supervision }); + if (!plan.mayStopRuntime) { + console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored." + : plan.notice); + return false; + } if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); return false; @@ -498,6 +509,15 @@ function runPackageManagerSelfUpdate(manager) { } function refreshBackgroundServiceOrStartDirect() { + const mayRefresh = () => { + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); + if (!plan.mayRestoreService) console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." + : plan.notice); + return plan.mayRestoreService; + }; + if (!mayRefresh()) return; const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { @@ -513,6 +533,7 @@ 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; console.log("No registered service found — installing it instead."); svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), @@ -545,9 +566,12 @@ function runPackageManagerSelfUpdate(manager) { // Re-read rather than reuse the plan from before the package install: the app can // claim the runtime during an update that takes minutes, and the refusal that repair // just returned is indistinguishable from any other failure at this layer. - const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true }); + const supervision = observeSupervision(); + const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); if (!nowOwned.mayStopRuntime) { - console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); + console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." + : nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); return; } // Repair normally avoids elevation for a healthy registration, but a stale Windows @@ -584,7 +608,7 @@ function runPackageManagerSelfUpdate(manager) { // 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 }); + const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { releaseUpdateLease(); console.error(lockedPlan.notice @@ -626,6 +650,7 @@ function runPackageManagerSelfUpdate(manager) { liveness, plan: planStoppedRuntimeRecovery({ stopAttempted, + supervision: observeSupervision(), ...recoveryOwnership, sameOwner: ownershipIdentity(recoveryOwnership) === stoppedOwnershipIdentity, liveness, @@ -643,7 +668,9 @@ function runPackageManagerSelfUpdate(manager) { releaseUpdateLease(); ({ liveness: recoveryLiveness, plan: recovery } = planRecovery()); } - if (recovery.reason === "ownership-unknown") { + if (recovery.reason === "desktop-supervised") { + console.log("OpenCodex Desktop supervises the proxy; no CLI runtime was restored."); + } else if (recovery.reason === "ownership-unknown") { console.error(`opencodex: ${reason}; runtime ownership is unknown, so automatic recovery was refused. Run 'ocx status --json' and repair the service-state record before retrying.`); } else if (recovery.reason === "ownership-transferred") { console.log("opencodex: runtime ownership moved to another installation; the stopped CLI runtime was not revived."); @@ -676,6 +703,14 @@ function runPackageManagerSelfUpdate(manager) { process.exit(1); } if (stopNeeded) { + const preStopPlan = planUpdateRuntimeHandling({ + ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision(), + }); + if (!preStopPlan.mayStopRuntime) { + console.error(preStopPlan.notice); + releaseUpdateLease(); + process.exit(1); + } stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { @@ -732,7 +767,7 @@ function runPackageManagerSelfUpdate(manager) { } const replacementOwnership = readOwnership(); - const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); const replacementLiveness = currentPackageRuntimeLiveness(); if (replacementOwnership.subjectToken !== initialOwnership.subjectToken || !replacementPlan.mayReplacePackage @@ -839,9 +874,10 @@ function runPackageManagerSelfUpdate(manager) { // path and keeps token restoration coupled to the lease itself. releaseUpdateLease(); } - const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (res.status === 0) { 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 (trayBeforeUpdate.refreshAfterReplacement) { const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], { diff --git a/src/update/index.ts b/src/update/index.ts index 900b6f4b394..0d5cfe7d542 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -1,4 +1,5 @@ import { spawn, spawnSync } from "node:child_process"; +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"; @@ -484,14 +485,17 @@ export async function runUpdate(): Promise { } 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. + const supervisionLatch = createSupervisionLatch(); + const observeSupervision = () => supervisionLatch.observe(inspectDesktopSupervision()); const initialOwnership = await resolvedRuntimeOwnership(); const runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled, + supervision: observeSupervision(), }); if (runtimePlan.notice) console.log(runtimePlan.notice); if (!runtimePlan.mayReplacePackage) { - console.error("⚠️ Update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + console.error("⚠️ Update stopped before tray handoff, runtime stop, or package replacement because runtime authority does not permit it."); return 1; } let trayWasInstalled = false; @@ -548,6 +552,15 @@ export async function runUpdate(): Promise { && before?.consentGeneration === after?.consentGeneration; }; const startProxyDirectly = async (): Promise => { + const ownership = await resolvedRuntimeOwnership(); + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...ownership, serviceInstalled: false, supervision }); + if (!plan.mayStopRuntime) { + console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored." + : plan.notice); + return false; + } 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)); @@ -574,7 +587,7 @@ export async function runUpdate(): Promise { const planRecovery = async () => { const current = await resolvedRuntimeOwnership(); return planStoppedRuntimeRecovery({ - stopAttempted, ...current, sameOwner: sameOwner(current), + stopAttempted, ...current, sameOwner: sameOwner(current), supervision: observeSupervision(), liveness: currentPackageRuntimeLiveness(), serviceInstalled: serviceWasInstalled, launcherUsable: postUpdateLauncherUsable && existsSync(postUpdateLauncher), hadRuntimeState, }); @@ -588,10 +601,17 @@ export async function runUpdate(): Promise { mutation.release(); recovery = await planRecovery(); } - if (recovery.action === "manual") { + if (recovery.reason === "desktop-supervised") { + console.log("OpenCodex Desktop supervises the proxy; no CLI runtime was restored."); + } else if (recovery.action === "manual") { console.warn(`⚠️ ${reason}; runtime recovery requires manual review (${recovery.reason}).`); } else if (recovery.action === "service") { const { serviceReinstallArgs, isServiceViable } = await import("../service"); + 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()], { stdio: updateChildStdio(), windowsHide: true, env: mutation.controlEnvironment({ ...process.env, OCX_BAKE_PORT: String(capturedListen.port) }), @@ -602,7 +622,7 @@ export async function runUpdate(): Promise { mutation.reacquire(); const nowOwned = await resolvedRuntimeOwnership(); const fallback = planStoppedRuntimeRecovery({ - stopAttempted, ...nowOwned, sameOwner: sameOwner(nowOwned), + stopAttempted, ...nowOwned, sameOwner: sameOwner(nowOwned), supervision: observeSupervision(), liveness: currentPackageRuntimeLiveness(), serviceInstalled: false, launcherUsable: postUpdateLauncherUsable && existsSync(postUpdateLauncher), hadRuntimeState, }); @@ -632,6 +652,15 @@ export async function runUpdate(): Promise { // Full `ocx stop` semantics (drain, service stop, restore). if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { + const preStopPlan = planUpdateRuntimeHandling({ + ...(await resolvedRuntimeOwnership()), + serviceInstalled: serviceWasInstalled, + supervision: supervisionLatch.observe(inspectDesktopSupervision({ targetPid: runtimeTrusted ? livePid : undefined })), + }); + if (!preStopPlan.mayStopRuntime) { + console.error(preStopPlan.notice); + return 1; + } stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); @@ -701,13 +730,14 @@ export async function runUpdate(): Promise { stderr?: string | Buffer | null; } | null = null; let replacementRefusal: string | null = null; - // Ownership can change while registry work is in flight. Unknown at this exact - // boundary blocks replacement; a confirmed desktop claim still permits updating the idle - // npm installation while leaving the bundled sidecar alone. + // Re-read durable ownership and live supervision before package replacement. + // Desktop supervision and foreign/unknown ownership veto replacement. + // Plain unknown supervision preserves the existing ownership/liveness decision. const replacementOwnership = await resolvedRuntimeOwnership(); const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled, + supervision: observeSupervision(), }); const replacementLiveness = currentPackageRuntimeLiveness(); if (replacementOwnership.subjectToken !== initialOwnership.subjectToken @@ -786,9 +816,11 @@ export async function runUpdate(): Promise { const postInstallPlan = planUpdateRuntimeHandling({ ...(await resolvedRuntimeOwnership()), serviceInstalled: serviceWasInstalled, + supervision: observeSupervision(), }); if (r.status === 0) { 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. @@ -819,6 +851,16 @@ export async function runUpdate(): Promise { // (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 (postInstallPlan.mayRestoreService) { + const mayRefresh = async () => { + const ownership = await resolvedRuntimeOwnership(); + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...ownership, serviceInstalled: true, supervision }); + if (!plan.mayRestoreService) console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." + : plan.notice); + return plan.mayRestoreService; + }; + if (!await mayRefresh()) return; console.log("🔁 Refreshing the background service with the updated files..."); const { serviceReinstallArgs } = await import("../service"); const { reclaimListenPort } = await import("../server/port-reclaim"); @@ -840,6 +882,7 @@ export async function runUpdate(): Promise { const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(capturedListen.port); try { + if (!await mayRefresh()) return; const svcStdio = updateChildStdio(); const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], { env: mutation.controlEnvironment(), @@ -889,12 +932,17 @@ export async function runUpdate(): Promise { console.warn(` Run 'ocx service repair', then 'ocx start --port ${capturedListen.port}'.`); return 1; } + const fallbackOwnership = await resolvedRuntimeOwnership(); + const supervision = observeSupervision(); const nowOwned = planUpdateRuntimeHandling({ - ...(await resolvedRuntimeOwnership()), + ...fallbackOwnership, serviceInstalled: true, + supervision, }); if (!nowOwned.mayStopRuntime) { - console.warn(nowOwned.notice ?? "⚠️ The background runtime is owned elsewhere; not starting a second proxy."); + 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; } console.warn( diff --git a/src/update/restart-ownership.ts b/src/update/restart-ownership.ts index 4b1657baf43..bd06802a973 100644 --- a/src/update/restart-ownership.ts +++ b/src/update/restart-ownership.ts @@ -6,6 +6,8 @@ import { 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"; +type SupervisionInspector = typeof inspectDesktopSupervision; export type { ServiceOwnershipResolution }; @@ -24,6 +26,8 @@ export type { ServiceOwnershipResolution }; */ export function updateRestartVeto( resolve: () => ServiceOwnershipResolution = resolveServiceOwnership, + inspect: SupervisionInspector = inspectDesktopSupervision, + latch = createSupervisionLatch(), ): string | null { const owner = resolve(); const plan = planUpdateRuntimeHandling({ @@ -31,6 +35,7 @@ export function updateRestartVeto( ownershipUnknown: owner.kind === "unknown", // The restart decision does not refresh the service; only the stop veto is read here. serviceInstalled: false, + supervision: latch.observe(inspect()), }); if (plan.mayStopRuntime) return null; return plan.notice ?? "The background runtime is owned elsewhere; it was left running."; @@ -78,7 +83,9 @@ const REACQUIRE_WAIT_MS = 10_000; export async function runUpdateRestartWithOwnershipLease( resolve: (() => ServiceOwnershipResolution) | undefined, restart: (lease: UpdateRestartLeaseControl) => Promise, + inspect: SupervisionInspector = inspectDesktopSupervision, ): Promise<{ readonly kind: "veto"; readonly notice: string } | { readonly kind: "ran"; readonly value: T }> { + const latch = createSupervisionLatch(); let lease = acquireOwnershipMutationLease(serviceStatePaths()); const previous = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = lease.token; @@ -107,11 +114,11 @@ export async function runUpdateRestartWithOwnershipLease( heldNow = true; process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = lease.token; } - const veto = updateRestartVeto(resolve); + const veto = updateRestartVeto(resolve, inspect, latch); return veto ? { notice: veto, failed: false } : null; }; try { - const veto = updateRestartVeto(resolve); + const veto = updateRestartVeto(resolve, inspect, latch); return veto ? { kind: "veto", notice: veto } : { @@ -119,7 +126,7 @@ export async function runUpdateRestartWithOwnershipLease( value: await restart({ releaseForServiceManager: release, reacquireForDirectStart, - vetoAgain: () => updateRestartVeto(resolve), + vetoAgain: () => updateRestartVeto(resolve, inspect, latch), }), }; } finally { diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts index 754c3499a4d..3de3154f8e4 100644 --- a/src/update/runtime-ownership.d.mts +++ b/src/update/runtime-ownership.d.mts @@ -1,6 +1,10 @@ +import type { SupervisionEvidence } from "../service/desktop-supervision.mjs"; + export declare function planUpdateRuntimeHandling(input: { ownership: { owner: string; installId: string; consentGeneration: number } | null; ownershipUnknown?: boolean; + /** Fresh evidence or the operation's supervision latch verdict. */ + supervision?: SupervisionEvidence | boolean; serviceInstalled: boolean; }): { mayReplacePackage: boolean; @@ -13,6 +17,8 @@ export declare function planStoppedRuntimeRecovery(input: { stopAttempted: boolean; ownership: { owner: string; installId: string; consentGeneration: number } | null; ownershipUnknown?: boolean; + /** Fresh evidence or the operation's supervision latch verdict. */ + supervision?: SupervisionEvidence | boolean; sameOwner: boolean; liveness: "live" | "dead" | "unknown"; serviceInstalled: boolean; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs index c9eb52e5df8..a5591fdb8d7 100644 --- a/src/update/runtime-ownership.mjs +++ b/src/update/runtime-ownership.mjs @@ -7,6 +7,11 @@ * situation separately is how a fix ships on one side only. */ +function desktopSupervisionBlocked(supervision) { + return supervision === true || supervision?.kind === "desktop" + || (supervision?.kind === "unknown" && supervision.desktopSeen); +} + /** * Decide how an update treats a runtime it may not own. * @@ -21,11 +26,11 @@ * The service registration itself is untouched in every case. It is kept by decision, not by * accident, so a user who later runs `ocx service install` gets their npm service back. * - * THE COST OF A STALE MARKER. This reads the recorded claim, not liveness. An app deleted - * without releasing ownership leaves a marker behind, and an update then declines to stop or - * refresh a runtime no app is managing any more. That is the orphan-recovery cost the - * two-record ownership design accepted; `ocx service install` clears the marker and restores - * the ordinary path. + * Durable ownership and live supervision are separate. Desktop supervision denies package + * replacement, stop and restoration even without a claim. A latch retains that veto through + * inconclusive probes until a positive none. Plain unknown supervision alone keeps existing + * behavior; unknown durable ownership still vetoes. Explicit service install recovers a stale + * durable claim after Desktop has quit. * * The three returned flags are separate authorities, not commands. In particular, leaving * a runtime running is not permission to replace the package it may be executing from. @@ -34,10 +39,19 @@ * carries installation identity, it therefore blocks package replacement as well as stop and * restoration; the notice tells a stale-marker user how to take ownership back explicitly. * - * @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean }} input + * @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean, supervision?: boolean | ReturnType }} input * @returns {{ mayReplacePackage: boolean, mayStopRuntime: boolean, mayRestoreService: boolean, notice: string | null }} */ -export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled }) { +export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled, supervision }) { + if (desktopSupervisionBlocked(supervision)) { + return { + mayReplacePackage: false, + mayStopRuntime: false, + mayRestoreService: false, + notice: "OpenCodex Desktop runs this proxy from its own bundle; use the app's updater (tray → Check for Updates). " + + "This npm/Bun install was left unchanged; quit OpenCodex first to update it.", + }; + } // Unreadable, malformed or contradictory is not "nobody owns it". Reading it that way is // how a permissions error reactivates the npm launcher over a consented takeover. if (ownershipUnknown) { @@ -77,6 +91,7 @@ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, /** Decide recovery after this updater already stopped the prior CLI-owned runtime. */ export function planStoppedRuntimeRecovery({ stopAttempted, + supervision, ownership, ownershipUnknown = false, sameOwner, @@ -86,6 +101,7 @@ export function planStoppedRuntimeRecovery({ hadRuntimeState, }) { if (!stopAttempted) return { action: "none", reason: "not-stopped" }; + if (desktopSupervisionBlocked(supervision)) return { action: "none", reason: "desktop-supervised" }; if (ownershipUnknown) return { action: "manual", reason: "ownership-unknown" }; if (!sameOwner || (ownership && ownership.owner !== "cli")) { return { action: "none", reason: "ownership-transferred" }; diff --git a/tests/cli/cli-update-restart.test.ts b/tests/cli/cli-update-restart.test.ts index 257466c0532..2730aebb16a 100644 --- a/tests/cli/cli-update-restart.test.ts +++ b/tests/cli/cli-update-restart.test.ts @@ -2,6 +2,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { describeUpdateRestartFailure, runUpdateRestart, UpdateRestartEligibilityError, type UpdateRestartIo } from "../../src/cli/update-restart"; import type { UpdateRestartCandidate } from "../../src/cli/update-restart-candidate"; import { reportRestartFailure } from "../../src/cli/restart-failure"; +import { updateRestartVeto } from "../../src/update/restart-ownership"; import type { LiveProxy } from "../../src/server/proxy-liveness"; const candidate: UpdateRestartCandidate = { @@ -226,3 +227,14 @@ test("an unverified update home names status and the owning service recovery", ( expect(text).toContain("ocx status"); expect(text).toContain("ocx service restart"); expect(text).toContain("No changes were made"); } finally { errors.mockRestore(); } }); + + +test("dashboard update restart veto gives Desktop update guidance with injected supervision", () => { + const resolve = () => ({ kind: "none" as const, revision: 0 }); + expect(updateRestartVeto(resolve, () => ({ + kind: "desktop", runtimePid: 321, supervisorPid: 123, app: "/fixture/opencodex-desktop", proxy: "/fixture/ocx", + }))).toContain("use the app's updater (tray → Check for Updates)"); + expect(updateRestartVeto(resolve, () => ({ kind: "unknown", reason: "probe-failed", desktopSeen: true }))) + .toContain("quit OpenCodex first"); + expect(updateRestartVeto(resolve, () => ({ kind: "unknown", reason: "probe-failed", desktopSeen: false }))).toBeNull(); +}); diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index 858744fef91..a4b72ace331 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -4,6 +4,7 @@ import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, re import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { bundledBunPath } from "../../src/lib/bun-runtime"; import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-path-runtime.mjs"; import { REAL_BUN_MIN_BYTES } from "../../src/lib/bun-binary-validator.mjs"; @@ -700,3 +701,57 @@ describe.skipIf(!nodeAvailable)("PATH fallback negative activation", () => { } finally { removeTree(root); } }); }); + +describe.skipIf(!nodeAvailable)("Node-safe Desktop supervision update guard", () => { + test("the real Node launcher loads update --help without package-manager probes", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-node-update-help-")); + try { + const result = spawnSync("node", [BIN_OCX, "update", "--help"], { + encoding: "utf8", timeout: 10_000, windowsHide: true, + env: isolatedLauncherEnv(root, ""), + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("update"); + expect(result.stderr).toBe(""); + } finally { removeTree(root); } + }); + + test("node -e imports the latch and inspector with injected darwin/Linux identities", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-node-supervision-")); + try { + const macos = join(root, "OpenCodex.app", "Contents", "MacOS"); + mkdirSync(macos, { recursive: true }); + const app = join(macos, "opencodex-desktop"), proxy = join(macos, "ocx"); + for (const file of [app, proxy]) { writeFileSync(file, "fixture"); chmodSync(file, 0o755); } + const moduleUrl = pathToFileURL(repoPath("src/service/desktop-supervision.mjs")).href; + const script = ` + const { inspectDesktopSupervision, createSupervisionLatch } = await import(${JSON.stringify(moduleUrl)}); + const app = ${JSON.stringify(app)}, proxy = ${JSON.stringify(proxy)}; + const states = ["linux", "darwin"].map(platform => inspectDesktopSupervision({ + platform, readPid: () => 321, readRuntimePortPid: () => 321, + proc: { exe: pid => pid === 321 ? proxy : app, parent: pid => pid === 321 ? 123 : 1 }, + run: (_command, args) => args[1] === "321" ? "123 " + proxy : "1 " + app, + })); + const latch = createSupervisionLatch(); + const sequence = [states[0], { kind: "unknown", reason: "probe-failed", desktopSeen: false }, + { kind: "unsupported" }, { kind: "none" }].map(evidence => latch.observe(evidence)); + const uncertain = createSupervisionLatch(); + const unknownSequence = [{ kind: "unknown", reason: "probe-failed", desktopSeen: true }, + { kind: "unknown", reason: "probe-failed", desktopSeen: false }, { kind: "none" }] + .map(evidence => uncertain.observe(evidence)); + const mismatch = inspectDesktopSupervision({ platform: "linux", targetPid: 999, + readPid: () => 321, readRuntimePortPid: () => 321 }); + const none = inspectDesktopSupervision({ platform: "linux", readPid: () => null, readRuntimePortPid: () => null }); + process.stdout.write(JSON.stringify({ kinds: states.map(state => state.kind), sequence, unknownSequence, mismatch, none })); + `; + const result = spawnSync("node", ["--input-type=module", "-e", script], { + encoding: "utf8", timeout: 10_000, windowsHide: true, env: isolatedLauncherEnv(root, ""), + }); + expect(result.status).toBe(0); expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + kinds: ["desktop", "desktop"], sequence: [true, true, true, false], unknownSequence: [true, true, false], + mismatch: { kind: "unknown", reason: "pid-mismatch", desktopSeen: false }, none: { kind: "none" }, + }); + } finally { removeTree(root); } + }); +}); diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index 2de34bf1a4f..2938f1c165e 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -197,3 +197,23 @@ describe("ocx.mjs package launcher (source invariants)", () => { expect(validatorSource).toMatch(/export function isRealBunBinary\(path\) \{[\s\S]*?try \{[\s\S]*?statSync\(path\)[\s\S]*?catch \{[\s\S]*?return false;/); }); }); + + +test("Desktop supervision is freshly checked before Node stop and both package-manager mutations", () => { + expect(source).toContain('from "../src/service/desktop-supervision.mjs"'); + 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 npm = source.indexOf("const tx = transactionalNpmUpdate({"); + const pnpm = source.indexOf("const update = runPnpmGlobalUpdate({"); + expect(initial).toBeGreaterThan(0); expect(gate).toBeGreaterThan(initial); + expect(stop).toBeGreaterThan(gate); expect(npm).toBeGreaterThan(stop); expect(pnpm).toBeGreaterThan(stop); + const gateBody = source.slice(gate, stop); + expect(gateBody).toContain("supervision: observeSupervision()"); + expect(gateBody).toContain("if (!preStopPlan.mayStopRuntime)"); + expect(gateBody).toContain("process.exit(1)"); + // Node has no identity-checked PID: the inspector must correlate its own records. + expect(source).toContain("supervisionLatch.observe(inspectDesktopSupervision())"); + expect(source).not.toContain("inspectDesktopSupervision({ targetPid"); +}); diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index 004266a7df4..9e6f5c06bb9 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -16,6 +16,7 @@ import { planStoppedRuntimeRecovery, planUpdateRuntimeHandling, } from "../../src/update/runtime-ownership.mjs"; +import { createSupervisionLatch, type SupervisionEvidence } from "../../src/service/desktop-supervision.mjs"; import { parseInstallStateRecord, selectAuthoritativeServiceState } from "../../src/service/install-state-contract.mjs"; describe("the runtime-ownership veto", () => { @@ -271,3 +272,194 @@ describe("every updater re-reads ownership before it starts a proxy directly", ( expect(veto).toContain("acquireOwnershipMutationLease"); }); }); + + +const desktopEvidence: SupervisionEvidence = { + kind: "desktop", runtimePid: 321, supervisorPid: 123, app: "/fixture/opencodex-desktop", proxy: "/fixture/ocx", +}; +const desktopNotice = "OpenCodex Desktop runs this proxy from its own bundle; use the app's updater (tray → Check for Updates). " + + "This npm/Bun install was left unchanged; quit OpenCodex first to update it."; + +describe("live Desktop update authority", () => { + test("supervision takes precedence over missing, CLI and unreadable durable ownership", () => { + for (const ownership of [null, { owner: "cli", installId: "fixture", consentGeneration: 1 }]) { + for (const supervision of [desktopEvidence, { kind: "unknown", reason: "probe-failed", desktopSeen: true } as const, true]) { + expect(planUpdateRuntimeHandling({ ownership, ownershipUnknown: true, serviceInstalled: true, supervision })) + .toEqual({ mayReplacePackage: false, mayStopRuntime: false, mayRestoreService: false, notice: desktopNotice }); + } + } + }); + + test("none, unsupported and unknown without Desktop evidence preserve the ordinary policy", () => { + for (const supervision of [{ kind: "none" }, { kind: "unsupported" }, + { kind: "unknown", reason: "probe-failed", desktopSeen: false }] as const) { + expect(planUpdateRuntimeHandling({ ownership: null, serviceInstalled: true, supervision })) + .toEqual({ mayReplacePackage: true, mayStopRuntime: true, mayRestoreService: true, notice: null }); + expect(planUpdateRuntimeHandling({ ownership: null, ownershipUnknown: true, serviceInstalled: true, supervision }).mayReplacePackage).toBe(false); + } + }); + + test("one latch retains Desktop through inconclusive reads, and only none clears recovery and refresh", () => { + const latch = createSupervisionLatch(); + for (const [evidence, blocked] of [ + [desktopEvidence, true], + [{ kind: "unknown", reason: "probe-failed", desktopSeen: false }, true], + [{ kind: "unsupported" }, true], + [{ kind: "none" }, false], + ] as const) { + const supervision = latch.observe(evidence); + expect(supervision).toBe(blocked); + const plan = planUpdateRuntimeHandling({ ownership: null, serviceInstalled: true, supervision }); + expect(plan.mayReplacePackage).toBe(!blocked); + expect(plan.mayStopRuntime).toBe(!blocked); + expect(plan.mayRestoreService).toBe(!blocked); + for (const serviceInstalled of [false, true]) { + const recovery = planStoppedRuntimeRecovery({ + stopAttempted: true, ownership: null, sameOwner: true, liveness: "dead", serviceInstalled, + launcherUsable: true, hadRuntimeState: true, supervision, + }); + expect(recovery.action).toBe(blocked ? "none" : serviceInstalled ? "service" : "direct"); + if (blocked) expect(recovery.reason).toBe("desktop-supervised"); + } + } + }); + + // Evaluate the actual pre-stop production blocks with injected inspectors and inert spawns. + // The launcher module cannot be imported without executing its top-level dispatch. + for (const lane of ["Bun", "Node"] as const) { + test(`${lane}: none → Desktop at pre-stop performs zero stop/package/service mutations`, async () => { + expect(await exercisePreStop(lane, desktopEvidence)).toEqual([0, 0, 0]); + }); + test(`${lane}: desktopSeen at pre-stop activates the same refusal`, async () => { + expect(await exercisePreStop(lane, { kind: "unknown", reason: "probe-failed", desktopSeen: true })).toEqual([0, 0, 0]); + }); + test(`${lane}: plain unknown and unsupported keep stop/package/service activation`, async () => { + for (const evidence of [{ kind: "none" }, { kind: "unsupported" }, + { kind: "unknown", reason: "probe-failed", desktopSeen: false }] as const) { + expect(await exercisePreStop(lane, evidence)).toEqual([1, 1, 1]); + } + }); + } +}); + +async function exercisePreStop(lane: "Bun" | "Node", evidence: SupervisionEvidence, runtimeTrusted = true): Promise { + const source = readFileSync(repoPath(lane === "Bun" ? "src/update/index.ts" : "bin/ocx.mjs"), "utf8"); + const start = source.indexOf(lane === "Bun" ? " if (runtimePlan.mayStopRuntime &&" : " if (stopNeeded) {"); + const end = source.indexOf(lane === "Bun" ? ' if (stopStdio === "pipe")' : " const stillHasRuntimeState", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const counts = [0, 0, 0]; + const latch = createSupervisionLatch(); + const owner = { ownership: null, ownershipUnknown: false }; + const runtimePlan = planUpdateRuntimeHandling({ ...owner, serviceInstalled: true, supervision: latch.observe({ kind: "none" }) }); + const exit = new Error("fixture exit"); + const inspect = (deps?: { targetPid?: number }) => { + if (lane === "Bun") expect(deps).toEqual({ targetPid: runtimeTrusted ? 321 : undefined }); + else expect(deps).toBeUndefined(); + return evidence; + }; + const run = new Function("deps", `with (deps) { return (async () => { + let stopAttempted = false; + ${source.slice(start, end)} + } + packageMutation(); serviceMutation(); + })(); }`); + try { + await run({ + runtimePlan, serviceWasInstalled: true, readPid: () => 321, readRuntimePort: () => ({}), + pendingTeardownOutstanding: () => false, stopNeeded: true, + resolvedRuntimeOwnership: async () => owner, readOwnership: () => owner, + planUpdateRuntimeHandling, supervisionLatch: latch, inspectDesktopSupervision: inspect, + observeSupervision: () => latch.observe(inspect()), runtimeTrusted, livePid: 321, + console: { log() {}, error(notice: string) { expect(notice).toBe(desktopNotice); } }, + process: { execPath: "fixture", exit() { throw exit; } }, + selfLaunchArgv: () => [], launcher: "fixture", mutation: { controlEnvironment: () => ({}) }, + mutationChildEnvironment: () => ({}), releaseUpdateLease() {}, updateChildStdio: () => "ignore", + spawnSync: () => { counts[0]++; return { status: 0 }; }, + packageMutation: () => { counts[1]++; }, serviceMutation: () => { counts[2]++; }, + }); + } catch (error) { if (error !== exit) throw error; } + return counts; +} + + +test("Bun pre-stop uses inspector correlation when runtime identity is untrusted", async () => { + expect(await exercisePreStop("Bun", desktopEvidence, false)).toEqual([0, 0, 0]); + expect(await exercisePreStop("Bun", { kind: "none" }, false)).toEqual([1, 1, 1]); +}); + +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) { + ${source.slice(start, end)} + refreshBackgroundServiceOrStartDirect(); + }`); + function refresh(evidence: SupervisionEvidence[], prior?: SupervisionEvidence): number[] { + const latch = createSupervisionLatch(); + if (prior) latch.observe(prior); + const mutations = [0, 0, 0]; // repair, absent-service install, direct fallback + run({ + observeSupervision: () => latch.observe(evidence.shift() ?? { kind: "none" }), + planUpdateRuntimeHandling, readOwnership: () => ({ ownership: null }), + process: { env: {}, execPath: "fixture" }, bakePort: 12345, postUpdateLauncher: "fixture", + console: { log() {}, warn() {} }, mutationChildEnvironment: () => ({}), + serviceRefreshArgs: () => ["repair"], serviceInstallArgs: () => ["install"], + readServiceInstalledFromStatus: () => false, + spawnSync: (_bin: string, args: string[]) => { + if (args[0] === "repair") { mutations[0]++; return { status: 1 }; } + if (args[0] === "install") { mutations[1]++; return { status: 1 }; } + throw new Error("unexpected spawn"); + }, + startProxyDirectly: () => { mutations[2]++; }, + }); + return mutations; + } + test("a prior Desktop followed by plain unknown cannot refresh the service", () => { + expect(refresh([{ kind: "unknown", reason: "probe-failed", desktopSeen: false }], desktopEvidence)).toEqual([0, 0, 0]); + }); + test("Desktop appearing after repair prevents the absent-service install", () => { + expect(refresh([{ kind: "none" }, desktopEvidence])).toEqual([1, 0, 0]); + }); + test("Desktop appearing after install prevents direct fallback", () => { + expect(refresh([{ kind: "none" }, { kind: "none" }, desktopEvidence])).toEqual([1, 1, 0]); + }); + test("positive none clears the previous veto and activates repair, install and direct fallback", () => { + expect(refresh([{ kind: "none" }], desktopEvidence)).toEqual([1, 1, 1]); + }); +}); + +test("Bun and Node replacement, recovery and refresh plans all receive fresh latch verdicts", () => { + for (const file of ["src/update/index.ts", "bin/ocx.mjs"]) { + const source = readFileSync(repoPath(file), "utf8"); + expect(source.match(/createSupervisionLatch\(\)/g)).toHaveLength(1); + for (const call of source.matchAll(/plan(?:UpdateRuntimeHandling|StoppedRuntimeRecovery)\(\{([\s\S]*?)\}\)/g)) { + expect(call[1]).toMatch(/\bsupervision\b/); + } + expect(source).toContain("supervisionLatch.observe(inspectDesktopSupervision("); + } +}); + + +test("Bun refresh gate vetoes retained Desktop and activates only after positive none", async () => { + const source = readFileSync(repoPath("src/update/index.ts"), "utf8"); + 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 () => { + ${source.slice(start, end)} + return await mayRefresh(); + })(); }`); + const latch = createSupervisionLatch(); latch.observe(desktopEvidence); + for (const [evidence, allowed] of [ + [{ kind: "unknown", reason: "probe-failed", desktopSeen: false }, false], + [{ kind: "unsupported" }, false], [{ kind: "none" }, true], + ] as const) { + expect(await run({ + resolvedRuntimeOwnership: async () => ({ ownership: null }), + observeSupervision: () => latch.observe(evidence), planUpdateRuntimeHandling, + console: { warn() {} }, + })).toBe(allowed); + } +}); diff --git a/tests/update/update-restart-lease.test.ts b/tests/update/update-restart-lease.test.ts index 81e67d9d1ab..0f27ca2456f 100644 --- a/tests/update/update-restart-lease.test.ts +++ b/tests/update/update-restart-lease.test.ts @@ -34,6 +34,7 @@ import { updateJobPath, type UpdateJobState, } from "../../src/update/job"; +import type { SupervisionEvidence } from "../../src/service/desktop-supervision.mjs"; import { runUpdateRestartWithOwnershipLease } from "../../src/update/restart-ownership"; import { serviceStatePaths, type ServiceOwnershipResolution } from "../../src/service/state"; import { isolationBudgetMs, watchdogMs } from "../helpers/ci-watchdog"; @@ -867,3 +868,49 @@ describe("lease fixture containment and cleanup controls", () => { }, watchdogMs(20_000)); } }); + + +describe("dashboard restart live Desktop veto", () => { + const desktop: SupervisionEvidence = { + kind: "desktop", runtimePid: 321, supervisorPid: 123, app: "/fixture/opencodex-desktop", proxy: "/fixture/ocx", + }; + const unowned = (): ServiceOwnershipResolution => ({ kind: "none", revision: 0 }); + + test("Desktop and target-bound desktopSeen refuse before restart despite absent durable ownership", async () => { + for (const evidence of [desktop, { kind: "unknown", reason: "probe-failed", desktopSeen: true } as const]) { + const box = sandbox(); + let mutations = 0; + const result = await runUpdateRestartWithOwnershipLease(unowned, async () => { mutations++; }, () => evidence); + expect(result).toEqual({ kind: "veto", notice: expect.stringContaining("OpenCodex Desktop runs this proxy") }); + expect(mutations).toBe(0); expect(existsSync(box.lockDir)).toBe(false); + } + }); + + test("released refresh and direct fallback share a latch until none positively clears it", async () => { + const box = sandbox(); + const evidence: SupervisionEvidence[] = [ + { kind: "none" }, desktop, { kind: "unknown", reason: "probe-failed", desktopSeen: false }, + { kind: "unsupported" }, { kind: "none" }, + ]; + let reads = 0; + const result = await runUpdateRestartWithOwnershipLease(unowned, async lease => { + expect(lease.vetoAgain()).toContain("OpenCodex Desktop"); + lease.releaseForServiceManager(); + expect(lease.reacquireForDirectStart()).toEqual({ notice: expect.stringContaining("OpenCodex Desktop"), failed: false }); + expect(lease.vetoAgain()).toContain("OpenCodex Desktop"); + expect(lease.vetoAgain()).toBeNull(); + return "cleared"; + }, () => { reads++; return evidence.shift()!; }); + expect(result).toEqual({ kind: "ran", value: "cleared" }); + expect(reads).toBe(5); expect(existsSync(box.lockDir)).toBe(false); + }); + + test("none, unsupported and plain unknown activate restart normally", async () => { + for (const evidence of [{ kind: "none" }, { kind: "unsupported" }, + { kind: "unknown", reason: "probe-failed", desktopSeen: false }] as const) { + sandbox(); let mutations = 0; + const result = await runUpdateRestartWithOwnershipLease(unowned, async () => ++mutations, () => evidence); + expect(result).toEqual({ kind: "ran", value: 1 }); expect(mutations).toBe(1); + } + }); +}); From 8a23b32013ea06ef8b24816d070792a383082bbe Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 11:58:34 +0900 Subject: [PATCH 3/5] fix(cli): explain Desktop supervision on start, stop and restart --- src/cli/desktop-runtime-guidance.ts | 65 +++++++ src/cli/dispatch.ts | 5 + src/cli/index.ts | 8 +- src/cli/update-restart.ts | 13 +- tests/cli/cli-dispatch.test.ts | 5 +- tests/cli/cli-restart-health.test.ts | 251 ++++++++++++++++++++++++++- tests/cli/cli-stop-json.test.ts | 97 ++++++++++- 7 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 src/cli/desktop-runtime-guidance.ts diff --git a/src/cli/desktop-runtime-guidance.ts b/src/cli/desktop-runtime-guidance.ts new file mode 100644 index 00000000000..241c6d1ad64 --- /dev/null +++ b/src/cli/desktop-runtime-guidance.ts @@ -0,0 +1,65 @@ +import { createSupervisionLatch, inspectDesktopSupervision } from "../service/desktop-supervision.mjs"; +import type { DesktopSupervision, SupervisionInspector } from "../service/desktop-command-guard"; +import { UpdateRestartRequired } from "./update-restart-candidate"; +import { runProxyRestart, type ProxyRestartIo } from "./tray-proxy"; + +function evidenceForTarget(evidence: DesktopSupervision, pid: number | null | undefined): DesktopSupervision { + // Injected inspectors must obey the same target binding as the production inspector. + return evidence.kind === "desktop" && pid != null && evidence.runtimePid !== pid + ? { kind: "unknown", reason: "unrelated-target", desktopSeen: false } : evidence; +} + +export function duplicateRuntimeMessage( + pid: number | null | undefined, + port: number, + inspect: SupervisionInspector = inspectDesktopSupervision, +): string { + const latch = createSupervisionLatch(); + const evidence = evidenceForTarget(inspect({ targetPid: pid ?? undefined }), pid); + if (pid != null && latch.observe(evidence)) { + const supervisor = evidence.kind === "desktop" ? `, ${evidence.app}` : ""; + return `OpenCodex Desktop supervises the running proxy (pid ${pid}${supervisor}, port ${port}). ` + + "Use the running proxy, or quit OpenCodex before starting it from this CLI."; + } + return `⚠️ Proxy already running (PID ${pid ?? "unknown"}, port ${port}). Use 'ocx stop' first.`; +} + +export function desktopStopNotice(evidence: DesktopSupervision): string | null { + const latch = createSupervisionLatch(); + return latch.observe(evidence) + ? "OpenCodex Desktop may start this proxy again after a short backoff. Use Stop Proxy or Quit in the OpenCodex menu to keep it stopped." + : null; +} + +export function runDesktopAwareProxyRestart( + io: ProxyRestartIo, + inspect: SupervisionInspector = inspectDesktopSupervision, + info: (line: string) => void = console.log, +) { + const latch = createSupervisionLatch(); + let targetPid: number | undefined; + let supervisedRequest = false; + return runProxyRestart({ + ...io, + requestInPlaceRestart: async previous => { + targetPid = previous.pid ?? undefined; + const evidence = evidenceForTarget(inspect({ targetPid }), targetPid); + supervisedRequest = latch.observe(evidence); + const result = await io.requestInPlaceRestart(previous); + if (evidence.kind === "desktop" && result.accepted) { + info("Restart requested; OpenCodex Desktop starts the replacement."); + } + if (supervisedRequest && !result.accepted && !result.uncertain && result.error instanceof UpdateRestartRequired) { + info("OpenCodex Desktop supervises this proxy. Use the app's updater (tray → Check for Updates), or quit OpenCodex before updating and restarting from this CLI."); + return { accepted: false as const, uncertain: false, error: new Error("restart_desktop_update_required") }; + } + return result; + }, + startWhenStopped: async recovering => { + const blocked = latch.observe(evidenceForTarget(inspect({ targetPid }), targetPid)); + // An accepted supervised handoff keeps its replacement intent through temporary absence. + if (supervisedRequest || blocked) return { status: "skipped" as const }; + return io.startWhenStopped(recovering); + }, + }); +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index f2574b7d41a..6b0d75dfa24 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -37,6 +37,8 @@ import { parseStopApproval, type StopApproval } from "./stop-approval"; import type { ResolveArgs } from "./resolve"; import type { ClientConnectionState } from "../client/state"; import { OCX_NATIVE_REPLAY_RECOVERY_NOTE } from "../responses/compaction"; +import { inspectDesktopSupervision } from "../service/desktop-supervision.mjs"; +import { desktopStopNotice } from "./desktop-runtime-guidance"; export interface CliDispatchDeps { args: string[]; @@ -51,6 +53,7 @@ export interface CliDispatchDeps { spawnDetached: (argv: readonly string[]) => void; handleStart: () => Promise; handleStop: (approval?: StopApproval) => Promise; + inspectDesktopSupervision?: typeof inspectDesktopSupervision; handleEnsure: (options?: { existingIsSuccess?: boolean }) => Promise; handleResolve: (args: ResolveArgs) => Promise; handleTrayProxyStart: (existingIsSuccess?: boolean) => Promise; @@ -112,6 +115,8 @@ const commandRunners: Record = { // re-start the proxy immediately, so warning there would contradict the next line. const warning = "⚠️ Codex/Claude requests through the proxy will fail until it is restarted ('ocx start' or 'ocx service start')."; if (!parsed.json) { + const notice = desktopStopNotice((deps.inspectDesktopSupervision ?? inspectDesktopSupervision)()); + if (notice) console.error(notice); // handleStop returns the structured outcome now; an object is always truthy, so // the warning must key on .ok — otherwise a failed stop would still claim downtime. if ((await deps.handleStop()).ok) console.log(warning); diff --git a/src/cli/index.ts b/src/cli/index.ts index 38a741e834f..31cd6ee17c3 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -85,11 +85,11 @@ import { recheckRestartFailedStart, reobserveRestartReplacement, restartStartOutcome, waitForProxyReplacement, - runProxyRestart, runTrayProxyStart, type ProxyRestartLive, type ProxyRestartStartOutcome, } from "./tray-proxy"; +import { duplicateRuntimeMessage, runDesktopAwareProxyRestart } from "./desktop-runtime-guidance"; import { reportRestartFailure } from "./restart-failure"; import { requestBoundSystemRestart } from "./system-restart-client"; import { installCrashGuards } from "../lib/crash-guard"; @@ -329,7 +329,7 @@ async function chooseListenPort( throw new StartCommandExit(serviceStayOutExitCode()); } if (decision === "refuse-live-proxy") { - console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`); + console.error(duplicateRuntimeMessage(holder?.pid, preferred)); throw new StartCommandExit(1); } if (decision === "refuse-unidentified-holder") { @@ -444,7 +444,7 @@ async function handleStart(options: { block?: boolean } = {}) { process.exit(serviceStayOutExitCode()); } if (decision === "refuse" || decision === "await-parent") { - console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`); + console.error(duplicateRuntimeMessage(owner.live.pid ?? owner.pidSnapshot, owner.live.port)); process.exit(1); } // Sibling path. The new instance takes over this home's ocx.pid / runtime-port.json while @@ -928,7 +928,7 @@ async function handleProxyRestart( startWhenStopped: (recoveringLiveRestart: boolean) => Promise, ): Promise { const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS; - const result = await runProxyRestart({ + const result = await runDesktopAwareProxyRestart({ findLive: () => discoverStableProxyForRestart({ findLive: () => findLiveProxy({ deadlineAt, attempts: 2, acceptPackageTreeFenced: true }), expired: () => Date.now() >= deadlineAt, diff --git a/src/cli/update-restart.ts b/src/cli/update-restart.ts index 1a59308dc84..d1b2f763f4c 100644 --- a/src/cli/update-restart.ts +++ b/src/cli/update-restart.ts @@ -19,9 +19,11 @@ import { observeAttestedUpdateReplacement, stopAttestedUpdateTarget } from "./up import type { UpdateRestartCandidate } from "./update-restart-candidate"; import { INCOMPLETE_INSTALL_RECOVERY } from "./restart-failure"; import { computeVersionSkew } from "./version-skew"; +import { createSupervisionLatch, inspectDesktopSupervision } from "../service/desktop-supervision.mjs"; export interface UpdateRestartChild { pid?: number; exitCode: number | null; signalCode: string | null } export interface UpdateRestartIo { + inspectSupervision?: typeof inspectDesktopSupervision; now(): number; acquire(): { release(): void }; home(): UpdateRestartHome; @@ -37,7 +39,7 @@ export interface UpdateRestartIo { wait(ms: number): Promise; } const ELIGIBILITY_REASONS = ["windows", "unsupported_platform", "foreground", "service", "shared_or_client", - "package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed"] as const; + "package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed", "desktop"] as const; export type UpdateRestartEligibilityReason = (typeof ELIGIBILITY_REASONS)[number]; /** Carries only a closed reason code across the eligibility/transport boundary. */ @@ -56,6 +58,7 @@ function sameRuntime(candidate: UpdateRestartCandidate, current: RuntimePortStat /** A terminal update transaction: it never enters generic restart recovery. */ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadlineAt: number, io: UpdateRestartIo): Promise { + const latch = createSupervisionLatch(); let phase = "eligibility"; let lease: { release(): void } | undefined; // Recorded here because the stop transport sanitizes anything thrown by beforeStop. @@ -79,6 +82,13 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli eligibilityReason = currentRuntime?.siblingOfPort !== undefined ? "shared_or_client" : "target_changed"; throw new Error("target"); } + const supervision = (io.inspectSupervision ?? inspectDesktopSupervision)({ targetPid: candidate.target.pid }); + const evidence = supervision.kind === "desktop" && supervision.runtimePid !== candidate.target.pid + ? { kind: "unknown" as const, reason: "unrelated-target", desktopSeen: false } : supervision; + if (latch.observe(evidence)) { + eligibilityReason = "desktop"; + throw new UpdateRestartEligibilityError("desktop"); + } try { if (!io.standalone(candidate.target)) throw new Error("target"); } catch (error) { @@ -147,6 +157,7 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar switch (reason) { case "windows": return "Restart from a newer CLI is unavailable on Windows. Run `ocx status`, then restart through the owning service or desktop app."; case "unsupported_platform": return "Restart from a newer CLI requires macOS or Linux. Run `ocx status`, then use the owning lifecycle manager."; + case "desktop": return "OpenCodex Desktop supervises this proxy. Use the app's updater (tray → Check for Updates), or quit OpenCodex before updating and restarting from this CLI."; case "service": return "An installed or active service owns lifecycle control. Run `ocx status`, then use the owning installation's `ocx service restart`."; case "foreground": return "The proxy is attached to a parent process. Run `ocx status`; if it runs in a terminal, restart it in that terminal, otherwise use its owning app or supervisor."; case "shared_or_client": return "This is a shared or connected-client runtime. Run `ocx status` and restart through its owning Hub, service or desktop app."; diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 63328072f59..6d766cb27c9 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -524,7 +524,10 @@ describe("a busy preferred port never becomes a second proxy (#5004)", () => { // Both refusals preserve the exit code through the caller's lease-cleanup boundary. expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?StartCommandExit\(1\)/); - expect(fn).toContain("Use 'ocx stop' first."); + // The refusal text moved into the guidance leaf so a Desktop-supervised holder gets Desktop + // guidance; the ordinary holder still hears "Use 'ocx stop' first." from the same function. + expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,200}?duplicateRuntimeMessage\(holder\?\.pid, preferred\)/); + expect(readFileSync(repoPath("src/cli/desktop-runtime-guidance.ts"), "utf8")).toContain("Use 'ocx stop' first."); expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?StartCommandExit\(1\)/); // The wrapper receives an explicit stay-out signal for a served port. expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?StartCommandExit\(serviceStayOutExitCode\(\)\)/); diff --git a/tests/cli/cli-restart-health.test.ts b/tests/cli/cli-restart-health.test.ts index be3b15e69f5..017c087129c 100644 --- a/tests/cli/cli-restart-health.test.ts +++ b/tests/cli/cli-restart-health.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { watchdogMs } from "../helpers/ci-watchdog"; import { captureTestOutput } from "../../scripts/test"; +import { repoPath } from "../helpers/repo-root"; +import { duplicateRuntimeMessage, runDesktopAwareProxyRestart } from "../../src/cli/desktop-runtime-guidance"; +import { UpdateRestartRequired, type UpdateRestartCandidate } from "../../src/cli/update-restart-candidate"; +import { describeUpdateRestartFailure, runUpdateRestart, type UpdateRestartIo } from "../../src/cli/update-restart"; +import type { SupervisionEvidence } from "../../src/service/desktop-supervision.mjs"; +import type { ProxyRestartIo, ProxyRestartLive } from "../../src/cli/tray-proxy"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); const cliPath = join(repoRoot, "src", "cli", "index.ts"); @@ -387,3 +393,246 @@ describe("ocx ready", () => { } }, CLI_TEST_TIMEOUT); }); + +const DESKTOP = { kind: "desktop", runtimePid: 4242, supervisorPid: 4200, + app: "/Applications/OpenCodex.app/Contents/MacOS/opencodex-desktop", + proxy: "/Applications/OpenCodex.app/Contents/MacOS/ocx" } as const; +const NON_DESKTOP: SupervisionEvidence[] = [{ kind: "none" }, + { kind: "unknown", reason: "probe-timeout", desktopSeen: false }, { kind: "unsupported" }]; +const RESTART_TARGET: ProxyRestartLive = { pid: 4242, port: 19999, source: "runtime" }; +const UPDATE_CANDIDATE: UpdateRestartCandidate = { + home: { config: { path: "/test/ocx", dev: 1, ino: 2 }, codex: { path: "/test/codex", dev: 1, ino: 3 }, revision: 0 }, + target: { ...RESTART_TARGET, pid: 4242, version: "2.76.0" }, + runtime: { pid: 4242, port: 19999, attestationSecret: "a".repeat(43) }, cliVersion: "2.77.0", +}; +function desktopRestartFixture() { + const events: string[] = []; + const lines: string[] = []; + const io: ProxyRestartIo = { + findLive: async () => ({ status: "live", live: RESTART_TARGET }), + requestInPlaceRestart: async () => { events.push("request"); return { accepted: true }; }, + waitForReplacement: async () => { events.push("replacement"); return { ...RESTART_TARGET, pid: 4243 }; }, + reobserveAfterReplacement: async () => ({ status: "absent" }), + recheckAfterFailedStart: async () => ({ status: "live", live: { ...RESTART_TARGET, pid: 4243 } }), + startWhenStopped: async recovering => { events.push(`start:${recovering}`); return { status: "started" }; }, + waitBetweenAttempts: async () => {}, + }; + return { io, events, lines }; +} + +describe("Desktop CLI start and restart guidance", () => { + test("duplicate start guidance names the verified Desktop target", () => { + const probes: Array = []; + expect(duplicateRuntimeMessage(4242, 19999, deps => { probes.push(deps?.targetPid); return DESKTOP; })) + .toBe(`OpenCodex Desktop supervises the running proxy (pid 4242, ${DESKTOP.app}, port 19999). Use the running proxy, or quit OpenCodex before starting it from this CLI.`); + expect(probes).toEqual([4242]); + for (const evidence of [...NON_DESKTOP, { ...DESKTOP, runtimePid: 9999 }]) { + expect(duplicateRuntimeMessage(4242, 19999, () => evidence)) + .toBe("⚠️ Proxy already running (PID 4242, port 19999). Use 'ocx stop' first."); + } + expect(duplicateRuntimeMessage(null, 19999, () => DESKTOP)) + .toBe("⚠️ Proxy already running (PID unknown, port 19999). Use 'ocx stop' first."); + expect(duplicateRuntimeMessage(4242, 19999, () => ({ kind: "unknown", reason: "pid-mismatch", desktopSeen: true }))) + .toContain("quit OpenCodex before starting it from this CLI"); + }); + + test("Desktop restart reports its replacement only after request acceptance", async () => { + const s = desktopRestartFixture(); + const result = await runDesktopAwareProxyRestart(s.io, deps => { + expect(deps?.targetPid).toBe(4242); + return DESKTOP; + }, line => { expect(s.events).toEqual(["request"]); s.lines.push(line); }); + expect(result).toEqual({ ok: true, mode: "restarted", live: { ...RESTART_TARGET, pid: 4243 } }); + expect(s.lines).toEqual(["Restart requested; OpenCodex Desktop starts the replacement."]); + expect(s.events).toEqual(["request", "replacement"]); + }); + + for (const later of NON_DESKTOP) test(`Desktop restart misses never start a competing CLI runtime: ${later.kind}`, async () => { + const s = desktopRestartFixture(); + s.io.waitForReplacement = async () => null; + const probes: Array = []; + const result = await runDesktopAwareProxyRestart(s.io, deps => { + probes.push(deps?.targetPid); + return probes.length === 1 ? DESKTOP : later; + }, line => s.lines.push(line)); + expect(result).toEqual({ ok: false, phase: "replacement" }); + expect(s.events).toEqual(["request"]); + expect(probes).toEqual([4242, 4242]); + expect(s.lines).toEqual(["Restart requested; OpenCodex Desktop starts the replacement."]); + }); + + for (const later of [DESKTOP, { kind: "unknown", reason: "pid-mismatch", desktopSeen: true }] as const) { + test(`Desktop appearing before a target-bound restart fallback prevents CLI activation: ${later.kind}`, async () => { + const s = desktopRestartFixture(); + s.io.waitForReplacement = async () => null; + let probes = 0; + const result = await runDesktopAwareProxyRestart(s.io, deps => { + expect(deps?.targetPid).toBe(4242); + return ++probes === 1 ? { kind: "none" } : later; + }, line => s.lines.push(line)); + expect(result).toEqual({ ok: false, phase: "replacement" }); + expect(probes).toBe(2); + expect(s.events).toEqual(["request"]); + expect(s.lines).toEqual([]); + }); + } + + test("target-bound unknown with desktopSeen retains restart supervision through inconclusive fallback", async () => { + const s = desktopRestartFixture(); + s.io.waitForReplacement = async () => null; + let probes = 0; + expect(await runDesktopAwareProxyRestart(s.io, deps => { + expect(deps?.targetPid).toBe(4242); + return { kind: "unknown", reason: "probe-timeout", desktopSeen: ++probes === 1 }; + }, line => s.lines.push(line))).toEqual({ ok: false, phase: "replacement" }); + expect(probes).toBe(2); + expect(s.events).toEqual(["request"]); + expect(s.lines).toEqual([]); + }); + + for (const uncertain of [false, true]) test(`Desktop restart refusal and uncertain response do not claim acceptance: ${uncertain}`, async () => { + const s = desktopRestartFixture(); + const error = new Error("request-refused"); + s.io.requestInPlaceRestart = async () => ({ accepted: false, uncertain, error }); + s.io.waitForReplacement = async () => null; + expect(await runDesktopAwareProxyRestart(s.io, () => DESKTOP, line => s.lines.push(line))) + .toEqual({ ok: false, phase: "request", error }); + expect(s.events).toEqual([]); + expect(s.lines).toEqual([]); + }); + + for (const evidence of [DESKTOP, { kind: "unknown", reason: "probe-timeout", desktopSeen: true }] as const) { + test(`newer CLI does not replace a supervised Desktop target: ${evidence.kind}`, async () => { + const s = desktopRestartFixture(); + s.io.requestInPlaceRestart = async () => ({ accepted: false, uncertain: false, error: new UpdateRestartRequired(UPDATE_CANDIDATE) }); + const result = await runDesktopAwareProxyRestart(s.io, () => evidence, line => s.lines.push(line)); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected refusal"); + expect(result.phase).toBe("request"); + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toBe("restart_desktop_update_required"); + expect(result.error).not.toBeInstanceOf(UpdateRestartRequired); + expect(s.events).toEqual([]); + expect(s.lines).toEqual(["OpenCodex Desktop supervises this proxy. Use the app's updater (tray → Check for Updates), or quit OpenCodex before updating and restarting from this CLI."]); + }); + } + + for (const evidence of [...NON_DESKTOP, { ...DESKTOP, runtimePid: 9999 }]) { + test(`non-Desktop restart retains generic recovery: ${evidence.kind}`, async () => { + const s = desktopRestartFixture(); + s.io.waitForReplacement = async () => null; + const probes: Array = []; + expect(await runDesktopAwareProxyRestart(s.io, deps => { probes.push(deps?.targetPid); return evidence; }, line => s.lines.push(line))) + .toEqual({ ok: true, mode: "started" }); + expect(probes).toEqual([4242, 4242]); + expect(s.events).toEqual(["request", "start:true"]); + expect(s.lines).toEqual([]); + }); + } + + test("restart fallback permits an unrelated Desktop pid appearing after the request", async () => { + const s = desktopRestartFixture(); + s.io.waitForReplacement = async () => null; + let probes = 0; + expect(await runDesktopAwareProxyRestart(s.io, deps => { + expect(deps?.targetPid).toBe(4242); + return ++probes === 1 ? { kind: "none" } : { ...DESKTOP, runtimePid: 9999 }; + }, line => s.lines.push(line))).toEqual({ ok: true, mode: "started" }); + expect(probes).toBe(2); + expect(s.events).toEqual(["request", "start:true"]); + expect(s.lines).toEqual([]); + }); + + for (const evidence of [...NON_DESKTOP, { ...DESKTOP, runtimePid: 9999 }]) { + test(`non-Desktop newer-CLI request retains the current-install candidate: ${evidence.kind}`, async () => { + const s = desktopRestartFixture(); + const error = new UpdateRestartRequired(UPDATE_CANDIDATE); + s.io.requestInPlaceRestart = async () => ({ accepted: false, uncertain: false, error }); + expect(await runDesktopAwareProxyRestart(s.io, () => evidence, line => s.lines.push(line))) + .toEqual({ ok: false, phase: "request", error }); + expect(error.candidate()).toBe(UPDATE_CANDIDATE); + expect(s.events).toEqual([]); + expect(s.lines).toEqual([]); + }); + } + + for (const evidence of [DESKTOP, { kind: "unknown", reason: "probe-timeout", desktopSeen: true }, ...NON_DESKTOP]) { + test(`restart with no discovered target inspects before start: ${evidence.kind}`, async () => { + const s = desktopRestartFixture(); + s.io.findLive = async () => ({ status: "absent" }); + let probes = 0; + const blocked = evidence.kind === "desktop" || (evidence.kind === "unknown" && evidence.desktopSeen); + expect(await runDesktopAwareProxyRestart(s.io, deps => { + expect(deps?.targetPid).toBeUndefined(); + probes++; + return evidence; + }, line => s.lines.push(line))).toEqual({ ok: true, mode: blocked ? "skipped" : "started" }); + expect(probes).toBe(1); + expect(s.events).toEqual(blocked ? [] : ["start:false"]); + expect(s.lines).toEqual([]); + }); + } + + test("a thrown supervised restart request remains uncertain and never starts fallback", async () => { + const s = desktopRestartFixture(); + const error = new Error("transport lost"); + s.io.requestInPlaceRestart = async () => { throw error; }; + s.io.waitForReplacement = async () => null; + expect(await runDesktopAwareProxyRestart(s.io, () => DESKTOP, line => s.lines.push(line))) + .toEqual({ ok: false, phase: "request", error }); + expect(s.events).toEqual([]); + expect(s.lines).toEqual([]); + }); + + test("CLI entrypoint binds both duplicate refusals and restart to Desktop guidance", () => { + const source = readFileSync(repoPath("src", "cli", "index.ts"), "utf8"); + expect(source).toContain("console.error(duplicateRuntimeMessage(holder?.pid, preferred))"); + expect(source).toContain("console.error(duplicateRuntimeMessage(owner.live.pid ?? owner.pidSnapshot, owner.live.port))"); + expect(source).toContain("const result = await runDesktopAwareProxyRestart({"); + }); +}); + +function desktopUpdateRestartFixture() { + const events: string[] = []; + const live = { ...UPDATE_CANDIDATE.target, pid: 4243, version: "2.77.0" }; + const io: UpdateRestartIo = { + inspectSupervision: () => ({ kind: "none" }), now: () => 1000, + acquire: () => { events.push("acquire"); return { release: () => { events.push("release"); } }; }, + home: () => UPDATE_CANDIDATE.home, checkHome: () => {}, runtime: () => UPDATE_CANDIDATE.runtime, + standalone: () => true, runtimeReady: () => true, + stop: async (_candidate, _deadline, beforeStop) => { beforeStop(); events.push("stop"); }, + stopped: async () => true, + start: () => { events.push("start"); return { pid: 4243, exitCode: null, signalCode: null }; }, + observe: async () => live, wait: async () => {}, + }; + return { io, events, live }; +} + +describe("Desktop guard on current-install restart", () => { + for (const evidence of [DESKTOP, { kind: "unknown", reason: "pid-mismatch", desktopSeen: true }] as const) { + for (const boundary of ["initial", "before-stop"] as const) { + test(`${evidence.kind} vetoes ${boundary} restart before stop or start`, async () => { + const s = desktopUpdateRestartFixture(); + let probes = 0; + s.io.inspectSupervision = deps => { + expect(deps?.targetPid).toBe(4242); + return ++probes === 1 && boundary === "before-stop" ? { kind: "none" } : evidence; + }; + const result = await runUpdateRestart(UPDATE_CANDIDATE, 5000, s.io); + const code = boundary === "initial" ? "update_restart_eligibility_failed" : "update_restart_stop_failed"; + expect(result).toEqual({ ok: false, code, reason: "desktop" }); + expect(probes).toBe(boundary === "initial" ? 1 : 2); + expect(s.events).toEqual(["acquire", "release"]); + expect(describeUpdateRestartFailure(code, "desktop")).toBe("OpenCodex Desktop supervises this proxy. Use the app's updater (tray → Check for Updates), or quit OpenCodex before updating and restarting from this CLI. Nothing was stopped by this restart attempt."); + }); + } + } + for (const evidence of [...NON_DESKTOP, { ...DESKTOP, runtimePid: 9999 }]) { + test(`current-install restart retains eligibility: ${evidence.kind}`, async () => { + const s = desktopUpdateRestartFixture(); + s.io.inspectSupervision = deps => { expect(deps?.targetPid).toBe(4242); return evidence; }; + expect(await runUpdateRestart(UPDATE_CANDIDATE, 5000, s.io)).toEqual({ ok: true, live: s.live }); + expect(s.events).toEqual(["acquire", "stop", "start", "release"]); + }); + } +}); diff --git a/tests/cli/cli-stop-json.test.ts b/tests/cli/cli-stop-json.test.ts index 900a3a2b558..5a7168e55a2 100644 --- a/tests/cli/cli-stop-json.test.ts +++ b/tests/cli/cli-stop-json.test.ts @@ -171,9 +171,11 @@ describe("ocx stop --json dispatch", () => { }; } - function stopDeps(handleStop: CliDispatchDeps["handleStop"], args: string[]): CliDispatchDeps { + function stopDeps(handleStop: CliDispatchDeps["handleStop"], args: string[], + inspectDesktopSupervision: NonNullable = () => ({ kind: "none" }), + ): CliDispatchDeps { const head: CliHead = { kind: "command", command: "stop", args }; - return { args, head, handleStop } as unknown as CliDispatchDeps; + return { args, head, handleStop, inspectDesktopSupervision } as unknown as CliDispatchDeps; } test("emits exactly one JSON document and moves human output to stderr", async () => { @@ -268,6 +270,97 @@ describe("ocx stop --json dispatch", () => { } }); + const desktopNotice = "OpenCodex Desktop may start this proxy again after a short backoff. Use Stop Proxy or Quit in the OpenCodex menu to keep it stopped."; + const desktop = { kind: "desktop", runtimePid: 4242, supervisorPid: 4200, + app: "/Applications/OpenCodex.app/Contents/MacOS/opencodex-desktop", + proxy: "/Applications/OpenCodex.app/Contents/MacOS/ocx" } as const; + + for (const evidence of [desktop, { kind: "unknown", reason: "probe-timeout", desktopSeen: true }] as const) { + test(`ordinary stop warns on stderr before stopping: ${evidence.kind}`, async () => { + const restoreExitCode = withExitCode(0); + const captured = captureConsole(); + const calls: string[] = []; + try { + const args = ["stop"]; + const code = await dispatchCommand({ kind: "command", command: "stop", args }, stopDeps(async () => { + calls.push("stop"); + expect(captured.stderr).toEqual([desktopNotice]); + console.log("Service manager stopped."); + return { ok: true, summary: summarize(record(), { exitCode: 0 }) }; + }, args, () => { calls.push("inspect"); return evidence; })); + expect(code).toBe(0); + expect(process.exitCode).toBe(0); + expect(calls).toEqual(["inspect", "stop"]); + expect(captured.stderr).toEqual([desktopNotice]); + expect(captured.stdout[0]).toBe("Service manager stopped."); + expect(captured.stdout.some(line => line.includes("will fail until it is restarted"))).toBe(true); + } finally { captured.restore(); restoreExitCode(); } + }); + } + + for (const exitCode of [0, 1, STOP_HISTORY_INCOMPLETE_EXIT_CODE, STOP_HISTORY_DEFERRED_EXIT_CODE]) { + test(`Desktop stop --json never probes or emits supervision guidance, exit ${exitCode}`, async () => { + const restoreExitCode = withExitCode(exitCode); + const captured = captureConsole(); + let stops = 0; + const summary = summarize(record(), { exitCode, failed: exitCode === 1, + historyOnly: exitCode === STOP_HISTORY_INCOMPLETE_EXIT_CODE, + historyDeferred: exitCode === STOP_HISTORY_DEFERRED_EXIT_CODE }); + try { + const args = ["stop", "--json"]; + const code = await dispatchCommand({ kind: "command", command: "stop", args }, stopDeps(async () => { + stops++; + return { ok: exitCode !== 1, summary }; + }, args, () => { throw new Error("JSON stop must not inspect supervision"); })); + expect(stops).toBe(1); + expect(code).toBe(exitCode); + expect(process.exitCode).toBe(exitCode); + expect(captured.stdout).toEqual([JSON.stringify(summary)]); + expect(captured.stderr.some(line => line.includes("OpenCodex Desktop"))).toBe(false); + } finally { captured.restore(); restoreExitCode(); } + }); + } + + for (const evidence of [{ kind: "none" }, { kind: "unknown", reason: "probe-timeout", desktopSeen: false }, + { kind: "unsupported" }] as const) { + test(`non-Desktop stop retains output and behavior: ${evidence.kind}`, async () => { + const restoreExitCode = withExitCode(0); + const captured = captureConsole(); + let stops = 0; + try { + const args = ["stop"]; + expect(await dispatchCommand({ kind: "command", command: "stop", args }, stopDeps(async () => { + stops++; + return { ok: true, summary: summarize(record(), { exitCode: 0 }) }; + }, args, () => evidence))).toBe(0); + expect(stops).toBe(1); + expect(captured.stderr).toEqual([]); + expect(captured.stdout).toHaveLength(1); + expect(captured.stdout[0]).toContain("will fail until it is restarted"); + } finally { captured.restore(); restoreExitCode(); } + }); + } + + test("Desktop supervision notice remains useful when stop detects a respawn", async () => { + const restoreExitCode = withExitCode(1); + const captured = captureConsole(); + const summary = summarize(record({ proxy: "respawned", service: "stopped-respawnable", sharedTeardown: "skipped" }), + { failed: true, exitCode: 1 }); + let stops = 0; + try { + const args = ["stop"]; + expect(await dispatchCommand({ kind: "command", command: "stop", args }, stopDeps(async () => { + stops++; + return { ok: false, summary }; + }, args, () => desktop))).toBe(1); + expect(stops).toBe(1); + expect(summary.runtimeDown).toBe(false); + expect(captured.stderr).toEqual([desktopNotice]); + expect(captured.stdout).toEqual([]); + expect(process.exitCode).toBe(1); + } finally { captured.restore(); restoreExitCode(); } + }); + test("guarded JSON stop forwards approval and rejects partial args before action", async () => { const flags = ["--json", "--expect-pid", "42", "--expect-port", "10100", "--expect-hostname", "", "--expect-config-home", "/sandbox", From 4a5fba126d0215346e8f0379fc43db06fbb546c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 11:58:34 +0900 Subject: [PATCH 4/5] docs: describe the Desktop supervision command guards --- .../src/content/docs/guides/desktop-app.md | 22 +++++++++++++++ .../src/content/docs/ko/guides/desktop-app.md | 22 +++++++++++++++ structure/cli-management.md | 8 ++++++ structure/desktop-shell.md | 27 +++++++++++++++++-- structure/ops/service-and-sidecars.md | 12 ++++++++- structure/runtime.md | 26 +++++++++--------- 6 files changed, 101 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index a5170c18689..dadd2064a66 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -83,6 +83,28 @@ The bundled CLI is `/Applications/OpenCodex.app/Contents/MacOS/ocx` on macOS and `/usr/bin/ocx` for the Linux deb package. Run that executable with `status` to check the app's proxy. Windows supervision detection is unsupported. +While the CLI detects Desktop supervising the proxy, `ocx service install`, `repair`, +`start`, and `restart` refuse before changing the service, even without recorded ownership +or verified login registration. Quit OpenCodex, then run `ocx service install` to move +startup management to the CLI. A duplicate `ocx start` names the Desktop supervisor. + +Use **Check for Updates…** in the tray to update the app's bundle. `ocx update` refuses +package replacement while Desktop supervises the proxy, even for a separate npm/Bun install; +quit OpenCodex first to update that install. An accepted `ocx restart` reports that Desktop +starts the replacement and waits for it to become healthy. A newer PATH CLI cannot use +`ocx restart` to replace the app's proxy with its own runtime. + +Terminal `ocx stop` still stops the proxy, but Desktop may start it again after a short +backoff. Use **Stop proxy** or **Quit** in the tray to keep it stopped. Ordinary stop prints +this reminder on stderr; `ocx stop --json` skips the supervision probe and reminder. + +These guards are early warnings in current CLIs and runtimes; older versions can lack them, +and Windows does not support the supervision probe. A probe that saw Desktop but could not +finish verification also blocks the operation. Once blocked, a later inconclusive probe +does not clear it; a check must positively show no Desktop supervision. With no prior +Desktop evidence, an inconclusive or unsupported probe keeps the existing command behavior. +Recorded-ownership guards still apply independently. + ## Startup safety on macOS and Linux Startup safety reports **Desktop app** protection when fresh diagnostics verify diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index 9e2dc08ee4e..c53dec5ff8f 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -59,6 +59,28 @@ status에 표시되는 데스크톱 안내를 따르세요. 실행 관계 확인 Linux deb 패키지에서 `/usr/bin/ocx`입니다. 해당 실행 파일에 `status`를 붙여 앱의 프록시 상태를 확인할 수 있습니다. Windows에서는 supervision 감지를 지원하지 않습니다. +CLI가 Desktop의 프록시 감독을 감지하면 `ocx service install`, `repair`, `start`, +`restart`는 서비스를 변경하기 전에 중단됩니다. 기록된 소유권이나 확인된 로그인 +등록이 없어도 적용됩니다. CLI로 시작 관리를 옮기려면 OpenCodex를 종료한 뒤 +`ocx service install`을 실행하세요. 중복 `ocx start`는 Desktop 감독자를 표시합니다. + +앱 번들은 트레이의 **Check for Updates…**로 업데이트하세요. Desktop이 프록시를 +감독하는 동안 `ocx update`는 별도로 설치한 npm/Bun 패키지도 교체하지 않습니다. +해당 설치를 업데이트하려면 OpenCodex를 먼저 종료하세요. 수락된 `ocx restart`는 +Desktop이 교체 프록시를 시작한다고 알리고 정상 작동 확인을 기다립니다. 더 최신인 +PATH CLI도 `ocx restart`로 앱의 프록시를 자기 설치의 런타임으로 교체할 수 없습니다. + +터미널의 `ocx stop`은 프록시를 중지하지만 Desktop이 짧은 대기 후 다시 시작할 수 +있습니다. 계속 중지하려면 트레이의 **Stop proxy** 또는 **Quit**을 사용하세요. +일반 stop은 이 안내를 stderr에 표시하고, `ocx stop --json`은 감독 검사와 안내를 생략합니다. + +이 가드는 현재 CLI와 런타임이 제공하는 조기 경고입니다. 이전 버전에는 없을 수 있으며 +Windows에서는 감독 검사를 지원하지 않습니다. 검사 중 Desktop을 발견했지만 확인을 +완료하지 못한 경우에도 작업을 차단합니다. 한 번 차단되면 이후의 불확실한 검사로는 +해제되지 않으며, Desktop 감독이 없다는 사실이 확인되어야 합니다. 앞서 Desktop을 +발견하지 않았다면 불확실하거나 지원되지 않는 검사는 기존 명령 동작을 유지합니다. +기록된 소유권에 따른 기존 거부 검사는 별도로 적용됩니다. + ## 트레이에서 사용량 보기 macOS와 Windows에서는 트레이 아이콘을 클릭하면 작은 사용량 창이 열립니다. 트레이의 **Show usage**로도 열 수 있으며, 트레이 클릭 이벤트를 전달하지 않는 Linux 데스크톱에서도 사용할 수 있습니다. Linux에서는 트레이 아이콘이 표시되지 않는 환경을 포함해 시작할 때 대시보드가 열립니다. diff --git a/structure/cli-management.md b/structure/cli-management.md index 86b37728cd0..f00ba15f814 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -6,6 +6,14 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output boundary, which `src/cli/runtime-api.ts` owns alongside the human/JSON print split. Every CLI path that prints a hub-supplied catalog value renders it there: the first-time refusal in `src/cli/connect.ts` and the connected `ocx sync` refusal in `src/cli/dispatch.ts`. C0/C1 controls, DEL, and Unicode line/paragraph separators print as visible hexadecimal escapes; structured status retains the exact reason, and a rendered failure keeps the domain error as its `cause`. The ready/unverified/incompatible classification and exit policy are unchanged. `src/cli/capabilities-command.ts` rejects leftover positional arguments, unknown or repeated flags, and blank route filters with exit 64 before emitting a capability index; a valid unmatched route remains exit 4. `printData` in `src/cli/runtime-api.ts` escapes each human-output line at the shared renderer, including role and delegation-model suggestion rationale. JSON output retains the exact original values. `src/cli/export-command.ts` passes native serialized documents as individual lines so their multiline layout is preserved. `tests/cli/cli-headless-parity.test.ts` checks both suggestion paths independently from their parsed JSON output; `tests/cli/cli-export-command.test.ts` covers export framing. +`src/cli/desktop-runtime-guidance.ts` names verified Desktop supervision of the target for +duplicate starts and accepted restart requests. Ordinary stop warns on stderr that Desktop +may start the proxy again after backoff and directs the user to Stop Proxy or Quit in the +OpenCodex menu. `stop --json` does not probe or print this guidance. Acceptance is not +replacement health proof: lifecycle decisions, attestation and exit codes stay authoritative, +and a known supervised target never earns a competing CLI recovery start. Command guard +continuity follows [runtime ownership](runtime.md#background-service-runtime-ownership). + ## Head and help navigation `src/cli/root.ts`, `src/cli/help.ts`, `src/cli/help-navigation.ts` diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index ffa41294ffd..e5f1fc5ce39 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -287,8 +287,10 @@ the tray's Stop (when it takes the phase), a quit's drain and an update's drain runtime's exit can arrive, finishing a stop does not restore it, and the failure page's retry sets it again. A coordinated update remembers the intent it temporarily clears: an aborted update restores a previously wanted runtime, but never turns a completed tray Stop back on. A terminal -`ocx stop` of the runtime this app started clears nothing, so the app starts it again after the -backoff; the tray's Stop and Quit keep it stopped. The dashboard's own Stop, in the app's window or +`ocx stop` of the runtime this app started clears nothing, so the app may start it again after +backoff; the CLI warns on stderr, while `stop --json` skips the probe and notice. The tray's +Stop and Quit keep it stopped. An accepted CLI restart says Desktop starts the replacement; +the CLI never starts its own recovery replacement for a known supervised target. The dashboard's own Stop, in the app's window or a browser, is refused with `desktop_supervised` while the app supervises the runtime (`src/server/stop-teardown.ts`): it would be undone within seconds, after a full native-Codex teardown. Only a dashboard session is refused; `ocx stop` authenticates with the admin token. Every @@ -339,6 +341,27 @@ process snapshots must agree. The target runtime PID, `ocx.pid`, and the PID in The attestation secret is never included in supervision evidence. Windows reports supervision as unsupported. +Command guards use this live evidence independently of login registration and durable ownership. +Each guarded service command, Node/Bun update run and dashboard restart decision uses one +`createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: `desktop` or +`unknown` with `desktopSeen: true` blocks; only a later positive `none` clears it. A later +plain `unknown` or `unsupported` cannot clear a prior block. Without a prior block, plain +unknown and unsupported preserve the existing durable-owner and liveness decisions. +Service install, repair, start and restart refuse before mutation, including registration staging. +Update gates re-read supervision before stop, package replacement, restoration and recovery; +they refuse even for a separate npm/Bun install. Use the app updater for its bundle, or quit +Desktop before updating the CLI install. Service stop and uninstall remain available. +These decisions follow [runtime ownership](runtime.md#background-service-runtime-ownership). + +The bypass ledger classifies these guards as **early warning**, not enforcement: + +| Guard | Surface / tier | Known limit and residual risk | Pass-through assertion | +| --- | --- | --- | --- | +| Service install/repair/start/restart | CLI before mutation / E3 | Older PATH CLIs, Windows unsupported, or inconclusive evidence without Desktop seen can allow a second supervisor. | With no prior block, none/unsupported preserve ordinary command behavior. | +| Node/Bun update, including pre-stop and recovery | CLI / E3 | Same limits; Desktop can start after the last check inside the stop window, leaving a short race. | None preserves the ordinary update decision. | +| Dashboard restart veto | Server route / E3 | Older runtimes lack this guard. | None preserves the ordinary restart decision. | +| Stop guidance | Notice only / E1 | Desktop may restart the stopped proxy. | `stop --json` neither probes nor emits the notice. | + When ownership is `none`, `src/service/desktop-startup.ts` can report `supervisor: { supervisorPid, runtimePid, app }` with `owned: false`. Restart safety credits this live supervision only when that same app's Start at Login registration is diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index b24c6ad2669..8ecc3eaaf89 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -370,6 +370,16 @@ lease boundary before exiting, and thrown failures release it after owner-aware Replacement and recovery inspect both the captured endpoint and the freshly read runtime record. Malformed or unreadable records remain unknown. Recovery requires the same complete owner identity and proven-dead liveness; unknown or transferred ownership never starts another proxy. +Live supervision is re-read separately from durable ownership before stop, package replacement, +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 +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 +under injected Desktop evidence; production Windows probes remain unsupported. Limits are listed +in [desktop supervision](../desktop-shell.md#runtime-ownership-from-the-apps-side). The lease is released before any service-manager-mediated start (`service repair` in recovery or the post-install refresh): the manager's `ocx start` child cannot join it, and holding it through the repair's health wait keeps that proxy from starting (#5760). The recovery decision @@ -390,7 +400,7 @@ direct start stay serialized with a claim that landed in the unleased window; af swap, a lease that stays claimed is reported with manual recovery steps and a non-zero exit. The dashboard restart worker in `src/update/job.ts` releases the lease immediately before `ocx service repair` and re-acquires it at the direct-start fallthrough, waiting long enough to -outlast one service-wrapper respawn, then re-runs the recorded-owner veto under it before +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. diff --git a/structure/runtime.md b/structure/runtime.md index 47e22da1a6e..4a88b22db4e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -574,24 +574,24 @@ subject and both manager identities; `recordServiceOwner` re-observes and compar the lock, so a mutable shim or downgrade cannot inherit earlier consent. An upgrade is a separate user-authorized action; declining or failing it leaves the app a guest. `ocx service claim` in `src/service/claim.ts` accepts the expected subject and compatibility token and calls `recordServiceOwner` under the ownership mutation lease. The CLI rechecks both before writing; a mismatch writes nothing. The desktop owns the consent prompt and treats `approval-changed`, `manager-still-active`, unreadable stop output and stop-child timeout as terminal before its silence wait or claim. Only a parsed `stopped` result or a validated exit-79 `history-incomplete` result reaches that wait. A stopped but unclaimed runtime is reported as such, without a restoration claim. -The verbs that activate the npm registration refuse on a foreign or unknown owner: -`src/service/repair.ts` stops before it asserts, writes, stops or starts anything, and -`ocx service start` reports the same refusal. `stop` and `uninstall` are not gated, because -they deactivate. `src/update/runtime-ownership.mjs` vetoes both the pre-update stop and the -post-update service refresh for all three update lanes — `src/update/index.ts`, -`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts`. The shared update decision has -three independent authorities: package replacement, runtime stop and service restoration. -Unknown and desktop ownership deny all three because a claim alone does not prove that the live -process is detached from the npm package; CLI ownership permits the ordinary stop-first -flow. Both package updaters use `src/service/install-state-contract.mjs`, backed by the single -`state-record.mjs` parser and authority selector. One mutation lease covers the fresh stop -authorization, the stop child, the current runtime-record re-read and package replacement. +Activating service verbs retain their foreign/unknown durable-owner refusals. +`src/service/desktop-command-guard.ts` separately refuses install, repair, start and restart +before mutation, including registration staging; stop and uninstall remain available. +Each guarded operation uses one `createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: +desktop or unknown with desktopSeen blocks; only a later none clears it. Plain unknown/unsupported cannot clear a block. +`src/update/runtime-ownership.mjs` denies package replacement, stop and service restoration +under blocked supervision, even for an unrelated npm install; foreign/unknown durable ownership also denies all three. +Node and Bun updater gates, failure recovery and the dashboard restart veto re-read supervision at mutation boundaries. +`src/cli/desktop-runtime-guidance.ts` names the supervisor for duplicate starts and accepted restarts; +ordinary stop warns Desktop may restart it, while stop --json skips the probe and notice. Desktop starts its replacement. +Both package updaters use `src/service/install-state-contract.mjs`, backed by the single state-record.mjs parser and authority selector. +One mutation lease still covers fresh authorization, stop, runtime re-read and package replacement. The updater never treats the pre-stop address as proof that this installation is idle; an unreadable current record is unknown, and a valid address is probed even when its recorded PID is gone. Lease delegation is passed only to stop and recovery children, never package manager children. A replacement refusal passes through owner-aware recovery: only the same CLI owner revives the stopped runtime; foreign ownership stays transferred and unknown ownership -remains a reported recovery requirement. Dashboard restart delegates the lease token to its `service repair` child, and releases the lease before that refresh: the service manager spawns `ocx start` outside the worker's process tree with no token, so the managed child must take the lease itself (#5760). Before the direct-start fallthrough kills or spawns on the port, the worker takes the lease back — a refresh that stayed claimed stops the restart — and re-runs the recorded-owner veto under it because a claim could have landed during the unleased refresh window. Direct start holds the same lease through bind plus PID and runtime-address publication. If listener rollback cannot prove the socket closed, the process retains its lease until exit. +remains a reported recovery requirement. Dashboard restart delegates the lease token to its `service repair` child, and releases the lease before that refresh: the service manager spawns `ocx start` outside the worker's process tree with no token, so the managed child must take the lease itself (#5760). Before the direct-start fallthrough kills or spawns on the port, the worker takes the lease back — a refresh that stayed claimed stops the restart — and re-runs the durable-owner and live-supervision veto under it because a claim could have landed during the unleased refresh window. Direct start holds the same lease through bind plus PID and runtime-address publication. If listener rollback cannot prove the socket closed, the process retains its lease until exit. The registration is never deleted; `ocx service install` releases the marker only after the registration succeeds. Busy lease diagnostics name the recorded PID, current liveness, optional executable (omitted by default on Windows), and lease age. The process identity is unverified because a PID may be reused. From f8fa8c54655d00f4b5d75d104f46b227af8f89f5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 9 Oct 2026 13:14:22 +0900 Subject: [PATCH 5/5] test: follow the guarded service calls in source-oracle tests --- tests/server/startup-prompt.test.ts | 2 +- tests/service/launchd-repair.test.ts | 2 +- tests/service/service.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/server/startup-prompt.test.ts b/tests/server/startup-prompt.test.ts index 4a73c1898f5..f576bf54d55 100644 --- a/tests/server/startup-prompt.test.ts +++ b/tests/server/startup-prompt.test.ts @@ -189,7 +189,7 @@ describe("startup star prompt", () => { test("ocx service install gets the prompt too, after the service is up", async () => { const service = await readText("src/service/cli.ts"); - const installIndex = service.indexOf("await installServiceSafely(backend, ops.install)"); + const installIndex = service.indexOf("await installServiceSafely(backend, ops.install, { supervisionLatch })"); const promptIndex = service.indexOf("await maybeShowStarPrompt()"); expect(installIndex).toBeGreaterThan(-1); diff --git a/tests/service/launchd-repair.test.ts b/tests/service/launchd-repair.test.ts index 2dad6b42347..256dcfad75f 100644 --- a/tests/service/launchd-repair.test.ts +++ b/tests/service/launchd-repair.test.ts @@ -1044,7 +1044,7 @@ describe("the surfaces around the repair (#4236 defects 1f, 1h, 2)", () => { // Without the catch, a throw escaped through src/cli/dispatch.ts to the top level and // the one command that can evict a hub never reached its own serving check. expect(branch).toContain("try {"); - expect(branch).toContain("await repairService({ verb });"); + expect(branch).toContain("await repairService({ verb, supervisionLatch });"); expect(branch).toContain("} catch (error) {"); expect(branch).toContain('await reportServiceServing(verb === "restart" ? "restarted" : "repaired", {}, repairError);'); // ONE outcome (#4914). The failure text has to reach the report, because printing it in diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 9439dde4fca..acaa867d865 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -321,7 +321,7 @@ describe("systemd service unit", () => { // condition, `restart` would fall through to the usage error. expect(serviceCommand).toContain('if (command === "repair" || command === "restart") {'); expect(serviceCommand).toContain('const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";'); - expect(serviceCommand).toContain("await repairService({ verb });"); + expect(serviceCommand).toContain("await repairService({ verb, supervisionLatch });"); }); test("Windows install presence distinguishes unknown queries from proven absence", () => { @@ -2467,9 +2467,9 @@ describe("service lifecycle cleanup ordering", () => { const service = await readText("src/service/cli.ts"); const installCase = service.slice(service.indexOf('case "install":'), service.indexOf('case "start":')); expect(installCase).toContain('scheduler.status === "absent"'); - expect(installCase).toContain("await installFreshWindowsSchedulerSafely()"); + expect(installCase).toContain("await installFreshWindowsSchedulerSafely({ supervisionLatch })"); expect(installCase.indexOf('scheduler.status === "absent"')).toBeLessThan( - installCase.indexOf("await installFreshWindowsSchedulerSafely()"), + installCase.indexOf("await installFreshWindowsSchedulerSafely({ supervisionLatch })"), ); });