66 * restore it via the command.
77 */
88import { execFileSync , execSync } from "node:child_process" ;
9+ import { findLiveProxy } from "./server/proxy-liveness" ;
910import { chmodSync , existsSync , mkdirSync , readFileSync , unlinkSync , writeFileSync } from "node:fs" ;
1011import { homedir } from "node:os" ;
1112import { dirname , join , resolve } from "node:path" ;
12- import { expandUserPath , getConfigDir , readPid , removePid , removeRuntimePort } from "./config" ;
13+ import { expandUserPath , getConfigDir , readPid , removePid , removeRuntimePort , verifyPidIdentity } from "./config" ;
1314import { loadConfig } from "./config" ;
1415import { restoreNativeCodex } from "./codex/inject" ;
1516import { stripGrokConfig } from "./grok/inject" ;
1617import { isWslRuntime } from "./codex/home" ;
1718import { durableBunPath , durableBunRuntime } from "./lib/bun-runtime" ;
1819import { isProcessAlive , stopProxy } from "./lib/process-control" ;
1920import { serviceApiTokenFilePath } from "./lib/service-secrets" ;
20- import { findLiveProxy } from "./server/proxy-liveness" ;
2121import { randomUUID } from "node:crypto" ;
2222import {
2323 ELEVATION_REQUEST_TIMEOUT_MS ,
@@ -1428,7 +1428,38 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
14281428 * scheduler backend first; on failure the machine is left with NO service (explicitly
14291429 * reported) — never a silent fallback to the scheduler.
14301430 */
1431+ /** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */
1432+ export function assertWindowsNativeServiceAccountSupported ( ) : void {
1433+ if ( process . platform !== "win32" ) return ;
1434+ const source = readWindowsPrincipalSource ( ) ;
1435+ if ( source ?. toLowerCase ( ) === "microsoftaccount" ) {
1436+ throw new Error (
1437+ "The native (WinSW) service backend cannot run under a Microsoft-account Windows login. "
1438+ + "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`." ,
1439+ ) ;
1440+ }
1441+ }
1442+
1443+ function readWindowsPrincipalSource ( ) : string | null {
1444+ if ( process . platform !== "win32" ) return null ;
1445+ const ps = join ( process . env . SystemRoot ?? "C:\\Windows" , "System32" , "WindowsPowerShell" , "v1.0" , "powershell.exe" ) ;
1446+ if ( ! existsSync ( ps ) ) return null ;
1447+ try {
1448+ const out = execFileSync ( ps , [
1449+ "-NoLogo" ,
1450+ "-NoProfile" ,
1451+ "-NonInteractive" ,
1452+ "-Command" ,
1453+ "(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource" ,
1454+ ] , { encoding : "utf8" , stdio : [ "ignore" , "pipe" , "pipe" ] , windowsHide : true } ) . trim ( ) ;
1455+ return out || null ;
1456+ } catch {
1457+ return null ;
1458+ }
1459+ }
1460+
14311461async function installWindowsNative ( ) : Promise < void > {
1462+ assertWindowsNativeServiceAccountSupported ( ) ;
14321463 recordOwnedConfigPath ( getConfigDir ( ) , serviceStatePath ( ) ) ;
14331464 if ( ! existsSync ( getConfigDir ( ) ) ) mkdirSync ( getConfigDir ( ) , { recursive : true } ) ;
14341465 writeServiceApiTokenFile ( ) ;
@@ -1463,11 +1494,49 @@ async function installWindowsNative(): Promise<void> {
14631494 writeServiceInstallState ( "native" ) ;
14641495}
14651496function startWindows ( ) : void { schtasks ( [ "/run" , "/tn" , TASK ] ) ; }
1466- function stopWindows ( ) : void { try { schtasks ( [ "/end" , "/tn" , TASK ] ) ; } catch { /* not running */ } }
1497+
1498+ export function isWindowsSchedulerEndBenign ( error : unknown ) : boolean {
1499+ const detail = schtasksErrorDetail ( error ) . toLowerCase ( ) ;
1500+ return detail . includes ( "no running instance" )
1501+ || detail . includes ( "not currently running" )
1502+ || detail . includes ( "0x41330" ) ;
1503+ }
1504+
1505+ /**
1506+ * End the scheduler task. "Already stopped" is success; other `/end` failures are
1507+ * swallowed so callers can still run tracked-proxy + live-proxy cleanup.
1508+ *
1509+ * Do not key a restart-window wait on `/end` failure: the #764 case is an `/end`
1510+ * that *succeeds* while the wrapper survives and respawns. That verification lives
1511+ * on the stop-verification path (poll across the restart window), not here.
1512+ */
1513+ export function stopWindows ( ) : void {
1514+ try {
1515+ schtasks ( [ "/end" , "/tn" , TASK ] ) ;
1516+ } catch ( error ) {
1517+ if ( isWindowsSchedulerEndBenign ( error ) ) return ;
1518+ }
1519+ }
14671520function statusWindows ( ) : string { try { return schtasks ( [ "/query" , "/tn" , TASK ] ) ; } catch { return "" ; } }
14681521function statusWindowsXml ( ) : string { try { return schtasks ( [ "/query" , "/tn" , TASK , "/xml" ] ) ; } catch { return "" ; } }
14691522function uninstallWindows ( ) : void {
1470- try { schtasks ( [ "/delete" , "/tn" , TASK , "/f" ] ) ; } catch { /* absent */ }
1523+ const probe = probeWindowsSchedulerTask ( TASK ) ;
1524+ if ( probe . status === "present" ) {
1525+ try {
1526+ schtasks ( [ "/delete" , "/tn" , TASK , "/f" ] ) ;
1527+ } catch ( error ) {
1528+ throw new Error ( `Failed to delete Task Scheduler task ${ TASK } : ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
1529+ }
1530+ const afterDelete = probeWindowsSchedulerTask ( TASK ) ;
1531+ if ( afterDelete . status === "present" ) {
1532+ throw new Error ( `Task Scheduler task ${ TASK } is still present after delete — refusing to remove service assets. Retry from an elevated shell.` ) ;
1533+ }
1534+ if ( afterDelete . status === "unknown" ) {
1535+ throw new Error ( `Task Scheduler task ${ TASK } presence could not be verified after delete — refusing to remove service assets.` ) ;
1536+ }
1537+ } else if ( probe . status === "unknown" ) {
1538+ throw new Error ( `Task Scheduler task ${ TASK } presence could not be verified — refusing to remove service assets.` ) ;
1539+ }
14711540 if ( existsSync ( windowsServiceScriptPath ( ) ) ) unlinkSync ( windowsServiceScriptPath ( ) ) ;
14721541 if ( existsSync ( windowsLauncherVbsPath ( ) ) ) unlinkSync ( windowsLauncherVbsPath ( ) ) ;
14731542 if ( existsSync ( windowsTaskXmlPath ( ) ) ) unlinkSync ( windowsTaskXmlPath ( ) ) ;
@@ -1626,6 +1695,12 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
16261695
16271696type TrackedProxyCleanupResult = "none" | "stale" | "stopped" ;
16281697
1698+ function verifiedKillTarget ( pid : number | null | undefined ) : number | null {
1699+ if ( typeof pid !== "number" || ! Number . isSafeInteger ( pid ) || pid <= 0 ) return null ;
1700+ const verified = verifyPidIdentity ( pid ) ;
1701+ return verified === pid ? verified : null ;
1702+ }
1703+
16291704/**
16301705 * Whether a proxy is still answering after the service manager claimed to stop it.
16311706 *
@@ -1666,17 +1741,31 @@ export async function proxyStillLiveAfterStop(deps: {
16661741}
16671742
16681743async function stopTrackedProxyIfRunning ( ) : Promise < TrackedProxyCleanupResult > {
1744+ let stopped = false ;
16691745 const pid = readPid ( ) ;
1670- if ( ! pid ) return "none" ;
1671- if ( ! isProcessAlive ( pid ) ) {
1746+ const trackedKillPid = verifiedKillTarget ( pid ) ;
1747+ if ( trackedKillPid !== null && isProcessAlive ( trackedKillPid ) ) {
1748+ await stopProxy ( trackedKillPid ) ;
1749+ removePid ( trackedKillPid ) ;
1750+ removeRuntimePort ( trackedKillPid ) ;
1751+ stopped = true ;
1752+ } else if ( pid ) {
16721753 removePid ( pid ) ;
16731754 removeRuntimePort ( pid ) ;
1674- return "stale" ;
16751755 }
1676- await stopProxy ( pid ) ;
1677- removePid ( pid ) ;
1678- removeRuntimePort ( pid ) ;
1679- return "stopped" ;
1756+ // Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
1757+ // a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
1758+ const live = await findLiveProxy ( { timeoutMs : 1500 } ) ;
1759+ const liveKillPid = verifiedKillTarget ( live ?. pid ) ;
1760+ if ( liveKillPid !== null ) {
1761+ await stopProxy ( liveKillPid ) ;
1762+ removePid ( liveKillPid ) ;
1763+ removeRuntimePort ( liveKillPid ) ;
1764+ stopped = true ;
1765+ }
1766+ if ( stopped ) return "stopped" ;
1767+ if ( pid ) return "stale" ;
1768+ return "none" ;
16801769}
16811770
16821771async function stopTrackedProxyForServiceCommand ( ) : Promise < TrackedProxyCleanupResult > {
@@ -1984,11 +2073,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
19842073 ops . start ( ) ;
19852074 console . log ( "✅ service started." ) ;
19862075 break ;
1987- case "stop" :
2076+ case "stop" : {
19882077 assertServiceEnvironmentMatchesInstall ( ) ;
19892078 // Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
19902079 // (and its Windows/Linux twins) even with nothing installed.
1991- if ( ops . status ( ) !== null || isServiceInstalled ( ) ) ops . stop ( ) ;
2080+ if ( ops . status ( ) !== null || isServiceInstalled ( ) ) {
2081+ ops . stop ( ) ;
2082+ }
19922083 await stopTrackedProxyForServiceCommand ( ) ;
19932084 {
19942085 // Verify rather than trust the stop command: a surviving wrapper respawns its child
@@ -2015,6 +2106,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
20152106 else if ( ! grok . ok ) console . error ( `⚠️ ${ grok . message } ` ) ;
20162107 }
20172108 break ;
2109+ }
20182110 case "status" : {
20192111 if ( process . platform === "win32" && backend === "scheduler" ) {
20202112 console . log ( await inspectWindowsSchedulerServiceStatus ( ) ) ;
@@ -2060,3 +2152,4 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
20602152 process . exit ( 1 ) ;
20612153 }
20622154}
2155+
0 commit comments