diff --git a/src/service.ts b/src/service.ts index 09988e54d..2ffdc56a0 100644 --- a/src/service.ts +++ b/src/service.ts @@ -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"); } @@ -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"), @@ -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 ` @@ -362,7 +377,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string false true true - false + true PT0S 7 @@ -372,7 +387,8 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string - ${escapedScript} + wscript.exe + ${escapedArgs} @@ -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(); @@ -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()); } diff --git a/src/update/index.ts b/src/update/index.ts index fa2da0c03..78fafa0a8 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -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"); @@ -160,7 +177,13 @@ export async function runUpdate(): Promise { // 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); @@ -178,7 +201,15 @@ export async function runUpdate(): Promise { 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 @@ -197,7 +228,13 @@ export async function runUpdate(): Promise { // 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"); diff --git a/tests/service.test.ts b/tests/service.test.ts index 97e97f6c9..68715a752 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -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"; @@ -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(''); expect(xml).toContain(""); @@ -197,13 +197,24 @@ describe("Windows service task", () => { expect(xml).toContain(""); expect(xml).toContain("PT1M"); expect(xml).toContain("3"); - expect(xml).toContain("C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd"); + expect(xml).toContain("true"); + expect(xml).toContain("wscript.exe"); + expect(xml).toContain('//B //Nologo "C:\\Users\\a&b\\.opencodex\\opencodex-service.vbs"'); + expect(xml).not.toContain("opencodex-service.cmd"); + }); + + 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", () => { @@ -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())"); }); diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index 081fb225a..d4794e117 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -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", () => {