diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index d3d722bcfb5..418987e990c 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -28,6 +28,10 @@ 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 { 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. */ export const SERVICE_PROBE_TIMEOUT_MS = 2_000; @@ -192,6 +196,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,9 +565,95 @@ function decodeBatchPathValue( .replaceAll(escapedPercent, "%"); } -/** 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); +/** 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; +} + +/** 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; + 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 --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) { + 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]; + 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; + if (!matchesGeneratedStandaloneControlFlow(body, Number(port))) 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 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 { @@ -711,7 +803,7 @@ function probeWinswRegistration( function inspectWindows( deps: Required> - & Pick, + & Pick, ): ServiceManagerInstallation { const configDir = windowsConfigDirPath(deps); const taskXmlPath = join(configDir, "opencodex-service-task.xml"); @@ -802,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"); } @@ -837,7 +933,7 @@ function homesEqual( * generated service-asset directory. */ function walkWindowsChain( - deps: Required> & Pick, + deps: Required> & Pick, xml: string, definitionPath: string, ): ServiceManagerInstallation { @@ -885,9 +981,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"); @@ -992,6 +1100,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/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 375e0ed055a..1e8deddd60f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -76,6 +76,20 @@ 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 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. +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. +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-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 6c2281d5ea5..237f1c4dbac 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,8 @@ import { type ProbeRunner, type RawProbeRunner, } from "../../src/service-manager-probe"; +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"; @@ -345,7 +347,7 @@ describe("the Windows chain walk", () => { "", " ", " ", - ` C:\\WINDOWS\\System32\\wscript.exe`, + ` ${windowsWscript()}`, ` /b /nologo "${launcherPath.replace(/&/g, "&").replace(/"/g, """)}"`, " ", " ", @@ -377,12 +379,33 @@ 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; } + 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); @@ -409,6 +432,264 @@ describe("the Windows chain walk", () => { expect(calls[0].args).toEqual(["/query", "/tn", "opencodex-proxy", "/xml"]); }); + test("a production-generated standalone wrapper is accepted", () => { + writeStandaloneWrapper(); + const statePath = writeStandaloneState(); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + runRaw, + winswStatus: () => "nonexistent", + statePaths: [statePath], + }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims).toHaveLength(1); + 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", + "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(); + 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( + '"%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 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_SERVICE_LOG%" 2>&1', + '"%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 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"); + 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 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", statePaths: [statePath], + }).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); @@ -423,14 +704,11 @@ 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`); - 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" }); @@ -451,7 +729,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); @@ -478,7 +756,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); @@ -530,7 +808,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`); @@ -1009,7 +1287,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`); @@ -1045,7 +1323,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`);