diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 5c00e47af..42a5f9553 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -165,6 +165,12 @@ function runWinsw(args: string[]): string { /** `install /p` prompts for the service-account password on the console — stdin must be inherited. */ function runWinswInteractive(args: string[]): void { + if (!process.stdin.isTTY) { + throw new Error( + "WinSW install requires an interactive console to prompt for the service account password. " + + "Run `ocx service install --native` from an elevated Command Prompt or PowerShell window, not a hidden or piped session.", + ); + } execFileSync(winswExePath(), args, { stdio: "inherit" }); } diff --git a/src/service.ts b/src/service.ts index b38305fa1..fc64d4c4c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,6 +6,7 @@ * restore it via the command. */ import { execFileSync, execSync } from "node:child_process"; +import { findLiveProxy } from "./server/proxy-liveness"; import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -1363,7 +1364,38 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise * scheduler backend first; on failure the machine is left with NO service (explicitly * reported) — never a silent fallback to the scheduler. */ +/** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */ +export function assertWindowsNativeServiceAccountSupported(): void { + if (process.platform !== "win32") return; + const source = readWindowsPrincipalSource(); + if (source?.toLowerCase() === "microsoftaccount") { + throw new Error( + "The native (WinSW) service backend cannot run under a Microsoft-account Windows login. " + + "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`.", + ); + } +} + +function readWindowsPrincipalSource(): string | null { + if (process.platform !== "win32") return null; + const ps = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + if (!existsSync(ps)) return null; + try { + const out = execFileSync(ps, [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource", + ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim(); + return out || null; + } catch { + return null; + } +} + async function installWindowsNative(): Promise { + assertWindowsNativeServiceAccountSupported(); recordOwnedConfigPath(getConfigDir(), serviceStatePath()); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeServiceApiTokenFile(); @@ -1402,7 +1434,23 @@ function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } } function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } } function uninstallWindows(): void { - try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ } + const probe = probeWindowsSchedulerTask(TASK); + if (probe.status === "present") { + try { + schtasks(["/delete", "/tn", TASK, "/f"]); + } catch (error) { + throw new Error(`Failed to delete Task Scheduler task ${TASK}: ${error instanceof Error ? error.message : String(error)}`); + } + const afterDelete = probeWindowsSchedulerTask(TASK); + if (afterDelete.status === "present") { + throw new Error(`Task Scheduler task ${TASK} is still present after delete — refusing to remove service assets. Retry from an elevated shell.`); + } + if (afterDelete.status === "unknown") { + throw new Error(`Task Scheduler task ${TASK} presence could not be verified after delete — refusing to remove service assets.`); + } + } else if (probe.status === "unknown") { + throw new Error(`Task Scheduler task ${TASK} presence could not be verified — refusing to remove service assets.`); + } if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath()); if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath()); if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath()); @@ -1562,17 +1610,29 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null { type TrackedProxyCleanupResult = "none" | "stale" | "stopped"; async function stopTrackedProxyIfRunning(): Promise { + let stopped = false; const pid = readPid(); - if (!pid) return "none"; - if (!isProcessAlive(pid)) { + if (pid && isProcessAlive(pid)) { + await stopProxy(pid); removePid(pid); removeRuntimePort(pid); - return "stale"; + stopped = true; + } else if (pid) { + removePid(pid); + removeRuntimePort(pid); + } + // Orphan recovery: the pid file can be missing/stale while the service wrapper keeps + // a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback. + const live = await findLiveProxy({ timeoutMs: 1500 }); + if (live?.pid) { + await stopProxy(live.pid); + removePid(live.pid); + removeRuntimePort(live.pid); + stopped = true; } - await stopProxy(pid); - removePid(pid); - removeRuntimePort(pid); - return "stopped"; + if (stopped) return "stopped"; + if (pid) return "stale"; + return "none"; } async function stopTrackedProxyForServiceCommand(): Promise { @@ -1886,6 +1946,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { expect(service).toContain('code !== "EBUSY" && code !== "EPERM" && code !== "EACCES"'); }); - test("Windows service uninstall removes generated task XML", async () => { + 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()")); + expect(uninstallWindows).toContain("probeWindowsSchedulerTask(TASK)"); expect(uninstallWindows).toContain("windowsServiceScriptPath()"); expect(uninstallWindows).toContain("windowsTaskXmlPath()"); expect(uninstallWindows).toContain("unlinkSync(windowsTaskXmlPath())"); + expect(uninstallWindows).toContain("refusing to remove service assets"); }); - test("service cleanup stops gracefully first via the shared stopper and clears the pid file", async () => { + test("service cleanup falls back to findLiveProxy and clears the pid file", async () => { const service = await readText("src/service.ts"); expect(service).toContain('import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";'); expect(service).toContain("removeRuntimePort(pid);"); expect(service).toContain('import { isProcessAlive, stopProxy } from "./lib/process-control";'); + expect(service).toContain('import { findLiveProxy } from "./server/proxy-liveness";'); expect(service).toContain('type TrackedProxyCleanupResult = "none" | "stale" | "stopped";'); expect(service).toContain("async function stopTrackedProxyIfRunning(): Promise"); - expect(service).toContain('if (!pid) return "none";'); - expect(service).toContain("if (!isProcessAlive(pid))"); - expect(service).toContain('return "stale";'); + expect(service).toContain("await findLiveProxy({ timeoutMs: 1500 })"); expect(service).toContain("await stopProxy(pid);"); expect(service).toContain("removePid(pid);"); expect(service).toContain('return "stopped";'); }); + test("service stop refuses success while the proxy is still live", async () => { + const service = await readText("src/service.ts"); + const stopCase = service.slice(service.indexOf('case "stop":'), service.indexOf('case "status":')); + expect(stopCase).toContain("await findLiveProxy({ timeoutMs: 1500 })"); + expect(stopCase).toContain("Service stop did not terminate the proxy"); + expect(stopCase).toContain("process.exit(1)"); + }); + + test("native install refuses Microsoft-account logins before removing the scheduler backend", async () => { + const service = await readText("src/service.ts"); + const installNative = service.slice(service.indexOf("async function installWindowsNative()"), service.indexOf("function startWindows()")); + expect(installNative.indexOf("assertWindowsNativeServiceAccountSupported()")).toBeLessThan(installNative.indexOf("uninstallWindows()")); + expect(service).toContain("Microsoft-account Windows login"); + }); + test("service command cleanup logs kill failures without skipping restore/delete", async () => { const service = await readText("src/service.ts"); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index 2f728a8b8..8c922d095 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -165,6 +165,13 @@ describe("winsw install flow", () => { expect(calls).toEqual([["interactive", "install", "/p"], ["verify"], ["run", "start"]]); }); + test("install /p refuses non-interactive stdin instead of hanging", () => { + const winsw = readFileSync(new URL("../src/lib/winsw.ts", import.meta.url), "utf8"); + const fn = winsw.slice(winsw.indexOf("function runWinswInteractive"), winsw.indexOf("function scQc()")); + expect(fn).toContain("process.stdin.isTTY"); + expect(fn).toContain("interactive console"); + }); + test("repair over an existing service rewrites assets and restarts without re-prompting", async () => { const calls: string[][] = []; await installWinswService(entry, {