From 49838f315fabfdb140891f77d7017942ace2a6ed Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:38:02 +0200 Subject: [PATCH 1/6] fix: keep configured port after update restart and stop Windows console flashes Pin post-update proxy restart to config.port, wait briefly for the listen socket to free, and avoid spawning visible PowerShell/icacls windows on the start hot path. --- src/cli/index.ts | 9 +++++-- src/config.ts | 46 +++++++++++++++++++++++++++++++++-- src/lib/process-control.ts | 2 +- src/lib/windows-secret-acl.ts | 4 +++ src/server/ports.ts | 46 +++++++++++++++++++++++++++++++++-- src/server/proxy-liveness.ts | 6 +++-- src/update/job.ts | 35 ++++++++++++++++++-------- tests/ports.test.ts | 37 +++++++++++++++++++++++++++- tests/update-job.test.ts | 11 +++++++++ 9 files changed, 176 insertions(+), 20 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 85d43f7f0..1086d7379 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,7 +87,12 @@ async function waitForProxy(timeoutMs = 8_000): Promise { async function chooseListenPort(requestedPort?: number): Promise { const config = loadConfig(); const preferred = requestedPort ?? config.port ?? 10100; - const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1"); + // Brief prefer-retry covers stop→start races (update restart, `ocx restart`) where the + // old process has exited but the listen socket is still draining. + const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", { + preferRetryMs: 750, + preferRetryIntervalMs: 50, + }); if (selected !== preferred) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); } @@ -587,7 +592,7 @@ switch (command) { const jobId = args[1]; if (!jobId) process.exit(1); const channel = normalizeUpdateChannel(args[2]); - runGuiUpdateWorker(jobId, channel, args[3] === "restart"); + await runGuiUpdateWorker(jobId, channel, args[3] === "restart"); break; } case "restart": { diff --git a/src/config.ts b/src/config.ts index 41cce6d6e..7afbf8188 100644 --- a/src/config.ts +++ b/src/config.ts @@ -825,26 +825,68 @@ export function isOcxStartCommandLine(commandLine: string): boolean { return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized); } +/** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */ +const ocxStartProcessCache = new Map(); + function isLikelyOcxStartProcess(pid: number): boolean { + const cached = ocxStartProcessCache.get(pid); + if (cached !== undefined) return cached; const commandLine = readProcessCommandLine(pid); if (commandLine === undefined) return false; - return isOcxStartCommandLine(commandLine); + const ok = isOcxStartCommandLine(commandLine); + ocxStartProcessCache.set(pid, ok); + return ok; +} + +/** + * Alive pid from the pid file without the expensive Windows command-line probe. + * Safe for liveness polls: callers still identity-check /healthz before trusting the proxy. + * Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline. + */ +export function readAlivePid(): number | null { + const pid = readPidFileValue(); + if (pid === null) return null; + try { + process.kill(pid, 0); + return pid; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return pid; + return null; + } } function readProcessCommandLine(pid: number): string | undefined { try { if (process.platform === "win32") { + // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash. + // Fall back to PowerShell when WMIC is absent (newer Windows images). + const wmic = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\wbem\\WMIC.exe`; + try { + const output = execFileSync(wmic, [ + "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE", + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); + const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, "")); + const value = match?.[1]?.trim(); + if (value) return value; + } catch { + /* WMIC missing or failed — fall through */ + } const output = execFileSync("powershell.exe", [ "-NoProfile", + "-NoLogo", + "-NonInteractive", + "-WindowStyle", + "Hidden", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }); + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); return output.trim() || undefined; } const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000, + windowsHide: true, }); return output.trim() || undefined; } catch { diff --git a/src/lib/process-control.ts b/src/lib/process-control.ts index 81f03044b..329a1fdb4 100644 --- a/src/lib/process-control.ts +++ b/src/lib/process-control.ts @@ -100,7 +100,7 @@ export function killProxy(pid: number): void { if (process.platform === "win32") { const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`; try { - execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" }); + execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true }); } catch (err) { if (isProcessAlive(pid)) throw err; } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 8a481d706..3ecd8e14e 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -64,11 +64,13 @@ function runIcacls(targetPath: string, directory: boolean): void { throw new Error("Cannot determine current Windows user for ACL hardening"); } + // windowsHide: console-subsystem tools (icacls) otherwise flash a visible window per call. // Step 1: disable inheritance and remove inherited ACEs execFileSync("icacls.exe", [targetPath, "/inheritance:r"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, + windowsHide: true, }); // Step 2: remove broad explicit grants using stable SIDs (not localized names). @@ -82,6 +84,7 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, + windowsHide: true, }); // Step 3: grant current user full control. @@ -90,6 +93,7 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, + windowsHide: true, }); } diff --git a/src/server/ports.ts b/src/server/ports.ts index 8f2dfd3b7..e4f1d47f8 100644 --- a/src/server/ports.ts +++ b/src/server/ports.ts @@ -24,8 +24,50 @@ export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Pro }); } -export async function findAvailablePort(preferredPort: number, hostname = "127.0.0.1"): Promise { - if (await isPortAvailable(preferredPort, hostname)) return preferredPort; +export type WaitForPortOptions = { + timeoutMs?: number; + intervalMs?: number; +}; + +/** Poll until `port` accepts a bind, or until the timeout elapses. */ +export async function waitForPortAvailable( + port: number, + hostname = "127.0.0.1", + opts: WaitForPortOptions = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 5000; + const intervalMs = opts.intervalMs ?? 50; + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await isPortAvailable(port, hostname)) return true; + if (Date.now() >= deadline) return false; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } +} + +export type FindAvailablePortOptions = { + /** How long to keep retrying the preferred port before falling back to an ephemeral port. */ + preferRetryMs?: number; + preferRetryIntervalMs?: number; +}; + +export async function findAvailablePort( + preferredPort: number, + hostname = "127.0.0.1", + opts: FindAvailablePortOptions = {}, +): Promise { + const preferRetryMs = opts.preferRetryMs ?? 0; + if (preferRetryMs > 0) { + if (await waitForPortAvailable(preferredPort, hostname, { + timeoutMs: preferRetryMs, + intervalMs: opts.preferRetryIntervalMs ?? 50, + })) { + return preferredPort; + } + } else if (await isPortAvailable(preferredPort, hostname)) { + return preferredPort; + } + return await new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 002bca2d9..100957635 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -9,7 +9,7 @@ * * Lives outside cli.ts (which dispatches argv at module top level) so tests can import it. */ -import { loadConfig, readPid, readRuntimePort } from "../config"; +import { loadConfig, readAlivePid, readRuntimePort } from "../config"; export interface HealthzIdentity { service?: unknown; @@ -86,7 +86,9 @@ export async function proxyIdentityAt( * found and a foreign listener on the configured port is rejected. */ export async function findLiveProxy(io: LivenessIo = {}): Promise { - const readPidFn = io.readPidFn ?? readPid; + // Prefer the cheap alive-pid check: the Windows cmdline probe (WMIC/PowerShell) is too + // expensive for waitForProxy's 150ms poll loop, and /healthz identity is the real trust gate. + const readPidFn = io.readPidFn ?? readAlivePid; const readRuntimeFn = io.readRuntimeFn ?? readRuntimePort; const configFn = io.configFn ?? loadConfig; diff --git a/src/update/job.ts b/src/update/job.ts index 441bb2eda..d875783a7 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -2,8 +2,9 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { atomicWriteFile, getConfigDir, readPid } from "../config"; +import { atomicWriteFile, getConfigDir, loadConfig, readPid } from "../config"; import { killProxy } from "../lib/process-control"; +import { waitForPortAvailable } from "../server/ports"; import { isServiceInstalled } from "../service"; import { type Channel, @@ -158,18 +159,23 @@ export function restartCommand( serviceInstalled: boolean, installer: Installer, launcher = packageLauncherPath(), + port?: number, ): { mode: "service" | "proxy"; bin: string; args: string[]; display: string } { const mode = serviceInstalled ? "service" : "proxy"; + const pinPort = !serviceInstalled && typeof port === "number" && Number.isFinite(port) && port > 0; + const startArgs = pinPort + ? [launcher, "start", "--port", String(Math.trunc(port))] + : [launcher, "start"]; if (installer === "npm") { const bin = nodeBin(); - const args = serviceInstalled ? [launcher, "service", "install"] : [launcher, "start"]; + const args = serviceInstalled ? [launcher, "service", "install"] : startArgs; return { mode, bin, args, display: formatCommand(bin, args) }; } // bun/source installs: restart via the current runtime executable + package launcher (both real // .exe files), NOT the `ocx.cmd` shim. Spawning a `.cmd` shell-less throws EINVAL on Windows // Node/Bun ≥18.20/20.12 (CVE-2024-27980 hardening) — the same class the npm path (nodeBin) avoids. const bin = process.execPath; - const args = serviceInstalled ? [launcher, "service", "install"] : [launcher, "start"]; + const args = serviceInstalled ? [launcher, "service", "install"] : startArgs; return { mode, bin, args, display: formatCommand(bin, args) }; } @@ -264,8 +270,8 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time return { status: result.status, signal: result.signal }; } -function spawnDetachedStart(job: UpdateJobState, installer: Installer): void { - const cmd = restartCommand(false, installer); +function spawnDetachedStart(job: UpdateJobState, installer: Installer, port?: number): void { + const cmd = restartCommand(false, installer, packageLauncherPath(), port); const env = { ...process.env }; delete env.OCX_SERVICE; updateJob(job, {}, `$ ${cmd.display}`); @@ -278,9 +284,12 @@ function spawnDetachedStart(job: UpdateJobState, installer: Installer): void { child.unref(); } -function restartAfterUpdate(job: UpdateJobState): void { +async function restartAfterUpdate(job: UpdateJobState): Promise { const serviceInstalled = isServiceInstalled(); - const cmd = restartCommand(serviceInstalled, job.installer); + const config = loadConfig(); + const port = config.port ?? 10100; + const hostname = config.hostname ?? "127.0.0.1"; + const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port); if (serviceInstalled) { const result = runLoggedCommand(job, cmd.bin, cmd.args, RESTART_TIMEOUT_MS); if (result.status !== 0) { @@ -293,11 +302,17 @@ function restartAfterUpdate(job: UpdateJobState): void { if (pid) { updateJob(job, {}, `Stopping current proxy PID ${pid}.`); killProxy(pid); + // Windows taskkill can leave the listen socket busy briefly; wait only after a real + // kill so cold restarts are not charged a multi-second prefer-wait. + const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 2000, intervalMs: 25 }); + if (!freed) { + updateJob(job, {}, `Port ${port} still busy after stop; starting with --port ${port} anyway.`); + } } - spawnDetachedStart(job, job.installer); + spawnDetachedStart(job, job.installer, port); } -export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): void { +export async function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): Promise { let job = readUpdateJob(jobId); const check = checkForUpdate(channel); const now = new Date().toISOString(); @@ -362,7 +377,7 @@ export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boo if (restart) { job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); - restartAfterUpdate(job); + await restartAfterUpdate(job); updateJob(job, { status: "succeeded", restarted: true }, "Restart requested."); return; } diff --git a/tests/ports.test.ts b/tests/ports.test.ts index dcc61965c..f3dd01ef1 100644 --- a/tests/ports.test.ts +++ b/tests/ports.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer, type Server } from "node:net"; -import { findAvailablePort, isAddrInUse, isPortAvailable, shouldPersistSelectedPort } from "../src/server/ports"; +import { findAvailablePort, isAddrInUse, isPortAvailable, shouldPersistSelectedPort, waitForPortAvailable } from "../src/server/ports"; const servers: Server[] = []; @@ -54,6 +54,41 @@ describe("port selection", () => { expect(shouldPersistSelectedPort(10100, 10100, 10100)).toBe(false); }); + test("waitForPortAvailable resolves once a busy port is released", async () => { + const { server, port } = await listen(); + expect(await isPortAvailable(port)).toBe(false); + + const waiting = waitForPortAvailable(port, "127.0.0.1", { timeoutMs: 2000, intervalMs: 25 }); + await close(server); + const idx = servers.indexOf(server); + if (idx >= 0) servers.splice(idx, 1); + + await expect(waiting).resolves.toBe(true); + expect(await isPortAvailable(port)).toBe(true); + }); + + test("waitForPortAvailable returns false when the port stays busy past the timeout", async () => { + const { port } = await listen(); + await expect(waitForPortAvailable(port, "127.0.0.1", { timeoutMs: 80, intervalMs: 20 })).resolves.toBe(false); + expect(await isPortAvailable(port)).toBe(false); + }); + + test("findAvailablePort retries the preferred port briefly before falling back", async () => { + const { server, port } = await listen(); + expect(await isPortAvailable(port)).toBe(false); + + const pending = findAvailablePort(port, "127.0.0.1", { preferRetryMs: 500, preferRetryIntervalMs: 25 }); + // Free the preferred port during the retry window. + setTimeout(() => { + void close(server).then(() => { + const idx = servers.indexOf(server); + if (idx >= 0) servers.splice(idx, 1); + }); + }, 60); + + expect(await pending).toBe(port); + }); + test("isAddrInUse recognizes bind conflicts by code or message and rejects everything else", () => { expect(isAddrInUse(Object.assign(new Error("listen failed"), { code: "EADDRINUSE" }))).toBe(true); expect(isAddrInUse(new Error("listen EADDRINUSE: address already in use ::1:8123"))).toBe(true); diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index a0cb00c27..54546604b 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -98,6 +98,17 @@ describe("GUI update execution decisions", () => { }); }); + test("proxy restart pins --port so post-update start does not hop to an ephemeral port", () => { + const proxy = restartCommand(false, "npm", "/pkg/bin/ocx.mjs", 10100); + expect(proxy.mode).toBe("proxy"); + expect(proxy.args).toEqual(["/pkg/bin/ocx.mjs", "start", "--port", "10100"]); + expect(proxy.display).toContain("start --port 10100"); + // Service reinstall keeps its own port contract — do not append --port. + expect(restartCommand(true, "npm", "/pkg/bin/ocx.mjs", 10100).args).toEqual([ + "/pkg/bin/ocx.mjs", "service", "install", + ]); + }); + test("a running job prevents a second update job", () => { const now = new Date().toISOString(); const job: UpdateJobState = { From 631d32d6cdfd1a4708b20719cf61950bbebba85b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:43:19 +0200 Subject: [PATCH 2/6] test: update Windows restart source contract for pinned --port startArgs --- tests/windows-deploy-close-regressions.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/windows-deploy-close-regressions.test.ts b/tests/windows-deploy-close-regressions.test.ts index 40c31b2be..5c60805c8 100644 --- a/tests/windows-deploy-close-regressions.test.ts +++ b/tests/windows-deploy-close-regressions.test.ts @@ -17,7 +17,10 @@ describe("update-job restart avoids the shell-less .cmd EINVAL (Windows, bun/sou }); test("bun/source restart uses the runtime executable + launcher (a real .exe, no shell)", () => { // restartCommand's non-npm branch resolves to process.execPath + the package launcher. - expect(src).toMatch(/const bin = process\.execPath;\s*\n\s*const args = serviceInstalled \? \[launcher, "service", "install"\] : \[launcher, "start"\];/); + // Proxy mode may pin --port via startArgs; service mode stays install-only. + expect(src).toMatch(/const bin = process\.execPath;\s*\n\s*const args = serviceInstalled \? \[launcher, "service", "install"\] : startArgs;/); + expect(src).toContain('? [launcher, "start", "--port", String(Math.trunc(port))]'); + expect(src).toContain(': [launcher, "start"]'); }); }); From f19f35e8fdb77e433b98f952af271eacfe302b15 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:52:54 +0200 Subject: [PATCH 3/6] test: shrink usage-debug long-run loop so pre-push does not time out under load --- tests/usage-debug.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/usage-debug.test.ts b/tests/usage-debug.test.ts index 3fbce29f6..00152e121 100644 --- a/tests/usage-debug.test.ts +++ b/tests/usage-debug.test.ts @@ -150,7 +150,10 @@ describe("appendUsageDebug", () => { }); test("keeps file size bounded by MAX_LINES across long runs", () => { - for (let i = 0; i < USAGE_DEBUG_MAX_LINES * 3; i++) { + // Cross the rotate threshold twice — enough to prove the bound holds across + // multiple rewrites without MAX*3 appends (that path times out under full-suite load). + const total = USAGE_DEBUG_MAX_LINES + USAGE_DEBUG_KEEP_LINES + 25; + for (let i = 0; i < total; i++) { appendUsageDebug(sample({ requestId: `ocx-${i}`, ts: i })); } const path = usageDebugPath(); From 5f391b5bb6c14e6afcfe5d304188fbf0615baaec Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:15:21 +0200 Subject: [PATCH 4/6] chore: leave Windows ACL harden for a separate PR Port stickiness stays scoped to restart/port behavior; icacls ACL soft-fail lives elsewhere. --- src/lib/windows-secret-acl.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 3ecd8e14e..8a481d706 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -64,13 +64,11 @@ function runIcacls(targetPath: string, directory: boolean): void { throw new Error("Cannot determine current Windows user for ACL hardening"); } - // windowsHide: console-subsystem tools (icacls) otherwise flash a visible window per call. // Step 1: disable inheritance and remove inherited ACEs execFileSync("icacls.exe", [targetPath, "/inheritance:r"], { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, - windowsHide: true, }); // Step 2: remove broad explicit grants using stable SIDs (not localized names). @@ -84,7 +82,6 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, - windowsHide: true, }); // Step 3: grant current user full control. @@ -93,7 +90,6 @@ function runIcacls(targetPath: string, directory: boolean): void { stdio: ["ignore", "pipe", "ignore"], timeout: 5000, shell: false, - windowsHide: true, }); } From 76065e1be5c4411d6861a944098a4f9b448b762f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:45:37 +0200 Subject: [PATCH 5/6] fix(windows): stop GUI update from stacking visible service consoles Hide the Task Scheduler wrapper via a WScript launcher (window style 0), pipe npm/update child stdio under OCX_SERVICE=1, and keep post-update restart pinned to config.port so failed starts do not fuel the :loop. --- src/service.ts | 29 +++++++++++++++++----- src/update/index.ts | 43 ++++++++++++++++++++++++++++++--- tests/service.test.ts | 23 ++++++++++++++---- tests/update-stop-first.test.ts | 8 ++++++ 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/service.ts b/src/service.ts index 09988e54d..c85564124 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,17 @@ 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. + */ +export function buildWindowsServiceVbs(script = windowsServiceScriptPath()): string { + const escaped = script.replace(/"/g, '""'); + return `CreateObject("WScript.Shell").Run """${escaped}""", 0, False\r\n`; +} + +export function buildWindowsTaskXml(vbs = windowsServiceVbsPath()): string { + const escapedArgs = taskXmlString(`//B //Nologo "${vbs}"`); return ` @@ -362,7 +375,7 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string false true true - false + true PT0S 7 @@ -372,7 +385,8 @@ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string - ${escapedScript} + wscript.exe + ${escapedArgs} @@ -425,8 +439,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 +453,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..7eacb61f5 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, False\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", () => { From 725b9d4d6af0d46946b7cd55b19e772b0ce43eba Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:01:23 +0200 Subject: [PATCH 6/6] fix(windows): keep schtasks Running while hidden VBS waits on service WaitOnReturn=True so wscript stays alive for the batch lifetime; False made CI see Ready despite a healthy proxy. --- src/service.ts | 4 +++- tests/service.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/service.ts b/src/service.ts index c85564124..2ffdc56a0 100644 --- a/src/service.ts +++ b/src/service.ts @@ -342,10 +342,12 @@ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath /** * 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, False\r\n`; + return `CreateObject("WScript.Shell").Run """${escaped}""", 0, True\r\n`; } export function buildWindowsTaskXml(vbs = windowsServiceVbsPath()): string { diff --git a/tests/service.test.ts b/tests/service.test.ts index 7eacb61f5..68715a752 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -206,7 +206,7 @@ describe("Windows service task", () => { 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, False\r\n', + 'CreateObject("WScript.Shell").Run """C:\\Users\\a&b\\.opencodex\\opencodex-service.cmd""", 0, True\r\n', ); });