diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index ccf2bc18e..23bd49d13 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -282,6 +282,13 @@ automatically. If that fallback cannot determine the token state, it retains the error. Foreign tasks and operations can never emit the automatic-elevation marker. Approve the dashboard UAC prompt or rerun `ocx service install` in an elevated PowerShell window. +For a fresh install where the OpenCodex scheduler task is confirmed absent, UAC approval now +happens before the installer stops any existing proxy. The task is registered without being run; +only after registration succeeds does OpenCodex stop the old listener, publish the service assets, +and start the scheduled task. Cancelling or denying UAC therefore leaves the working proxy and its +Codex routing in place. Existing or conflicting scheduler registrations continue to fail closed +rather than being deleted as an unsafe best-effort rollback. + ### `ocx codex-shim ` Wrap a script-based `codex` launcher on PATH with a lightweight autostart script. Real `codex.exe` diff --git a/src/server/startup-action-control.ts b/src/server/startup-action-control.ts index 08b81f71a..a3165a85e 100644 --- a/src/server/startup-action-control.ts +++ b/src/server/startup-action-control.ts @@ -164,10 +164,17 @@ function runCliInstall( const cli = join(import.meta.dir, "..", "cli", "index.ts"); const argv = [cli, ...startupInstallArgv(action, options)]; return new Promise((resolve, reject) => { + const timeout = process.platform === "win32" && action === "install-service" + ? 0 + : 60_000; execFile(bun, argv, { encoding: "utf8", env: process.env, - timeout: 60_000, + // A fresh Windows scheduler install now owns its UAC prompt inside this CLI + // transaction. Killing only the CLI at 60s can orphan its elevated schtasks child, + // which may register the task after the Dashboard has reported failure. Keep the + // async request/attempt lock alive until Windows returns approval or cancellation. + timeout, windowsHide: true, maxBuffer: 256 * 1024, }, (error, stdout, stderr) => { diff --git a/src/service.ts b/src/service.ts index d976df203..fb5508ac5 100644 --- a/src/service.ts +++ b/src/service.ts @@ -29,6 +29,7 @@ import { runWindowsElevated, toWindowsSchtasksError, WindowsElevationError, + WindowsSchtasksError, type ElevatedSchedulerOutcome, type ElevatedSchtasksCreateAndRunExecution, type ElevatedSchtasksCreateAndRunResult, @@ -1506,6 +1507,11 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath return ["/create", "/tn", TASK, "/xml", xml, "/f"]; } +/** Build the fixed scheduler-create command from an explicit staged XML document. */ +export function buildWindowsSchtasksCreateArgsForXml(xml: string): string[] { + return ["/create", "/tn", TASK, "/xml", xml, "/f"]; +} + /** * VBS launcher that starts the batch wrapper with a hidden window (style 0). * bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the @@ -1823,6 +1829,84 @@ function writeWindowsSchedulerAssets(): void { writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le"); } +function stageWindowsSchedulerRegistrationXml(): string { + if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + const path = join(getConfigDir(), `.opencodex-service-task.${randomUUID()}.xml`); + // This document points at the canonical launcher but does not publish or rewrite that + // launcher. UAC can therefore be refused while the current proxy still owns its port. + writeServiceAssetWithRetry( + path, + `\uFEFF${buildWindowsTaskXml(windowsServiceScriptPath(), windowsLauncherVbsPath())}`, + "utf16le", + ); + return path; +} + +export interface FreshWindowsSchedulerRegistrationDeps { + create?: (args: string[]) => void; + elevate?: (args: string[]) => Promise; + probe?: () => WindowsSchedulerTaskProbe; + queryXml?: () => string; + rollback?: () => Promise; +} + +export async function registerFreshWindowsSchedulerTask( + xmlPath: string, + deps: FreshWindowsSchedulerRegistrationDeps = {}, +): Promise { + const args = buildWindowsSchtasksCreateArgsForXml(xmlPath); + try { + (deps.create ?? schtasks)(args); + } catch (error) { + if ( + !(error instanceof WindowsSchtasksError) + || error.operation !== "create" + || error.reason !== "access-denied" + ) { + throw error; + } + // The elevated command is still the fixed trusted schtasks executable plus the + // owned create shape. It registers only; the task is not run until cleanup commits. + await (deps.elevate ?? elevateSchtasks)(args); + } + + const rollbackTask = deps.rollback ?? (() => rollbackElevatedSchedulerTask(TASK)); + const probe = (deps.probe ?? (() => probeWindowsSchedulerTask(TASK)))(); + if (probe.status === "absent") { + throw new Error("Task Scheduler reported success, but the new registration is absent; no service cleanup was started."); + } + if (probe.status === "unknown") { + const rollback = await rollbackTask(); + throw new Error( + `Task Scheduler registration was not verifiably present after create (${probe.detail}).` + + (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."), + ); + } + + let registeredXml = ""; + let queryDetail: string | null = null; + try { + registeredXml = (deps.queryXml ?? (() => querySchtasks(["/query", "/tn", TASK, "/xml"])))(); + } catch (error) { + queryDetail = error instanceof Error ? error.message : String(error); + } + if (!registeredXml.trim()) { + const rollback = await rollbackTask(); + throw new Error( + "Task Scheduler registration was created, but its live XML could not be verified." + + (queryDetail ? ` Query failed: ${queryDetail}` : " The query returned an empty document.") + + (rollback ? ` Cleanup also failed: ${rollback}` : " The unverified registration was rolled back."), + ); + } + if (!windowsTaskRegistrationHealthy(registeredXml)) { + const rollback = await rollbackTask(); + throw new Error( + "Task Scheduler registration was created but failed the OpenCodex action/trigger verification." + + (rollback ? ` Cleanup also failed: ${rollback}` : " The invalid registration was rolled back."), + ); + } +} + function installWindows(): void { recordOwnedConfigPath(getConfigDir(), serviceStatePath()); // Transactional backend switch: installing the scheduler backend removes a native @@ -2437,6 +2521,82 @@ export async function installServiceSafely( await install(); } +export interface FreshWindowsSchedulerInstallDeps { + stageRegistrationXml?: () => string; + register?: (xmlPath: string) => Promise; + prepare?: () => Promise; + publishAssets?: () => void; + runTask?: () => void; + writeState?: () => void; + rollbackTask?: () => Promise; + removeStagedXml?: (xmlPath: string) => void; +} + +/** + * Fresh Windows scheduler install with UAC before the destructive commit. + * + * The registration is created but never run before `prepare`: UAC cancellation and + * create failure therefore cannot stop the existing proxy or trigger its native-routing + * cleanup. This path is used only after Task Scheduler absence was proved, so rollback + * can delete the exact registration this attempt created without touching prior state. + */ +export async function installFreshWindowsSchedulerSafely( + deps: FreshWindowsSchedulerInstallDeps = {}, +): Promise { + const stage = deps.stageRegistrationXml ?? stageWindowsSchedulerRegistrationXml; + const register = deps.register ?? registerFreshWindowsSchedulerTask; + const prepare = deps.prepare ?? (() => prepareServiceInstall("scheduler")); + const publishAssets = deps.publishAssets ?? writeWindowsSchedulerAssets; + const runTask = deps.runTask ?? startWindows; + const writeState = deps.writeState ?? (() => writeServiceInstallState("scheduler")); + const rollbackTask = deps.rollbackTask ?? (() => rollbackElevatedSchedulerTask(TASK)); + const removeStagedXml = deps.removeStagedXml ?? ((path: string) => { + if (existsSync(path)) unlinkSync(path); + }); + + let stagedXml: string | null = null; + let registered = false; + let started = false; + try { + stagedXml = stage(); + await register(stagedXml); + registered = true; + + // The destructive boundary begins only after Task Scheduler accepted the definition. + await prepare(); + publishAssets(); + runTask(); + started = true; + writeState(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + if (registered && !started) { + const rollback = await rollbackTask(); + throw new Error( + `${detail}\n` + + (rollback + ? `The new Task Scheduler registration may remain: ${rollback}` + : "The new Task Scheduler registration was rolled back. The previous proxy/routing state was not assumed restored."), + ); + } + if (started) { + throw new Error( + `${detail}\nThe scheduler task started, but install state was not published. ` + + "The task was left in place; inspect `ocx service status` before retrying.", + ); + } + throw error; + } finally { + if (stagedXml) { + try { removeStagedXml(stagedXml); } catch (error) { + console.error( + `⚠️ Failed to remove temporary Task Scheduler XML ${stagedXml}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } +} + /** * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`. * Returns true if a service was found and stopped. @@ -2812,7 +2972,19 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { }); describe("service lifecycle cleanup ordering", () => { + test("fresh registration elevates only the fixed create after a structured denial", async () => { + const calls: string[] = []; + const stagedXml = "C:\\Users\\x\\.opencodex\\attempt.xml"; + const expectedArgs = buildWindowsSchtasksCreateArgsForXml(stagedXml); + await registerFreshWindowsSchedulerTask(stagedXml, { + create: args => { + calls.push(`create:${args.join(" ")}`); + throw new WindowsSchtasksError("create", "access-denied", "denied"); + }, + elevate: async args => { calls.push(`elevate:${args.join(" ")}`); }, + probe: () => ({ status: "present", detail: "present" }), + queryXml: () => buildWindowsTaskXml(), + rollback: async () => { calls.push("rollback"); return null; }, + }); + + expect(calls).toEqual([ + `create:${expectedArgs.join(" ")}`, + `elevate:${expectedArgs.join(" ")}`, + ]); + }); + + test("fresh registration UAC denial returns before task probing or cleanup", async () => { + const calls: string[] = []; + await expect(registerFreshWindowsSchedulerTask("attempt.xml", { + create: () => { + calls.push("create"); + throw new WindowsSchtasksError("create", "access-denied", "denied"); + }, + elevate: async () => { calls.push("elevate"); throw new Error("UAC cancelled"); }, + probe: () => { calls.push("probe"); return { status: "present", detail: "present" }; }, + queryXml: () => { calls.push("query"); return buildWindowsTaskXml(); }, + rollback: async () => { calls.push("rollback"); return null; }, + })).rejects.toThrow("UAC cancelled"); + + expect(calls).toEqual(["create", "elevate"]); + }); + + test("fresh registration never elevates an unstructured scheduler failure", async () => { + const calls: string[] = []; + await expect(registerFreshWindowsSchedulerTask("attempt.xml", { + create: () => { calls.push("create"); throw new Error("scheduler unavailable"); }, + elevate: async () => { calls.push("elevate"); }, + probe: () => { calls.push("probe"); return { status: "present", detail: "present" }; }, + queryXml: () => buildWindowsTaskXml(), + rollback: async () => { calls.push("rollback"); return null; }, + })).rejects.toThrow("scheduler unavailable"); + + expect(calls).toEqual(["create"]); + }); + + test("create success followed by proven absence does not request a pointless rollback UAC", async () => { + const calls: string[] = []; + await expect(registerFreshWindowsSchedulerTask("attempt.xml", { + create: () => { calls.push("create"); }, + elevate: async () => { calls.push("elevate"); }, + probe: () => { calls.push("probe"); return { status: "absent", detail: "absent" }; }, + queryXml: () => { calls.push("query"); return buildWindowsTaskXml(); }, + rollback: async () => { calls.push("rollback"); return null; }, + })).rejects.toThrow(/registration is absent/); + + expect(calls).toEqual(["create", "probe"]); + }); + + test("fresh registration requires the live Task Scheduler XML before cleanup can begin", async () => { + const calls: string[] = []; + await expect(registerFreshWindowsSchedulerTask("attempt.xml", { + create: () => { calls.push("create"); }, + probe: () => { calls.push("probe"); return { status: "present", detail: "present" }; }, + queryXml: () => { calls.push("query"); throw new Error("query denied"); }, + rollback: async () => { calls.push("rollback"); return null; }, + })).rejects.toThrow(/live XML could not be verified/); + + expect(calls).toEqual(["create", "probe", "query", "rollback"]); + }); + + test("fresh Windows scheduler install gets registration approval before destructive cleanup", async () => { + const calls: string[] = []; + await installFreshWindowsSchedulerSafely({ + stageRegistrationXml: () => { calls.push("stage"); return "attempt.xml"; }, + register: async path => { calls.push(`register:${path}`); }, + prepare: async () => { calls.push("prepare:stop-managers-and-proxy"); }, + publishAssets: () => { calls.push("publish-assets"); }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: path => { calls.push(`remove:${path}`); }, + }); + + expect(calls).toEqual([ + "stage", + "register:attempt.xml", + "prepare:stop-managers-and-proxy", + "publish-assets", + "run-task", + "write-state", + "remove:attempt.xml", + ]); + }); + + test("UAC cancellation removes only staged XML and never enters cleanup or asset publication", async () => { + const calls: string[] = []; + mkdirSync(TEST_DIR, { recursive: true }); + const routingPath = join(TEST_DIR, "config.toml"); + const routingBefore = 'openai_base_url = "http://127.0.0.1:10100/v1"\nmodel_catalog_json = "keep.json"\n'; + writeFileSync(routingPath, routingBefore, "utf8"); + await expect(installFreshWindowsSchedulerSafely({ + stageRegistrationXml: () => { calls.push("stage"); return "attempt.xml"; }, + register: async path => { + calls.push(`register:${path}`); + throw new Error("UAC prompt was cancelled"); + }, + prepare: async () => { calls.push("prepare"); }, + publishAssets: () => { calls.push("publish-assets"); }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: path => { calls.push(`remove:${path}`); }, + })).rejects.toThrow("UAC prompt was cancelled"); + + expect(calls).toEqual([ + "stage", + "register:attempt.xml", + "remove:attempt.xml", + ]); + expect(readFileSync(routingPath, "utf8")).toBe(routingBefore); + }); + + test("a pre-run commit failure rolls back only the newly-created registration", async () => { + const calls: string[] = []; + await expect(installFreshWindowsSchedulerSafely({ + stageRegistrationXml: () => "attempt.xml", + register: async () => { calls.push("register"); }, + prepare: async () => { calls.push("prepare"); throw new Error("standalone stop failed"); }, + publishAssets: () => { calls.push("publish-assets"); }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: () => { calls.push("remove-stage"); }, + })).rejects.toThrow(/previous proxy\/routing state was not assumed restored/); + + expect(calls).toEqual(["register", "prepare", "rollback-task", "remove-stage"]); + }); + + test("a state-write failure leaves the already-started task for explicit diagnosis", async () => { + const calls: string[] = []; + await expect(installFreshWindowsSchedulerSafely({ + stageRegistrationXml: () => "attempt.xml", + register: async () => { calls.push("register"); }, + prepare: async () => { calls.push("prepare"); }, + publishAssets: () => { calls.push("publish-assets"); }, + runTask: () => { calls.push("run-task"); }, + writeState: () => { calls.push("write-state"); throw new Error("state write failed"); }, + rollbackTask: async () => { calls.push("rollback-task"); return null; }, + removeStagedXml: () => { calls.push("remove-stage"); }, + })).rejects.toThrow(/task was left in place/); + + expect(calls).toEqual([ + "register", + "prepare", + "publish-assets", + "run-task", + "write-state", + "remove-stage", + ]); + }); + test("service install stops the recorded backend, requested backend, and standalone before loading assets", async () => { const calls: string[] = []; const managerOps = (backend: "scheduler" | "native") => ({ @@ -763,6 +930,16 @@ describe("service lifecycle cleanup ordering", () => { expect(service).toContain('code !== "EBUSY" && code !== "EPERM" && code !== "EACCES"'); }); + test("fresh Windows scheduler wiring selects the pre-registration transaction", async () => { + const service = await readText("src/service.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.indexOf('scheduler.status === "absent"')).toBeLessThan( + installCase.indexOf("await installFreshWindowsSchedulerSafely()"), + ); + }); + test("Windows service uninstall verifies task deletion before removing assets", async () => { const service = await readText("src/service.ts"); const uninstallWindows = service.slice(service.indexOf("function uninstallWindows()"), service.indexOf("function serviceDiagnosticsSummary()")); diff --git a/tests/startup-action-control-elevation.test.ts b/tests/startup-action-control-elevation.test.ts index f89d4dfed..cb249cb7e 100644 --- a/tests/startup-action-control-elevation.test.ts +++ b/tests/startup-action-control-elevation.test.ts @@ -68,6 +68,24 @@ describe("startup install elevation retry", () => { }); } + test("a CLI-completed two-phase scheduler install does not enter the legacy Dashboard finalizer", async () => { + execFileMock.mockImplementation(( + _file: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout?: string, stderr?: string) => void, + ) => { + callback(null, "service installed", ""); + }); + + await expect(runStartupInstallAction("install-service")).resolves.toEqual({ + message: "Background service installed.", + }); + expect(finalizeMock).not.toHaveBeenCalled(); + expect(getStartupInstallState().status).toBe("idle"); + expect((execFileMock.mock.calls[0]![2] as { timeout?: number }).timeout).toBe(0); + }); + test("retries only for structured schtasks /create access denied", async () => { failCli(`Windows access denied while running Task Scheduler.\n${WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER}`);