Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/lib/winsw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}

Expand Down
80 changes: 72 additions & 8 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1363,7 +1364,38 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
* 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<void> {
assertWindowsNativeServiceAccountSupported();
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
writeServiceApiTokenFile();
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1562,17 +1610,29 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
type TrackedProxyCleanupResult = "none" | "stale" | "stopped";

async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
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<TrackedProxyCleanupResult> {
Expand Down Expand Up @@ -1886,6 +1946,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
// (and its Windows/Linux twins) even with nothing installed.
if (ops.status() !== null || isServiceInstalled()) ops.stop();
await stopTrackedProxyForServiceCommand();
if (await findLiveProxy({ timeoutMs: 1500 })) {
console.error("❌ Service stop did not terminate the proxy — it is still running. Check `ocx service status` and the service log.");
process.exit(1);
}
{
const restore = restoreNativeCodex();
if (restore.success) console.log("✅ service stopped + native Codex restored.");
Expand Down
26 changes: 21 additions & 5 deletions tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,31 +644,47 @@ describe("service lifecycle cleanup ordering", () => {
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<TrackedProxyCleanupResult>");
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");

Expand Down
7 changes: 7 additions & 0 deletions tests/winsw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Loading