From 824dcba1f25fd0d30bd51f03ee370490b94672ed Mon Sep 17 00:00:00 2001 From: lcBreathe <165003424+lcBreathe@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:35:20 +0800 Subject: [PATCH 1/8] fix(service): accept standalone Windows wrappers --- src/service-manager-probe.ts | 6 ++- structure/ops/service-and-sidecars.md | 2 + .../codex-service-manager-probe.test.ts | 42 ++++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index d3d722bcfb5..d0cba2a3a68 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -561,7 +561,11 @@ function decodeBatchPathValue( /** Validate the generated wrapper before interpreting omitted optional homes. */ function wrapperLooksGenerated(body: string): boolean { - return /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im.test(body); + const sourceLaunch = /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im; + const standaloneLaunch = /:loop\s*[\s\S]*^"%OCX_BUN%" start\b[^\r\n]*$/im; + return batchSetValue(body, "OCX_CLI") === null + ? standaloneLaunch.test(body) + : sourceLaunch.test(body); } function normalizeWindowsPath(value: string): string { diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 375e0ed055a..d7e4f8935e1 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -76,6 +76,8 @@ two naming different homes, and on macOS a logged-out user can have the plist on domain to query. The probe returns what it saw and does not decide ownership; callers such as `src/integrations/native/ownership-preflight.ts` compare the homes. Every command it runs is read-only and time-bounded, so it is safe while the proxy runs under that same manager. +On Windows, the generated-wrapper check accepts both package installs that invoke the source CLI +and standalone installs whose executable handles `start` directly; other wrapper shapes fail closed. ## Stable service launcher (launchd and systemd) diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 6c2281d5ea5..8cdd1419778 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -8,7 +8,7 @@ * - mutation-test the fixture's argv instead of the argv production emits */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,6 +17,7 @@ import { type ProbeRunner, type RawProbeRunner, } from "../../src/service-manager-probe"; +import { buildWindowsServiceScript } from "../../src/service/windows-taskxml"; import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -409,6 +410,45 @@ describe("the Windows chain walk", () => { expect(calls[0].args).toEqual(["/query", "/tn", "opencodex-proxy", "/xml"]); }); + test("a production-generated standalone wrapper is accepted", () => { + const dir = join(home, ".opencodex"); + const wrapper = join(dir, "opencodex-service.cmd"); + mkdirSync(dir, { recursive: true }); + writeFileSync(wrapper, buildWindowsServiceScript({ + bun: "C:\\OpenCodex\\ocx.exe", + bunRuntimeSource: "standalone", + cli: null, + }, 10100)); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + expect(inspectServiceManagerInstallation({ + platform: "win32", + home, + runRaw, + winswStatus: () => "nonexistent", + }).kind).toBe("present"); + }); + + test("a source wrapper that drops its CLI argument is unknown", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( + '"%OCX_BUN%" "%OCX_CLI%" start', + '"%OCX_BUN%" start', + )); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + expect(inspectServiceManagerInstallation({ + platform: "win32", + home, + runRaw, + winswStatus: () => "nonexistent", + }).kind).toBe("unknown"); + }); + test("a registered task whose chain disagrees with the staged definition is unknown", () => { const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); From 9eeff0ddd0a7dcb8e54a73d334365dc4c3712775 Mon Sep 17 00:00:00 2001 From: lcBreathe <165003424+lcBreathe@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:28:55 +0800 Subject: [PATCH 2/8] test(service): strengthen standalone wrapper claim --- src/service-manager-probe.ts | 5 ++++- .../codex-integration/codex-service-manager-probe.test.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index d0cba2a3a68..171b92c16ac 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -559,7 +559,10 @@ function decodeBatchPathValue( .replaceAll(escapedPercent, "%"); } -/** Validate the generated wrapper before interpreting omitted optional homes. */ +/** + * Validate the generated wrapper before interpreting omitted optional homes. + * The generated OCX_CLI set line, not bun runtime provenance, selects the valid launch shape. + */ function wrapperLooksGenerated(body: string): boolean { const sourceLaunch = /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im; const standaloneLaunch = /:loop\s*[\s\S]*^"%OCX_BUN%" start\b[^\r\n]*$/im; diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 8cdd1419778..3e5e5abd9f0 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -423,12 +423,16 @@ describe("the Windows chain walk", () => { writeWindowsTask(launcher); const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); - expect(inspectServiceManagerInstallation({ + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent", - }).kind).toBe("present"); + }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims).toHaveLength(1); + expect(result.claims[0].backend).toBe("scheduler"); }); test("a source wrapper that drops its CLI argument is unknown", () => { From d2d107631635f10d2fa4163155b1941836d93deb Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 02:39:03 +0900 Subject: [PATCH 3/8] fix(service): reject mixed Windows wrapper launch shapes Require one direct Bun launch in a generated wrapper. Cover source and standalone wrappers that contain both launch forms. --- src/service-manager-probe.ts | 12 ++++--- .../codex-service-manager-probe.test.ts | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 171b92c16ac..9eac1adc04d 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -564,11 +564,15 @@ function decodeBatchPathValue( * The generated OCX_CLI set line, not bun runtime provenance, selects the valid launch shape. */ function wrapperLooksGenerated(body: string): boolean { - const sourceLaunch = /:loop\s*[\s\S]*^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/im; - const standaloneLaunch = /:loop\s*[\s\S]*^"%OCX_BUN%" start\b[^\r\n]*$/im; + if (!/^:loop\s*$/im.test(body)) return false; + const launchLines = body.split(/\r?\n/).filter(line => /^\s*"%OCX_BUN%"/i.test(line)); + if (launchLines.length !== 1) return false; + const launch = launchLines[0]!.trim(); + const sourceLaunch = /^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/i; + const standaloneLaunch = /^"%OCX_BUN%" start\b[^\r\n]*$/i; return batchSetValue(body, "OCX_CLI") === null - ? standaloneLaunch.test(body) - : sourceLaunch.test(body); + ? standaloneLaunch.test(launch) + : sourceLaunch.test(launch); } function normalizeWindowsPath(value: string): string { diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 3e5e5abd9f0..d11dcef8e32 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -453,6 +453,40 @@ describe("the Windows chain walk", () => { }).kind).toBe("unknown"); }); + test("a source wrapper with an extra standalone launch is unknown", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100\r\n"%OCX_BUN%" start --port 10100', + )); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", + }).kind).toBe("unknown"); + }); + + test("a standalone wrapper with an extra source launch is unknown", () => { + const dir = join(home, ".opencodex"); + const wrapper = join(dir, "opencodex-service.cmd"); + mkdirSync(dir, { recursive: true }); + writeFileSync(wrapper, buildWindowsServiceScript({ + bun: "C:\\OpenCodex\\ocx.exe", bunRuntimeSource: "standalone", cli: null, + }, 10100).replace( + '"%OCX_BUN%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100\r\n"%OCX_BUN%" start --port 10100', + )); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", + }).kind).toBe("unknown"); + }); + test("a registered task whose chain disagrees with the staged definition is unknown", () => { const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); From 0112217b5950e5acf641f8c063bacfef804f8e37 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 03:10:42 +0900 Subject: [PATCH 4/8] fix(service): bind standalone Windows wrapper to install state --- src/service-manager-probe.ts | 71 +++++++++--- structure/ops/service-and-sidecars.md | 8 +- .../codex-service-manager-probe.test.ts | 106 ++++++++++++++++-- 3 files changed, 159 insertions(+), 26 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 9eac1adc04d..fd9ba0828fe 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -28,6 +28,9 @@ import { } from "./lib/windows-elevation"; import { decodeWindowsTextBytes } from "./lib/windows-text"; import { WINSW_SERVICE_ID } from "./lib/winsw"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "./lib/bun-runtime"; +import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./service/windows-wrapper-exit"; +import { inspectServiceStateEvidence, serviceStatePathsForOpenCodexHome } from "./service/state"; /** Short: this runs inside admission, and a slow answer is the same as none. */ export const SERVICE_PROBE_TIMEOUT_MS = 2_000; @@ -192,6 +195,8 @@ export interface ProbeDeps { readonly windowsLocale?: string; /** Startup-local full-listing cache; targeted task queries always bypass it. */ readonly windowsTaskListingCache?: WindowsTaskListingCache; + /** Service-state evidence paths; production derives them from the effective config home. */ + readonly statePaths?: readonly string[]; } const LABEL = "com.opencodex.proxy"; @@ -559,20 +564,45 @@ function decodeBatchPathValue( .replaceAll(escapedPercent, "%"); } -/** - * Validate the generated wrapper before interpreting omitted optional homes. - * The generated OCX_CLI set line, not bun runtime provenance, selects the valid launch shape. - */ -function wrapperLooksGenerated(body: string): boolean { - if (!/^:loop\s*$/im.test(body)) return false; +/** One generated quoted assignment, rejecting unquoted and duplicate forms. */ +function generatedBatchSetValue(body: string, name: string): string | null { + const assignments = body.split(/\r?\n/).filter(line => new RegExp(`^\\s*@?set\\s+"?${name}=`, "i").test(line)); + return assignments.length === 1 ? batchSetValue(assignments[0]!, name) : null; +} + +/** Validate the generated launch shape before interpreting omitted optional homes. */ +function wrapperLaunchShape(body: string): "source" | { standaloneBun: string } | null { + if (!/^:loop\s*$/im.test(body)) return null; const launchLines = body.split(/\r?\n/).filter(line => /^\s*"%OCX_BUN%"/i.test(line)); - if (launchLines.length !== 1) return false; + if (launchLines.length !== 1) return null; const launch = launchLines[0]!.trim(); const sourceLaunch = /^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/i; - const standaloneLaunch = /^"%OCX_BUN%" start\b[^\r\n]*$/i; - return batchSetValue(body, "OCX_CLI") === null - ? standaloneLaunch.test(launch) - : sourceLaunch.test(launch); + const standaloneLaunch = /^"%OCX_BUN%" start --port ([0-9]{1,5}) >>"%OCX_SERVICE_LOG%" 2>&1$/i; + const cliAssignments = body.split(/\r?\n/).filter(line => /^\s*@?set\s+"?OCX_CLI=/i.test(line)); + if (cliAssignments.length > 0) { + return cliAssignments.length === 1 && generatedBatchSetValue(body, "OCX_CLI") !== null && sourceLaunch.test(launch) + ? "source" : null; + } + const port = standaloneLaunch.exec(launch)?.[1]; + if (!port || Number(port) < 1 || Number(port) > 65535 || !/^@echo off\s*$/im.test(body) + || !/^setlocal EnableExtensions DisableDelayedExpansion\s*$/im.test(body) + || !new RegExp(`^if "%ERRORLEVEL%"=="${WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE}" goto stopped\\s*$`, "im").test(body) + || !/^:stopped\s*$/im.test(body)) return null; + for (const [name, value] of [ + ["OCX_SERVICE", "1"], + [WINDOWS_WRAPPER_PROTOCOL_ENV, "1"], + [BUN_RUNTIME_SOURCE_ENV, "standalone"], + ]) { + if (generatedBatchSetValue(body, name) !== value) return null; + } + const bun = generatedBatchSetValue(body, "OCX_BUN"); + const runtimePath = generatedBatchSetValue(body, BUN_RUNTIME_PATH_ENV); + if (!bun || !runtimePath || normalizeWindowsPath(decodeBatchPathValue(bun)) !== normalizeWindowsPath(decodeBatchPathValue(runtimePath))) return null; + for (const name of ["CODEX_HOME", "OPENCODEX_HOME"]) { + const assignments = body.split(/\r?\n/).filter(line => new RegExp(`^\\s*@?set\\s+"?${name}=`, "i").test(line)); + if (assignments.length > 0 && generatedBatchSetValue(body, name) === null) return null; + } + return { standaloneBun: decodeBatchPathValue(bun) }; } function normalizeWindowsPath(value: string): string { @@ -722,7 +752,7 @@ function probeWinswRegistration( function inspectWindows( deps: Required> - & Pick, + & Pick, ): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); const taskXmlPath = join(configDir, "opencodex-service-task.xml"); @@ -848,7 +878,7 @@ function homesEqual( * generated service-asset directory. */ function walkWindowsChain( - deps: Required> & Pick, + deps: Required> & Pick, xml: string, definitionPath: string, ): ServiceManagerInstallation { @@ -896,9 +926,21 @@ function walkWindowsChain( return unknown(`the launcher wrapper could not be read: ${String(error)}`); } - if (!wrapperLooksGenerated(wrapperBody)) { + const launchShape = wrapperLaunchShape(wrapperBody); + if (launchShape === null) { return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`); } + if (typeof launchShape !== "string") { + const executable = launchShape.standaloneBun; + const evidence = inspectServiceStateEvidence(deps.statePaths ?? serviceStatePathsForOpenCodexHome(configDir)); + const valid = evidence.filter(e => e.kind === "valid"); + if (!win32Path.isAbsolute(executable) || win32Path.extname(executable).toLowerCase() !== ".exe" + || valid.length === 0 || evidence.some(e => e.kind === "invalid" || e.kind === "unreadable") + || valid.some(e => e.state.version !== 2 || e.state.backend !== "scheduler" || e.state.cliPath !== null + || !e.state.bunPath || normalizeWindowsPath(e.state.bunPath) !== normalizeWindowsPath(executable))) { + return unknown("the standalone service wrapper executable is not bound to recorded scheduler install state"); + } + } const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME"); const rawOpencodexHome = batchSetValue(wrapperBody, "OPENCODEX_HOME"); @@ -1003,6 +1045,7 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service winswStatus: deps.winswStatus, windowsLocale: deps.windowsLocale, windowsTaskListingCache: deps.windowsTaskListingCache, + statePaths: deps.statePaths, }); } return unknown(`no service manager probe for platform ${platform}`); diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index d7e4f8935e1..8f02c41f37d 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -76,8 +76,12 @@ two naming different homes, and on macOS a logged-out user can have the plist on domain to query. The probe returns what it saw and does not decide ownership; callers such as `src/integrations/native/ownership-preflight.ts` compare the homes. Every command it runs is read-only and time-bounded, so it is safe while the proxy runs under that same manager. -On Windows, the generated-wrapper check accepts both package installs that invoke the source CLI -and standalone installs whose executable handles `start` directly; other wrapper shapes fail closed. +On Windows, the generated-wrapper check accepts package installs that invoke the source CLI. +A standalone wrapper that invokes `start` directly must carry the generated protocol and runtime +markers, one quoted `OCX_BUN` assignment, and no `OCX_CLI` assignment in either quoting form. +Its executable must be absolute, end in `.exe`, and agree with `bunPath` in every readable service +state record for the scheduler backend with `cliPath: null`. Missing, malformed, or contradictory +state leaves the probe unknown; it cannot authorize unattended native Codex writes. ## Stable service launcher (launchd and systemd) diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index d11dcef8e32..d34d28a3f53 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -384,6 +384,27 @@ describe("the Windows chain walk", () => { return path; } + function writeStandaloneState(bunPath = "C:\\OpenCodex\\ocx.exe"): string { + const path = join(home, ".opencodex", "service-state.json"); + writeFileSync(path, JSON.stringify({ + version: 2, codexHome: "C:\\Users\\ws\\.codex", + opencodexHome: "C:\\Users\\ws\\.opencodex", + backend: "scheduler", bunPath, cliPath: null, + })); + return path; + } + + function writeStandaloneWrapper(bunPath = "C:\\OpenCodex\\ocx.exe"): string { + const dir = join(home, ".opencodex"); + const wrapper = join(dir, "opencodex-service.cmd"); + mkdirSync(dir, { recursive: true }); + writeFileSync(wrapper, buildWindowsServiceScript({ + bun: bunPath, bunRuntimeSource: "standalone", cli: null, + }, 10100)); + writeWindowsTask(writeWindowsLauncher(wrapper)); + return wrapper; + } + test("the full chain is walked and the homes are extracted", () => { const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); const launcher = writeWindowsLauncher(wrapper); @@ -411,16 +432,8 @@ describe("the Windows chain walk", () => { }); test("a production-generated standalone wrapper is accepted", () => { - const dir = join(home, ".opencodex"); - const wrapper = join(dir, "opencodex-service.cmd"); - mkdirSync(dir, { recursive: true }); - writeFileSync(wrapper, buildWindowsServiceScript({ - bun: "C:\\OpenCodex\\ocx.exe", - bunRuntimeSource: "standalone", - cli: null, - }, 10100)); - const launcher = writeWindowsLauncher(wrapper); - writeWindowsTask(launcher); + writeStandaloneWrapper(); + const statePath = writeStandaloneState(); const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); const result = inspectServiceManagerInstallation({ @@ -428,6 +441,7 @@ describe("the Windows chain walk", () => { home, runRaw, winswStatus: () => "nonexistent", + statePaths: [statePath], }); expect(result.kind).toBe("present"); if (result.kind !== "present") return; @@ -435,6 +449,78 @@ describe("the Windows chain walk", () => { expect(result.claims[0].backend).toBe("scheduler"); }); + test("a direct launch without an OCX_BUN assignment is unknown", () => { + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace(/^set "OCX_BUN=.*"\r?\n/im, "")); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", statePaths: [statePath], + }).kind).toBe("unknown"); + }); + + test.each([ + ['set "OCX_WINDOWS_WRAPPER_PROTOCOL=1"\r\n', ""], + ['set "OCX_BUN_RUNTIME_SOURCE=standalone"', 'set "OCX_BUN_RUNTIME_SOURCE=bundled"'], + ['set "OCX_BUN=C:\\OpenCodex\\ocx.exe"', 'set OCX_BUN=C:\\OpenCodex\\ocx.exe'], + ])("a direct launch with a missing generator marker is unknown: %s", (original, replacement) => { + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace(original, replacement)); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", statePaths: [statePath], + }).kind).toBe("unknown"); + }); + + test("a direct launch without independent install state is unknown", () => { + writeStandaloneWrapper(); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", + statePaths: [join(home, ".opencodex", "service-state.json")], + }).kind).toBe("unknown"); + }); + + test.each(['set OCX_CLI=C:\\foreign\\index.ts', '@set "OCX_CLI=C:\\foreign\\index.ts"', 'set "OCX_CLI=C:\\foreign\\index.ts"\r\nset OCX_CLI=C:\\other\\index.ts'])( + "a direct launch with a mixed or unquoted CLI assignment is unknown: %s", assignment => { + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace(":loop", `${assignment}\r\n:loop`)); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", statePaths: [statePath], + }).kind).toBe("unknown"); + }, + ); + + test("a direct launch with an extra shell command is unknown", () => { + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( + '"%OCX_BUN%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', + '"%OCX_BUN%" start --port 10100 & echo foreign', + )); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", statePaths: [statePath], + }).kind).toBe("unknown"); + }); + + test("a foreign direct-launch executable stays unknown despite valid scheduler state", () => { + writeStandaloneWrapper("C:\\foreign\\proxy.exe"); + const statePath = writeStandaloneState(); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const deps = { + platform: "win32" as const, home, runRaw, winswStatus: () => "nonexistent" as const, + statePaths: [statePath], configDir: join(home, ".opencodex"), + }; + expect(inspectServiceManagerInstallation(deps).kind).toBe("unknown"); + expect(inspectNativeCodexOwnership({ + ...deps, currentHomes: { codexHome: "C:\\Users\\ws\\.codex", opencodexHome: "C:\\Users\\ws\\.opencodex" }, + }).ownership).toBe("unknown"); + }); + test("a source wrapper that drops its CLI argument is unknown", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( From 4ff12fa69f88021755ed2607f5344f52b259d47d Mon Sep 17 00:00:00 2001 From: lcBreathe <165003424+lcBreathe@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:37:22 +0800 Subject: [PATCH 5/8] fix(service): harden source Windows wrapper matching --- src/service-manager-probe.ts | 9 ++-- .../codex-service-manager-probe.test.ts | 46 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index fd9ba0828fe..e21125b35ad 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -576,11 +576,15 @@ function wrapperLaunchShape(body: string): "source" | { standaloneBun: string } const launchLines = body.split(/\r?\n/).filter(line => /^\s*"%OCX_BUN%"/i.test(line)); if (launchLines.length !== 1) return null; const launch = launchLines[0]!.trim(); - const sourceLaunch = /^"%OCX_BUN%" "%OCX_CLI%" start\b[^\r\n]*$/i; + const sourceLaunch = /^"%OCX_BUN%" "%OCX_CLI%" start --port ([0-9]{1,5}) >>"%OCX_SERVICE_LOG%" 2>&1$/i; const standaloneLaunch = /^"%OCX_BUN%" start --port ([0-9]{1,5}) >>"%OCX_SERVICE_LOG%" 2>&1$/i; + const bun = generatedBatchSetValue(body, "OCX_BUN"); + if (!bun) return null; const cliAssignments = body.split(/\r?\n/).filter(line => /^\s*@?set\s+"?OCX_CLI=/i.test(line)); if (cliAssignments.length > 0) { - return cliAssignments.length === 1 && generatedBatchSetValue(body, "OCX_CLI") !== null && sourceLaunch.test(launch) + const port = sourceLaunch.exec(launch)?.[1]; + return cliAssignments.length === 1 && Boolean(generatedBatchSetValue(body, "OCX_CLI")) + && Boolean(port) && Number(port) >= 1 && Number(port) <= 65535 ? "source" : null; } const port = standaloneLaunch.exec(launch)?.[1]; @@ -595,7 +599,6 @@ function wrapperLaunchShape(body: string): "source" | { standaloneBun: string } ]) { if (generatedBatchSetValue(body, name) !== value) return null; } - const bun = generatedBatchSetValue(body, "OCX_BUN"); const runtimePath = generatedBatchSetValue(body, BUN_RUNTIME_PATH_ENV); if (!bun || !runtimePath || normalizeWindowsPath(decodeBatchPathValue(bun)) !== normalizeWindowsPath(decodeBatchPathValue(runtimePath))) return null; for (const name of ["CODEX_HOME", "OPENCODEX_HOME"]) { diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index d34d28a3f53..61af317a68e 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -378,7 +378,7 @@ describe("the Windows chain walk", () => { 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", '>>"%OCX_SERVICE_LOG%" echo start', - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ); writeFileSync(path, lines.join("\r\n")); return path; @@ -542,7 +542,7 @@ describe("the Windows chain walk", () => { test("a source wrapper with an extra standalone launch is unknown", () => { const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', '"%OCX_BUN%" "%OCX_CLI%" start --port 10100\r\n"%OCX_BUN%" start --port 10100', )); const launcher = writeWindowsLauncher(wrapper); @@ -554,6 +554,33 @@ describe("the Windows chain walk", () => { }).kind).toBe("unknown"); }); + test("a source wrapper with an appended shell command is unknown", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1 & echo foreign', + )); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", + }).kind).toBe("unknown"); + }); + + test("a source wrapper without an OCX_BUN assignment is unknown", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace(/^set "OCX_BUN=.*"\r?\n/im, "")); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", + }).kind).toBe("unknown"); + }); + test("a standalone wrapper with an extra source launch is unknown", () => { const dir = join(home, ".opencodex"); const wrapper = join(dir, "opencodex-service.cmd"); @@ -564,12 +591,13 @@ describe("the Windows chain walk", () => { '"%OCX_BUN%" start --port 10100', '"%OCX_BUN%" "%OCX_CLI%" start --port 10100\r\n"%OCX_BUN%" start --port 10100', )); + const statePath = writeStandaloneState(); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); expect(inspectServiceManagerInstallation({ - platform: "win32", home, runRaw, winswStatus: () => "nonexistent", + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", statePaths: [statePath], }).kind).toBe("unknown"); }); @@ -587,7 +615,7 @@ describe("the Windows chain walk", () => { 'set "OCX_BUN=C:\\bun\\bun.exe"', 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ].join("\r\n")); const foreignLauncher = join(home, ".opencodex", "foreign-launcher.vbs"); writeFileSync(foreignLauncher, `shell.Run """${foreignWrapper}""", 0, True\r\n`); @@ -615,7 +643,7 @@ describe("the Windows chain walk", () => { 'set "OCX_BUN=C:\\bun\\bun.exe"', 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ].join("\r\n")); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); @@ -642,7 +670,7 @@ describe("the Windows chain walk", () => { 'set "OCX_BUN=C:\\bun\\bun.exe"', 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ].join("\r\n")); const launcher = writeWindowsLauncher(wrapper); writeWindowsTask(launcher); @@ -694,7 +722,7 @@ describe("the Windows chain walk", () => { 'set "OCX_BUN=C:\\bun\\bun.exe"', 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ].join("\r\n")); const launcher = join(custom, "opencodex-service-launcher.vbs"); writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); @@ -1173,7 +1201,7 @@ describe("ownership refuses what it cannot prove", () => { 'set "OCX_BUN=C:\\bun\\bun.exe"', 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ].join("\r\n")); const launcher = join(opencodexHome, "opencodex-service-launcher.vbs"); writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); @@ -1209,7 +1237,7 @@ describe("ownership refuses what it cannot prove", () => { 'set "OCX_BUN=C:\\bun\\bun.exe"', 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ].join("\r\n")); const launcher = join(opencodexHome, "opencodex-service-launcher.vbs"); writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); From 6b7b26a56100b9f1ebc92d10d83c708e8ada6f49 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 03:36:36 +0900 Subject: [PATCH 6/8] fix(service): require generated standalone wrapper control flow --- src/service-manager-probe.ts | 48 +++++++++++++++++ structure/ops/service-and-sidecars.md | 5 ++ .../codex-service-manager-probe.test.ts | 53 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index e21125b35ad..266387ebc85 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -30,6 +30,7 @@ import { decodeWindowsTextBytes } from "./lib/windows-text"; import { WINSW_SERVICE_ID } from "./lib/winsw"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "./lib/bun-runtime"; import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./service/windows-wrapper-exit"; +import { buildWindowsServiceScript } from "./service/windows-taskxml"; import { inspectServiceStateEvidence, serviceStatePathsForOpenCodexHome } from "./service/state"; /** Short: this runs inside admission, and a slow answer is the same as none. */ @@ -570,6 +571,52 @@ function generatedBatchSetValue(body: string, name: string): string | null { return assignments.length === 1 ? batchSetValue(assignments[0]!, name) : null; } +/** Compare executable lines with the actual standalone generator's ordered script. */ +function matchesGeneratedStandaloneControlFlow(body: string, port: number): boolean { + const scriptLines = (script: string): string[] => { + const lines = script.replace(/\r\n/g, "\n").split("\n"); + if (lines.at(-1) === "") lines.pop(); + return lines; + }; + const lines = scriptLines(body); + const expected = scriptLines(buildWindowsServiceScript({ + bun: "C:\\OpenCodex\\ocx.exe", bunRuntimeSource: "standalone", cli: null, + }, port, [])); + const tokenBlock = 'if exist "%OCX_API_TOKEN_FILE%" ('; + const boundary = lines.indexOf(tokenBlock); + const expectedBoundary = expected.indexOf(tokenBlock); + if (boundary < 0 || expectedBoundary < 0) return false; + const allowed = [ + "OCX_SERVICE", WINDOWS_WRAPPER_PROTOCOL_ENV, BUN_RUNTIME_SOURCE_ENV, + BUN_RUNTIME_PATH_ENV, "PATH", "CODEX_HOME", "CODEX_SQLITE_HOME", + "OPENCODEX_HOME", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "OCX_API_TOKEN_FILE", "OCX_SERVICE_LOG", "OCX_BUN", + ]; + const required = [ + "OCX_SERVICE", WINDOWS_WRAPPER_PROTOCOL_ENV, BUN_RUNTIME_SOURCE_ENV, + BUN_RUNTIME_PATH_ENV, "OCX_API_TOKEN_FILE", + "OCX_SERVICE_LOG", "OCX_BUN", + ]; + let previous = -1; + const seen = new Set(); + for (const line of lines.slice(0, boundary)) { + if (line === 'set "ERRORLEVEL="') continue; + if (!line.startsWith('set "')) continue; + const name = /^set "([A-Z_]+)=[^"\r\n]*"$/.exec(line)?.[1]; + const index = name ? allowed.indexOf(name) : -1; + if (index <= previous || !name) return false; + previous = index; + seen.add(name); + } + if (required.some(name => !seen.has(name))) return false; + const withoutPrefixSets = (scriptLines: string[], end: number): string[] => + scriptLines.filter((line, index) => index >= end || line === 'set "ERRORLEVEL="' || !line.startsWith('set "')); + const actualFlow = withoutPrefixSets(lines, boundary); + const generatedFlow = withoutPrefixSets(expected, expectedBoundary); + return actualFlow.length === generatedFlow.length + && actualFlow.every((line, index) => line === generatedFlow[index]); +} + /** Validate the generated launch shape before interpreting omitted optional homes. */ function wrapperLaunchShape(body: string): "source" | { standaloneBun: string } | null { if (!/^:loop\s*$/im.test(body)) return null; @@ -592,6 +639,7 @@ function wrapperLaunchShape(body: string): "source" | { standaloneBun: string } || !/^setlocal EnableExtensions DisableDelayedExpansion\s*$/im.test(body) || !new RegExp(`^if "%ERRORLEVEL%"=="${WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE}" goto stopped\\s*$`, "im").test(body) || !/^:stopped\s*$/im.test(body)) return null; + if (!matchesGeneratedStandaloneControlFlow(body, Number(port))) return null; for (const [name, value] of [ ["OCX_SERVICE", "1"], [WINDOWS_WRAPPER_PROTOCOL_ENV, "1"], diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 8f02c41f37d..73ca1dc91c6 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -79,9 +79,14 @@ read-only and time-bounded, so it is safe while the proxy runs under that same m On Windows, the generated-wrapper check accepts package installs that invoke the source CLI. A standalone wrapper that invokes `start` directly must carry the generated protocol and runtime markers, one quoted `OCX_BUN` assignment, and no `OCX_CLI` assignment in either quoting form. +Its executable lines and control-flow order must match the standalone script emitted by +`src/service/windows-taskxml.ts`; added jumps, exits, calls, labels, or commands make the probe unknown. Its executable must be absolute, end in `.exe`, and agree with `bunPath` in every readable service state record for the scheduler backend with `cliPath: null`. Missing, malformed, or contradictory state leaves the probe unknown; it cannot authorize unattended native Codex writes. +The state records a lexical executable path, not an install-time file identity or digest. A +retargeted junction or replacement at the same path is therefore outside this probe's evidence; +resolving the path only at probe time cannot establish which file the installer recorded. ## Stable service launcher (launchd and systemd) diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index 61af317a68e..b9902a5dc24 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -449,6 +449,59 @@ describe("the Windows chain walk", () => { expect(result.claims[0].backend).toBe("scheduler"); }); + test.each([ + "goto stopped", + "exit /b 0", + "call C:\\foreign\\proxy.cmd", + ":foreign", + "echo foreign", + ])("a standalone wrapper with an inserted control line is unknown: %s", inserted => { + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( + '"%OCX_BUN%" start --port 10100', + `${inserted}\r\n"%OCX_BUN%" start --port 10100`, + )); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", statePaths: [statePath], + }).kind).toBe("unknown"); + }); + + test("an unreachable standalone launch cannot establish native ownership", () => { + process.env.CODEX_HOME = "C:\\Users\\ws\\.codex"; + process.env.OPENCODEX_HOME = "C:\\Users\\ws\\.opencodex"; + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const deps = { + platform: "win32" as const, home, runRaw, winswStatus: () => "nonexistent" as const, + statePaths: [statePath], configDir: join(home, ".opencodex"), + currentHomes: { codexHome: process.env.CODEX_HOME, opencodexHome: process.env.OPENCODEX_HOME }, + }; + expect(inspectNativeCodexOwnership(deps).ownership).toBe("owned"); + writeFileSync(wrapper, readFileSync(wrapper, "utf8").replace( + '"%OCX_BUN%" start --port 10100', + 'goto stopped\r\n"%OCX_BUN%" start --port 10100', + )); + expect(inspectNativeCodexOwnership(deps).ownership).toBe("unknown"); + }); + + test("a production-generated source wrapper remains accepted", () => { + const dir = join(home, ".opencodex"); + const wrapper = join(dir, "opencodex-service.cmd"); + mkdirSync(dir, { recursive: true }); + writeFileSync(wrapper, buildWindowsServiceScript({ + bun: "C:\\OpenCodex\\bun.exe", bunRuntimeSource: "bundled", + cli: "C:\\OpenCodex\\src\\cli\\index.ts", + }, 10100)); + writeWindowsTask(writeWindowsLauncher(wrapper)); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + expect(inspectServiceManagerInstallation({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", + }).kind).toBe("present"); + }); + test("a direct launch without an OCX_BUN assignment is unknown", () => { const wrapper = writeStandaloneWrapper(); const statePath = writeStandaloneState(); From 08f2fb1145984482610e2e5daeb32d1a2d45a4a7 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 04:04:42 +0900 Subject: [PATCH 7/8] fix(service): validate registered Windows task action before ownership --- src/service-manager-probe.ts | 6 ++- src/service/windows-taskxml.ts | 26 ++++++++--- structure/ops/service-and-sidecars.md | 3 ++ ...ex-service-manager-probe-hardening.test.ts | 4 +- .../codex-service-manager-probe.test.ts | 45 ++++++++++++++++--- 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 266387ebc85..418987e990c 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -30,7 +30,7 @@ import { decodeWindowsTextBytes } from "./lib/windows-text"; import { WINSW_SERVICE_ID } from "./lib/winsw"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "./lib/bun-runtime"; import { WINDOWS_WRAPPER_PROTOCOL_ENV, WINDOWS_WRAPPER_STAY_OUT_EXIT_CODE } from "./service/windows-wrapper-exit"; -import { buildWindowsServiceScript } from "./service/windows-taskxml"; +import { buildWindowsServiceScript, windowsTaskActionMatches } from "./service/windows-taskxml"; import { inspectServiceStateEvidence, serviceStatePathsForOpenCodexHome } from "./service/state"; /** Short: this runs inside admission, and a slow answer is the same as none. */ @@ -894,6 +894,10 @@ function inspectWindows( const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath); if (registeredWalk.kind !== "present") return registeredWalk; const registeredClaim = registeredWalk.claims[0]; + const registeredLauncher = /"([^"]+)"/.exec(windowsTaskArguments(registration.registeredXml) ?? "")?.[1]; + if (!registeredLauncher || !windowsTaskActionMatches(registration.registeredXml, registeredLauncher)) { + return unknown("the registered scheduled-task action does not match the generated launcher action"); + } if (!homesEqual(registeredClaim.homes, stagedClaim.homes)) { return unknown("the registered scheduled task names different homes than the staged task definition"); } diff --git a/src/service/windows-taskxml.ts b/src/service/windows-taskxml.ts index 795d020a670..8884d7410ba 100644 --- a/src/service/windows-taskxml.ts +++ b/src/service/windows-taskxml.ts @@ -427,6 +427,25 @@ function taskXmlDecodedLossyValueEquals(xml: string, tag: string, expected: stri return taskXmlLossyValueEquals(taskXmlDecodeEntities(value), expected); } +/** Accept only the single Exec action emitted for this launcher's task. */ +export function windowsTaskActionMatches( + xml: string, + launcher: string, + wscript = windowsWscript(), + allowLossyPaths = false, +): boolean { + const scrubbed = taskXmlWithoutCommentsAndCdata(xml); + if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data") + || taskXmlHasPrefixedTag(scrubbed, "Actions") || taskXmlHasPrefixedTag(scrubbed, "Exec") + || taskXmlElementCount(scrubbed, "Actions") !== 1 || taskXmlElementCount(scrubbed, "Exec") !== 1) return false; + const actions = taskXmlSection(scrubbed, "Actions"); + const exec = /]*)?>([\s\S]*?)<\/Exec>/i.exec(actions); + if (!exec || actions.replace(exec[0], "").trim() !== "") return false; + const equals = allowLossyPaths ? taskXmlDecodedLossyValueEquals : taskXmlDecodedValueEquals; + return equals(exec[1]!, "Command", wscript) + && equals(exec[1]!, "Arguments", `/b /nologo "${launcher}"`); +} + export function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean { // Check the prefixed form first: treating `false` as an // omission would turn an explicitly disabled task into a healthy one. @@ -520,7 +539,6 @@ function windowsTaskRegistrationBaseHealthy( const trigger = taskXmlSection(triggers, "LogonTrigger"); const principal = taskXmlSection(scrubbed, "Principal"); const settings = taskXmlSection(scrubbed, "Settings"); - const action = taskXmlSection(scrubbed, "Exec"); // A self-closing leaves an empty section, so look for the element // itself — scoped to so a decoy elsewhere cannot satisfy it. return taskXmlElementCount(triggers, "LogonTrigger") > 0 @@ -538,11 +556,7 @@ function windowsTaskRegistrationBaseHealthy( // cannot carry when the profile is named outside the code page (#3064). Only // unrepresentable characters are forgiven; every ASCII segment and every // separator is still matched literally. - && (allowLossyPaths - ? taskXmlDecodedLossyValueEquals(action, "Command", wscript) - && taskXmlDecodedLossyValueEquals(action, "Arguments", `/b /nologo "${launcher}"`) - : taskXmlDecodedValueEquals(action, "Command", wscript) - && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`)); + && windowsTaskActionMatches(xml, launcher, wscript, allowLossyPaths); } /** Validate the security/lifecycle-critical fields of the registered scheduler task. */ diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 73ca1dc91c6..1e8deddd60f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -81,6 +81,9 @@ A standalone wrapper that invokes `start` directly must carry the generated prot markers, one quoted `OCX_BUN` assignment, and no `OCX_CLI` assignment in either quoting form. Its executable lines and control-flow order must match the standalone script emitted by `src/service/windows-taskxml.ts`; added jumps, exits, calls, labels, or commands make the probe unknown. +When Task Scheduler reports a registered task, the probe also requires its action to contain exactly +one Exec with the generated `wscript.exe` command and exact `/b /nologo` launcher arguments. +A foreign command or additional action makes ownership unknown even if the wrapper and homes agree. Its executable must be absolute, end in `.exe`, and agree with `bunPath` in every readable service state record for the scheduler backend with `cliPath: null`. Missing, malformed, or contradictory state leaves the probe unknown; it cannot authorize unattended native Codex writes. diff --git a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts index a7ece534269..b209341df50 100644 --- a/tests/codex-integration/codex-service-manager-probe-hardening.test.ts +++ b/tests/codex-integration/codex-service-manager-probe-hardening.test.ts @@ -11,6 +11,7 @@ import { } from "../../src/service-manager-probe"; import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; +import { windowsWscript } from "../../src/service/windows-scheduler"; import { getDefaultConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -74,6 +75,7 @@ function schedulerXml(launcherPath: string): string { "", " ", " ", + ` ${windowsWscript()}`, ` /b /nologo "${escaped}"`, " ", " ", @@ -99,7 +101,7 @@ function writeSchedulerChain( 'set "OCX_BUN=C:\\bun\\bun.exe"', 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', ":loop", - '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100 >>"%OCX_SERVICE_LOG%" 2>&1', ].join("\r\n")); writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); if (options.writeTaskXml !== false) writeFileSync(taskXml, schedulerXml(launcher)); diff --git a/tests/codex-integration/codex-service-manager-probe.test.ts b/tests/codex-integration/codex-service-manager-probe.test.ts index b9902a5dc24..237f1c4dbac 100644 --- a/tests/codex-integration/codex-service-manager-probe.test.ts +++ b/tests/codex-integration/codex-service-manager-probe.test.ts @@ -17,7 +17,8 @@ import { type ProbeRunner, type RawProbeRunner, } from "../../src/service-manager-probe"; -import { buildWindowsServiceScript } from "../../src/service/windows-taskxml"; +import { buildWindowsServiceScript, buildWindowsTaskXml } from "../../src/service/windows-taskxml"; +import { windowsWscript } from "../../src/service/windows-scheduler"; import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -346,7 +347,7 @@ describe("the Windows chain walk", () => { "", " ", " ", - ` C:\\WINDOWS\\System32\\wscript.exe`, + ` ${windowsWscript()}`, ` /b /nologo "${launcherPath.replace(/&/g, "&").replace(/"/g, """)}"`, " ", " ", @@ -449,6 +450,41 @@ describe("the Windows chain walk", () => { expect(result.claims[0].backend).toBe("scheduler"); }); + test("a registered production-generated standalone action establishes native ownership", () => { + process.env.CODEX_HOME = "C:\\Users\\ws\\.codex"; + process.env.OPENCODEX_HOME = "C:\\Users\\ws\\.opencodex"; + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + const launcher = join(home, ".opencodex", "opencodex-service-launcher.vbs"); + const registeredXml = buildWindowsTaskXml(wrapper, launcher, undefined, "S-1-5-21-123"); + const { runRaw } = recorder(() => ({ status: 0, stdout: registeredXml })); + expect(inspectNativeCodexOwnership({ + platform: "win32", home, runRaw, winswStatus: () => "nonexistent", statePaths: [statePath], + configDir: join(home, ".opencodex"), + currentHomes: { codexHome: process.env.CODEX_HOME, opencodexHome: process.env.OPENCODEX_HOME }, + }).ownership).toBe("owned"); + }); + + test.each([ + ["foreign command", (xml: string) => xml.replace(/[^<]*<\/Command>/, "C:\\foreign\\proxy.exe")], + ["extra Exec action", (xml: string) => xml.replace("", "C:\\foreign\\proxy.exe")], + ])("a registered standalone task with %s cannot establish native ownership", (_, mutate) => { + process.env.CODEX_HOME = "C:\\Users\\ws\\.codex"; + process.env.OPENCODEX_HOME = "C:\\Users\\ws\\.opencodex"; + const wrapper = writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + const launcher = join(home, ".opencodex", "opencodex-service-launcher.vbs"); + const registeredXml = mutate(buildWindowsTaskXml(wrapper, launcher, undefined, "S-1-5-21-123")); + const { runRaw } = recorder(() => ({ status: 0, stdout: registeredXml })); + const deps = { + platform: "win32" as const, home, runRaw, winswStatus: () => "nonexistent" as const, + statePaths: [statePath], configDir: join(home, ".opencodex"), + currentHomes: { codexHome: process.env.CODEX_HOME, opencodexHome: process.env.OPENCODEX_HOME }, + }; + expect(inspectServiceManagerInstallation(deps).kind).toBe("unknown"); + expect(inspectNativeCodexOwnership(deps).ownership).toBe("unknown"); + }); + test.each([ "goto stopped", "exit /b 0", @@ -672,10 +708,7 @@ describe("the Windows chain walk", () => { ].join("\r\n")); const foreignLauncher = join(home, ".opencodex", "foreign-launcher.vbs"); writeFileSync(foreignLauncher, `shell.Run """${foreignWrapper}""", 0, True\r\n`); - const registeredXml = [ - '', - `/b /nologo "${foreignLauncher}"`, - ].join("\n"); + const registeredXml = windowsTaskXmlFor(foreignLauncher); const { runRaw } = recorder(() => ({ status: 0, stdout: registeredXml })); const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); From 3488c24389bd62a92a5e143b4d39a1d0c0d27b05 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 30 Sep 2026 04:30:42 +0900 Subject: [PATCH 8/8] chore(service): record #6238 carry attribution Carries #6238 (Windows standalone wrapper ownership probe) with the RT6 security-review hardening commits. Co-authored-by: lcBreathe <165003424+lcBreathe@users.noreply.github.com>