Skip to content
31 changes: 25 additions & 6 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ function windowsServiceScriptPath(): string {
return join(getConfigDir(), "opencodex-service.cmd");
}

function windowsServiceVbsPath(): string {
return join(getConfigDir(), "opencodex-service.vbs");
}

function windowsTaskXmlPath(): string {
return join(getConfigDir(), "opencodex-service-task.xml");
}
Expand Down Expand Up @@ -295,7 +299,7 @@ export function buildWindowsServiceScript(entry = cliEntry()): string {
const lines = [
"@echo off",
"setlocal",
// The wrapper runs in its own hidden console, so switching that console to UTF-8 is
// The wrapper is launched via a hidden WScript helper, so switching that console to UTF-8 is
// safe (no leak into user shells) and lets cmd parse any UTF-8 remnants correctly.
"chcp 65001 >nul",
windowsBatchSet("OCX_SERVICE", "1"),
Expand Down Expand Up @@ -335,8 +339,19 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath
return ["/create", "/tn", TASK, "/xml", xml, "/f"];
}

export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string {
const escapedScript = taskXmlString(script);
/**
* Hidden launcher for the service `.cmd`. Task Scheduler Exec of a console-subsystem
* batch paints a visible window; WScript Run with window style 0 does not.
* WaitOnReturn must be True so the scheduled task stays Running while the wrapper
* (and proxy) are alive — False returns immediately and Task Scheduler flips to Ready.
*/
export function buildWindowsServiceVbs(script = windowsServiceScriptPath()): string {
const escaped = script.replace(/"/g, '""');
return `CreateObject("WScript.Shell").Run """${escaped}""", 0, True\r\n`;
}

export function buildWindowsTaskXml(vbs = windowsServiceVbsPath()): string {
const escapedArgs = taskXmlString(`//B //Nologo "${vbs}"`);
return `<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
Expand All @@ -362,7 +377,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<Hidden>true</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
<RestartOnFailure>
Expand All @@ -372,7 +387,8 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string
</Settings>
<Actions Context="Author">
<Exec>
<Command>${escapedScript}</Command>
<Command>wscript.exe</Command>
<Arguments>${escapedArgs}</Arguments>
</Exec>
</Actions>
</Task>
Expand Down Expand Up @@ -425,8 +441,10 @@ function installWindows(): void {
// script mid-rewrite runs a torn batch file, and its open handle can fail the write.
try { stopWindows(); } catch { /* not running */ }
const script = windowsServiceScriptPath();
const vbs = windowsServiceVbsPath();
writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
writeServiceAssetWithRetry(vbs, buildWindowsServiceVbs(script), "utf8");
writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(vbs)}`, "utf16le");
schtasks(buildWindowsSchtasksCreateArgs(script));
schtasks(["/run", "/tn", TASK]);
writeServiceInstallState();
Expand All @@ -437,6 +455,7 @@ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]
function uninstallWindows(): void {
try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
if (existsSync(windowsServiceVbsPath())) unlinkSync(windowsServiceVbsPath());
if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
}

Expand Down
43 changes: 40 additions & 3 deletions src/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ function npmSpawnTarget(bin: string): { bin: string; shell: boolean } {
return { bin: "npm.cmd", shell: true };
}

/**
* GUI update worker sets OCX_SERVICE=1 and has stdio ignored — inheriting that for
* `npm.cmd` (shell:true) opens stacked visible consoles on Windows. Pipe instead.
*/
function updateChildStdio(): "inherit" | "pipe" {
if (process.env.OCX_SERVICE === "1") return "pipe";
if (typeof process.stdout.isTTY === "boolean" && !process.stdout.isTTY) return "pipe";
return "inherit";
}

function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null; stderr?: string | Buffer | null }): void {
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
if (stdout) console.log(stdout.length > 4000 ? `${label}${stdout.slice(-4000)}` : stdout);
if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr);
}

/** Latest published version from the registry (best-effort; null if npm isn't available). */
export function latestVersion(tag: string): string | null {
const npm = npmSpawnTarget("npm");
Expand Down Expand Up @@ -160,7 +177,13 @@ export async function runUpdate(): Promise<void> {
// Full `ocx stop` semantics (drain, service stop, restore).
if (serviceWasInstalled || readPid() || readRuntimePort()) {
console.log("⏹ Stopping the running proxy before updating...");
const stop = spawnSync(process.execPath, [process.argv[1], "stop"], { stdio: "inherit", windowsHide: true });
const stopStdio = updateChildStdio();
const stop = spawnSync(process.execPath, [process.argv[1], "stop"], {
stdio: stopStdio,
encoding: stopStdio === "pipe" ? "utf8" : undefined,
windowsHide: true,
});
if (stopStdio === "pipe") logSpawnOutput("", stop);
if (stop.status !== 0 || readPid() || readRuntimePort()) {
console.error("⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
process.exit(1);
Expand All @@ -178,7 +201,15 @@ export async function runUpdate(): Promise<void> {
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);

const target = npmSpawnTarget(bin);
const r = spawnSync(target.bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true, shell: target.shell });
const installStdio = updateChildStdio();
const r = spawnSync(target.bin, cmdArgs, {
stdio: installStdio,
encoding: installStdio === "pipe" ? "utf8" : undefined,
timeout: 180000,
windowsHide: true,
shell: target.shell,
});
if (installStdio === "pipe") logSpawnOutput("", r);
if (r.status === 0) {
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
// Re-bake the bundled Bun path into the Codex autostart shim on every
Expand All @@ -197,7 +228,13 @@ export async function runUpdate(): Promise<void> {
// launchd/schtasks/systemd user isn't left with the background proxy down.
if (serviceWasInstalled) {
console.log("🔁 Reinstalling the background service with the updated files...");
const svc = spawnSync(process.execPath, [process.argv[1], "service", "install"], { stdio: "inherit", windowsHide: true });
const svcStdio = updateChildStdio();
const svc = spawnSync(process.execPath, [process.argv[1], "service", "install"], {
stdio: svcStdio,
encoding: svcStdio === "pipe" ? "utf8" : undefined,
windowsHide: true,
});
if (svcStdio === "pipe") logSpawnOutput("", svc);
if (svc.status !== 0) console.warn("⚠️ Service refresh failed — run 'ocx service install' manually.");
} else {
console.log("Restart the proxy: ocx start");
Expand Down
23 changes: 18 additions & 5 deletions tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { saveConfig } from "../src/config";
import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths";
import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, normalizeServiceSubcommand, serviceLogPath, serviceStatusSummary } from "../src/service";
import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsServiceVbs, buildWindowsTaskXml, normalizeServiceSubcommand, serviceLogPath, serviceStatusSummary } from "../src/service";
import { serviceApiTokenFilePath } from "../src/lib/service-secrets";
import type { OcxConfig } from "../src/types";

Expand Down Expand Up @@ -184,8 +184,8 @@ describe("Windows service task", () => {
});

test("builds service-like Task Scheduler XML settings", () => {
const script = "C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd";
const xml = buildWindowsTaskXml(script);
const vbs = "C:\\Users\\a&b\\.opencodex\\opencodex-service.vbs";
const xml = buildWindowsTaskXml(vbs);

expect(xml).toContain('<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">');
expect(xml).toContain("<LogonTrigger>");
Expand All @@ -197,13 +197,24 @@ describe("Windows service task", () => {
expect(xml).toContain("<RestartOnFailure>");
expect(xml).toContain("<Interval>PT1M</Interval>");
expect(xml).toContain("<Count>3</Count>");
expect(xml).toContain("<Command>C:\\Users\\a&amp;b\\.opencodex\\opencodex-service.cmd</Command>");
expect(xml).toContain("<Hidden>true</Hidden>");
expect(xml).toContain("<Command>wscript.exe</Command>");
expect(xml).toContain('<Arguments>//B //Nologo &quot;C:\\Users\\a&amp;b\\.opencodex\\opencodex-service.vbs&quot;</Arguments>');
expect(xml).not.toContain("opencodex-service.cmd</Command>");
});

test("builds a hidden WScript launcher for the service batch", () => {
const script = "C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd";
expect(buildWindowsServiceVbs(script)).toBe(
'CreateObject("WScript.Shell").Run """C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd""", 0, True\r\n',
);
});

test("writes Task Scheduler XML with a UTF-16 BOM for schtasks", async () => {
const service = await Bun.file(new URL("../src/service.ts", import.meta.url)).text();

expect(service).toContain('writeServiceAssetWithRetry(windowsTaskXmlPath(), `\\uFEFF${buildWindowsTaskXml(script)}`, "utf16le")');
expect(service).toContain('writeServiceAssetWithRetry(windowsTaskXmlPath(), `\\uFEFF${buildWindowsTaskXml(vbs)}`, "utf16le")');
expect(service).toContain("writeServiceAssetWithRetry(vbs, buildWindowsServiceVbs(script), \"utf8\")");
});

test("escapes environment values that would break out of set quotes", () => {
Expand Down Expand Up @@ -385,7 +396,9 @@ describe("service lifecycle cleanup ordering", () => {
const uninstallWindows = service.slice(service.indexOf("function uninstallWindows()"), service.indexOf("function serviceDiagnosticsSummary()"));

expect(uninstallWindows).toContain("windowsServiceScriptPath()");
expect(uninstallWindows).toContain("windowsServiceVbsPath()");
expect(uninstallWindows).toContain("windowsTaskXmlPath()");
expect(uninstallWindows).toContain("unlinkSync(windowsServiceVbsPath())");
expect(uninstallWindows).toContain("unlinkSync(windowsTaskXmlPath())");
});

Expand Down
8 changes: 8 additions & 0 deletions tests/update-stop-first.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ describe("update stops the running proxy before replacing files", () => {
expect(launcherSource).toContain("if (serviceWasInstalled || hasRuntimeState)");
expect(launcherSource).toContain("stopRes.status !== 0 || stillHasRuntimeState");
});

test("GUI worker update children use pipe stdio so Windows npm.cmd does not open consoles", () => {
expect(updateSource).toContain("function updateChildStdio()");
expect(updateSource).toContain('process.env.OCX_SERVICE === "1"');
expect(updateSource).toContain('return "pipe"');
expect(updateSource).toContain("stdio: installStdio");
expect(updateSource).toContain("windowsHide: true");
});
});

describe("/healthz identity fields", () => {
Expand Down
Loading