-
Notifications
You must be signed in to change notification settings - Fork 715
fix(windows): preflight scheduler registration before teardown #1465
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"]; | ||
| } | ||
|
Comment on lines
+1510
to
+1513
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Let Lines 1505-1508 and lines 1511-1513 now build the same argument array. Two copies of the fixed create shape can drift, and the security argument in the code comments depends on the shape staying identical everywhere. Keep one definition. ♻️ Proposed refactor export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
- return ["/create", "/tn", TASK, "/xml", xml, "/f"];
+ return buildWindowsSchtasksCreateArgsForXml(xml);
}Move 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * 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<void>; | ||
| probe?: () => WindowsSchedulerTaskProbe; | ||
| queryXml?: () => string; | ||
| rollback?: () => Promise<string | null>; | ||
| } | ||
|
|
||
| export async function registerFreshWindowsSchedulerTask( | ||
| xmlPath: string, | ||
| deps: FreshWindowsSchedulerRegistrationDeps = {}, | ||
| ): Promise<void> { | ||
| 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<void>; | ||
| prepare?: () => Promise<void>; | ||
| publishAssets?: () => void; | ||
| runTask?: () => void; | ||
| writeState?: () => void; | ||
| rollbackTask?: () => Promise<string | null>; | ||
| 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<void> { | ||
| 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(); | ||
|
Comment on lines
+2543
to
+2570
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift The fresh path skips the native (WinSW) backend switch and the owned-path record.
The new dispatch at lines 2980-2981 calls Add the WinSW switch and the ownership record to the transactional path, before 🐛 Sketch of the missing phase let stagedXml: string | null = null;
let registered = false;
let started = false;
try {
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
+ // Two live managers would both respawn the proxy; remove the native backend
+ // before the scheduler backend is registered.
+ removeNativeServiceBeforeSchedulerSwitch();
stagedXml = stage();
await register(stagedXml);
registered = true;Expose the WinSW removal block of #!/bin/bash
# Check whether prepareServiceInstall already removes the WinSW service and records the owned path.
ast-grep run --pattern 'export async function prepareServiceInstall($$$) { $$$ }' --lang typescript src/service.ts
rg -nP -C 5 'uninstallWinswService|statusWinswRaw|recordOwnedConfigPath' --type=ts src🤖 Prompt for AI Agents |
||
| } 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<v | |
| // restart-loops on EADDRINUSE, and the old standalone process makes the install | ||
| // verification report a false success. | ||
| try { | ||
| await installServiceSafely(backend, ops.install); | ||
| if (process.platform === "win32" && backend === "scheduler") { | ||
| const scheduler = probeWindowsSchedulerTask(TASK); | ||
| if (scheduler.status === "unknown") { | ||
| throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`); | ||
| } | ||
| if (scheduler.status === "absent") { | ||
| await installFreshWindowsSchedulerSafely(); | ||
| } else { | ||
| await installServiceSafely(backend, ops.install); | ||
| } | ||
| } else { | ||
| await installServiceSafely(backend, ops.install); | ||
| } | ||
| } catch (error) { | ||
| console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`); | ||
| process.exitCode = 1; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
The disabled timeout has no upper bound, and the test pins that value. Both sites follow from one decision: replacing the 60-second bound with
0rather than with a longer bound. An unanswered UAC prompt therefore leavesinstallStateinrunningforever and blocks every later install attempt, and the test locks the literal0in place so the source cannot be corrected without touching it.src/server/startup-action-control.ts#L167-L179: replace0with a named long bound, for exampleWINDOWS_ELEVATED_INSTALL_TIMEOUT_MS, and classify its expiry as the existingindeterminatestate defined at lines 27-34.tests/startup-action-control-elevation.test.ts#L81-L86: assert against the exported bound constant instead of the literal0, and add a case that an expired elevated install ends inindeterminaterather thanrunning.📍 Affects 2 files
src/server/startup-action-control.ts#L167-L179(this comment)tests/startup-action-control-elevation.test.ts#L81-L86🤖 Prompt for AI Agents