diff --git a/src/integrations/native/ownership-preflight.ts b/src/integrations/native/ownership-preflight.ts index c76339270..5324c2a8a 100644 --- a/src/integrations/native/ownership-preflight.ts +++ b/src/integrations/native/ownership-preflight.ts @@ -78,6 +78,25 @@ function claimNamesDifferentHome( return false; } +/** + * Map a service-manager claim backend to the `ServiceInstallState.backend` + * value it corresponds to. `scheduler` (Task Scheduler) and `winsw` (native) + * are the two Windows manager backends; launchd/systemd claims have no Windows + * backend and can never mismatch a v2 state file. + */ +function claimBackendToStateBackend(backend: ServiceManagerClaim["backend"]): "scheduler" | "native" | null { + if (backend === "scheduler") return "scheduler"; + if (backend === "winsw") return "native"; + return null; +} + +/** True when the recorded state backend disagrees with the manager claim. Legacy v1 means scheduler. */ +function claimBackendMismatchesState(claim: ServiceManagerClaim, state: { backend?: "scheduler" | "native" }): boolean { + const expected = claimBackendToStateBackend(claim.backend); + if (expected === null) return false; + return (state.backend ?? "scheduler") !== expected; +} + export interface OwnershipDeps extends ProbeDeps { /** * Which state paths to consult. Injectable because the default set includes @@ -125,7 +144,14 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership }; } - const manager = inspectServiceManagerInstallation(deps); + // The manager assets live under the effective OPENCODEX_HOME. Production + // callers do not inject ProbeDeps.configDir, so derive it from the same + // current-home snapshot used for ownership comparison rather than silently + // falling back to /.opencodex. + const manager = inspectServiceManagerInstallation({ + ...deps, + configDir: deps.configDir ?? current.opencodexHome, + }); if (manager.kind === "unknown") { return { ownership: "unknown", reason: manager.reason }; } @@ -146,6 +172,17 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership reason: `${disagreeing.backend} is installed from ${disagreeing.definitionPath}, which names different homes than the recorded service state`, }; } + // A manager backend that disagrees with the recorded state (e.g. state says + // native/WinSW but a scheduler task is found) is an interrupted backend + // switch: it does not prove which manager owns the installation. v1 state + // predates the field and is scheduler by contract. + const stateBackendMismatch = valid.find(state => manager.claims.some(claim => claimBackendMismatchesState(claim, state.state))); + if (stateBackendMismatch) { + return { + ownership: "unknown", + reason: `the service state records backend ${stateBackendMismatch.state.backend ?? "scheduler"} but ${manager.claims[0]?.backend ?? "a service manager"} is installed`, + }; + } // Definition agrees. Valid state agreeing with it is ownership; no state at // all beside an installed definition is not, because the definition is the // claim and nothing here recorded making it. @@ -162,4 +199,4 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership return valid.length === 0 ? { ownership: "owned", reason: "no service state and no service manager claim" } : { ownership: "owned", reason: "the recorded service state names these homes" }; -} +} \ No newline at end of file diff --git a/src/service-manager-probe.ts b/src/service-manager-probe.ts index 2d4914376..cc447b644 100644 --- a/src/service-manager-probe.ts +++ b/src/service-manager-probe.ts @@ -21,7 +21,12 @@ import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, win32 as win32Path } from "node:path"; +import { + resolveTrustedWindowsSchtasksExe, + resolveTrustedWindowsSystemDirectory, +} from "./lib/windows-elevation"; +import { WINSW_SERVICE_ID } from "./lib/winsw"; /** Short: this runs inside admission, and a slow answer is the same as none. */ export const SERVICE_PROBE_TIMEOUT_MS = 2_000; @@ -60,6 +65,18 @@ export interface ProbeRunner { }; } +/** + * Windows probe runner: preserves schtasks stdout/stderr as raw bytes so the + * UTF-16LE task XML is not corrupted by a UTF-8 decode. + */ +export type RawProbeRunner = (file: string, args: readonly string[]) => { + status: number | null; + stdout: Buffer; + stderr: Buffer; + timedOut: boolean; + spawnFailed: boolean; +}; + export const defaultProbeRunner: ProbeRunner = (file, args) => { const result = spawnSync(file, [...args], { encoding: "utf8", @@ -76,11 +93,32 @@ export const defaultProbeRunner: ProbeRunner = (file, args) => { }; }; +export const defaultRawProbeRunner: RawProbeRunner = (file, args) => { + const result = spawnSync(file, [...args], { + encoding: "buffer", + windowsHide: true, + timeout: SERVICE_PROBE_TIMEOUT_MS, + }); + return { + status: result.status, + stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.alloc(0), + stderr: Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.alloc(0), + timedOut: result.signal !== null && result.error === undefined, + spawnFailed: result.error !== undefined, + }; +}; + export interface ProbeDeps { readonly run?: ProbeRunner; + /** Raw-buffer runner for bounded Windows service-manager queries. */ + readonly runRaw?: RawProbeRunner; readonly platform?: NodeJS.Platform; readonly uid?: number; readonly home?: string; + /** Effective OpenCodex config dir (OPENCODEX_HOME). Overrides `/.opencodex`. */ + readonly configDir?: string; + /** Test seam for WinSW SCM status. Production uses bounded trusted `sc.exe query`. */ + readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown"; } const LABEL = "com.opencodex.proxy"; @@ -274,24 +312,496 @@ function inspectSystemd(deps: Required>): Servic } /** - * Windows is deferred to its own phase and reports `unknown` until then. + * Windows: walk the scheduled-task definition chain and report the homes it names. + * + * The chain is not one file: the task XML names only the launcher, the launcher + * (VBS) names only the batch wrapper, and the homes live in the wrapper's + * `set "CODEX_HOME=..."` / `set "OPENCODEX_HOME=..."` lines. Parsing the XML + * and stopping would find no homes and read that as agreement, so the walk goes + * all the way to the wrapper. + * + * A `set` line is OMITTED by `buildWindowsServiceScript` when the value was + * unset at install time (windowsBatchSet returns null for empty values), so a + * missing home stays `null` — the same contract the launchd/systemd probes use — + * and a definition that names no homes cannot be mistaken for agreement. * - * Not an oversight: the definition there is a chain, not a file. The task XML - * names only the launcher, and the homes live in the batch wrapper it eventually - * runs — a probe that parsed the XML and stopped would find no homes and read - * that as agreement. Reporting `unknown` refuses unattended convergence on - * Windows, which is the safe direction while the chain walk is unwritten. + * Registration is answered by bounded `schtasks` queries so a definition staged + * on disk but never registered is still visible (the interrupted-install case). + * Every failure to ask is `unknown`, never absence. */ -function inspectWindows(): ServiceManagerInstallation { - return unknown("the Windows definition chain is not inspected yet"); +function windowsTaskName(): string { + return "opencodex-proxy"; +} + +function windowsConfigDirPath(deps: { home: string; configDir?: string }): string { + if (deps.configDir) return deps.configDir; + return join(deps.home, ".opencodex"); +} + +/** Decode an on-disk Windows text asset (task XML, VBS), which is UTF-16LE (often BOM-prefixed). */ +function decodeWindowsText(buffer: Buffer): string { + if (buffer.length === 0) return ""; + const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe; + const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff; + const looksUtf16Le = buffer.length >= 4 + && buffer[1] === 0x00 + && buffer[3] === 0x00 + && buffer[0] !== 0x00; + if (bomUtf16Le || looksUtf16Le) { + return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim(); + } + if (bomUtf16Be) { + const swapped = Buffer.alloc(buffer.length - 2); + for (let i = 2; i + 1 < buffer.length; i += 2) { + swapped[i - 2] = buffer[i + 1]!; + swapped[i - 1] = buffer[i]!; + } + return swapped.toString("utf16le").trim(); + } + return buffer.toString("utf8").replace(/^\uFEFF/, "").trim(); +} + +/** Decode the XML entities emitted by the service-definition writers. */ +function decodeXmlEntities(value: string): string { + return value + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +/** Pull the launcher path out of the task XML `` element. */ +function windowsTaskArguments(xml: string): string | null { + const match = /]*>\s*([^<]*?)\s*<\/Arguments>/i.exec(xml); + return match ? decodeXmlEntities(match[1]!.trim()) : null; +} + +/** + * Pull the wrapper path out of a VBS `shell.Run` line. + * + * `buildWindowsLauncherVbs` escapes a `"` inside a VBS string literal by + * doubling it, so a wrapper `C:\...\opencodex-service.cmd` is emitted as + * `shell.Run """C:\...\opencodex-service.cmd""", 0, True`. + */ +function vbsWrappedCommand(body: string): string | null { + const match = /\.Run\s+"""([^"]*)"""/.exec(body); + if (match) { + const unwrapped = match[1]!.trim(); + if (unwrapped.length > 0) return unwrapped; + } + const plain = /\.Run\s+"([^"]+)"/.exec(body); + return plain ? plain[1]!.trim() : null; +} + +/** Pull one `set "NAME=value"` out of a batch wrapper. */ +function batchSetValue(body: string, name: string): string | null { + const match = new RegExp(`^\\s*set\\s+"${name}=([^"]*)"\\s*$`, "im").exec(body); + return match ? match[1]!.trim() : null; +} + +/** Resolve generated batch env indirection before comparing homes. */ +function decodeBatchPathValue( + value: string, + env: Record = process.env, +): string { + const escapedPercent = "\u0000"; + const tokens: Record = { + USERPROFILE: env.USERPROFILE, + APPDATA: env.APPDATA, + LOCALAPPDATA: env.LOCALAPPDATA, + SYSTEMROOT: env.SystemRoot, + }; + return value + .replace(/%%/g, escapedPercent) + .replace(/%([A-Za-z][A-Za-z0-9_]*)%/g, (whole, name: string) => { + const resolved = tokens[name.toUpperCase()]; + return resolved === undefined ? whole : resolved; + }) + .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); +} + +function normalizeWindowsPath(value: string): string { + return win32Path.normalize(value.replace(/\//g, "\\")).replace(/[\\]+$/, "").toLowerCase(); +} + +/** True only when a definition-provided path remains inside the effective OPENCODEX_HOME. */ +function windowsPathInsideConfigDir(candidate: string, configDir: string): boolean { + const root = normalizeWindowsPath(configDir); + const path = normalizeWindowsPath(candidate); + const relative = win32Path.relative(root, path); + return relative === "" || (relative !== ".." && !relative.startsWith("..\\") && !win32Path.isAbsolute(relative)); +} + +/** Parse the first CSV field emitted by schtasks `/fo CSV`. */ +function csvFirstField(line: string): string { + const trimmed = line.trim(); + if (!trimmed.startsWith('"')) return (trimmed.split(",", 1)[0] ?? "").trim(); + let value = ""; + for (let i = 1; i < trimmed.length; i += 1) { + const ch = trimmed[i]!; + if (ch !== '"') { + value += ch; + continue; + } + if (trimmed[i + 1] === '"') { + value += '"'; + i += 1; + continue; + } + break; + } + return value; +} + +function windowsTaskListContains(body: string, taskName: string): boolean { + const target = taskName.toLowerCase(); + return body.split(/\r?\n/).some(line => { + const field = csvFirstField(line).replace(/\//g, "\\").replace(/^\\+/, ""); + return field.toLowerCase() === target; + }); +} + +/** English hosts provide a decisive fast path; other locales fall back to a full listing. */ +const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i; + +/** + * Registration state of the scheduled task. + * + * The `/xml` query gives the authoritative registered definition. A nonzero + * result is locale-dependent. English's task-not-found message is decisive; all + * other nonzero responses use a bounded full listing as the locale-neutral + * fallback, and only a successful list without our task proves absence. + */ +function probeWindowsTaskRegistration(deps: Required>): { + registered: "present" | "absent" | "unknown"; + registeredXml: string; +} { + let schtasks: string; + try { + schtasks = resolveTrustedWindowsSchtasksExe(); + } catch { + return { registered: "unknown", registeredXml: "" }; + } + + const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]); + if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" }; + if (queried.status === 0) { + const registeredXml = decodeWindowsText(queried.stdout) || decodeWindowsText(queried.stderr); + return registeredXml + ? { registered: "present", registeredXml } + : { registered: "unknown", registeredXml: "" }; + } + + const queryText = `${decodeWindowsText(queried.stdout)}\n${decodeWindowsText(queried.stderr)}`; + if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND_EN.test(queryText)) { + return { registered: "absent", registeredXml: "" }; + } + + const listed = deps.runRaw(schtasks, ["/query", "/fo", "CSV", "/nh"]); + if (listed.spawnFailed || listed.timedOut || listed.status !== 0) { + return { registered: "unknown", registeredXml: "" }; + } + const listing = decodeWindowsText(listed.stdout) || decodeWindowsText(listed.stderr); + return windowsTaskListContains(listing, windowsTaskName()) + ? { registered: "unknown", registeredXml: "" } + : { registered: "absent", registeredXml: "" }; +} + +type WinswRegistration = "present" | "absent" | "unknown"; + +/** Query WinSW registration through trusted System32 sc.exe; never execute the user-writable WinSW binary. */ +function probeWinswRegistration( + deps: Required> & Pick, +): WinswRegistration { + if (deps.winswStatus) { + const injected = deps.winswStatus(); + if (injected === "started" || injected === "stopped") return "present"; + if (injected === "nonexistent") return "absent"; + return "unknown"; + } + + let sc: string; + try { + sc = join(resolveTrustedWindowsSystemDirectory(), "sc.exe"); + if (artifactPresence(sc) !== "present") return "unknown"; + } catch { + return "unknown"; + } + + const queried = deps.runRaw(sc, ["query", WINSW_SERVICE_ID]); + if (queried.spawnFailed || queried.timedOut) return "unknown"; + if (queried.status === 0) return "present"; + + // ERROR_SERVICE_DOES_NOT_EXIST (1060) is locale-invariant. Search raw byte + // text so localized OEM output cannot affect the numeric classification. + const text = `${queried.stdout.toString("latin1")}\n${queried.stderr.toString("latin1")}`; + return /\b1060\b/.test(text) ? "absent" : "unknown"; +} + +function inspectWindows( + deps: Required> & Pick, +): ServiceManagerInstallation { + const configDir = windowsConfigDirPath(deps); + const taskXmlPath = join(configDir, "opencodex-service-task.xml"); + const task = artifactPresence(taskXmlPath); + const winsw = walkWinswChain(deps); + const winswInstalled = winsw.kind === "present" && winsw.claims[0].registration === "present"; + const winswStaged = winsw.kind === "present" && winsw.claims[0].registration === "absent"; + + let xml = ""; + if (task !== "absent") { + try { + xml = decodeWindowsText(readFileSync(taskXmlPath)); + } catch (error) { + return unknown(`the scheduled-task XML exists but could not be read: ${String(error)}`); + } + } + + const registration = probeWindowsTaskRegistration(deps); + if (registration.registered === "unknown") { + return unknown("Task Scheduler could not be asked whether opencodex-proxy is registered"); + } + const schedulerRegistered = registration.registered === "present"; + + // Two live registrations are a conflict even if the staging copy of the task + // XML disappeared. The authoritative `/query /xml` definition is sufficient + // to walk the scheduler chain without inventing homes. + if (winswInstalled && schedulerRegistered) { + if (!registration.registeredXml.trim()) { + return unknown("Task Scheduler is registered but its definition XML could not be read"); + } + const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath); + if (registeredWalk.kind !== "present") return registeredWalk; + return { + kind: "conflict", + claims: [ + winsw.claims[0], + { ...registeredWalk.claims[0], registration: "present" }, + ], + }; + } + + if (winswInstalled) { + // A staged scheduler definition beside a registered WinSW service is an + // interrupted backend switch, not proof that WinSW alone owns the machine. + if (task !== "absent") { + return unknown("a scheduled-task definition is staged while the native WinSW service is registered"); + } + return winsw; + } + + if (winsw.kind === "unknown") return winsw; + + // Staged-but-unregistered WinSW remains evidence. If Scheduler is also live + // or staged, neither half-finished backend switch can be chosen unattended. + if (winswStaged && (schedulerRegistered || task !== "absent")) { + return unknown("native WinSW and Task Scheduler definitions overlap during an incomplete backend switch"); + } + if (winswStaged && task === "absent" && registration.registered === "absent") { + return winsw; + } + + if (task === "absent") { + return schedulerRegistered + ? unknown("Task Scheduler holds opencodex-proxy but its task XML is missing") + : { kind: "absent" }; + } + + const staged = walkWindowsChain(deps, xml, taskXmlPath); + if (staged.kind !== "present") return staged; + const stagedClaim = staged.claims[0]; + + if (schedulerRegistered) { + if (!registration.registeredXml.trim()) { + return unknown("Task Scheduler is registered but its definition XML could not be read"); + } + const registeredWalk = walkWindowsChain(deps, registration.registeredXml, taskXmlPath); + if (registeredWalk.kind !== "present") return registeredWalk; + const registeredClaim = registeredWalk.claims[0]; + if (!homesEqual(registeredClaim.homes, stagedClaim.homes)) { + return unknown("the registered scheduled task names different homes than the staged task definition"); + } + } + + return { + kind: "present", + claims: [{ + ...stagedClaim, + registration: schedulerRegistered ? "present" : "absent", + }], + }; +} + +/** Compare two home pairs with Windows path normalization (case, slashes, trailing separators). */ +function homesEqual( + a: { codexHome: string | null; opencodexHome: string | null }, + b: { codexHome: string | null; opencodexHome: string | null }, +): boolean { + const norm = (v: string | null): string | null => { + if (v === null) return null; + return v.replace(/[\\/]+$/, "").replace(/\//g, "\\").toLowerCase(); + }; + return norm(a.codexHome) === norm(b.codexHome) && norm(a.opencodexHome) === norm(b.opencodexHome); +} + +/** + * Walk one scheduled-task definition (staged or registered XML) down to the + * generated batch wrapper and extract the homes it names. Definition-provided + * paths are followed only inside the effective OPENCODEX_HOME, preventing a + * foreign task from turning this ownership probe into an arbitrary local/UNC + * file read while preserving interrupted-reinstall diagnostics within the + * generated service-asset directory. + */ +function walkWindowsChain( + deps: Required> & Pick, + xml: string, + definitionPath: string, +): ServiceManagerInstallation { + const configDir = windowsConfigDirPath(deps); + + const launcherArg = windowsTaskArguments(xml); + if (!launcherArg) { + return unknown("the scheduled-task XML names no launcher to run"); + } + const launcherPath = /"([^"]+)"/.exec(launcherArg)?.[1]; + if (!launcherPath) { + return unknown("the scheduled-task XML launcher argument is not a quoted path"); + } + if (!windowsPathInsideConfigDir(launcherPath, configDir)) { + return unknown(`the scheduled-task XML names ${launcherPath}, outside the expected launcher directory ${configDir}`); + } + + const launcher = artifactPresence(launcherPath); + if (launcher === "absent") { + return unknown(`the scheduled-task launcher is missing: ${launcherPath}`); + } + let launcherBody: string; + try { + launcherBody = decodeWindowsText(readFileSync(launcherPath)); + } catch (error) { + return unknown(`the scheduled-task launcher could not be read: ${String(error)}`); + } + + const wrapperPath = vbsWrappedCommand(launcherBody); + if (!wrapperPath) { + return unknown(`the launcher ${launcherPath} names no wrapper to run`); + } + if (!windowsPathInsideConfigDir(wrapperPath, configDir)) { + return unknown(`the scheduled-task launcher names ${wrapperPath}, outside the expected wrapper directory ${configDir}`); + } + + const wrapper = artifactPresence(wrapperPath); + if (wrapper === "absent") { + return unknown(`the launcher wrapper is missing: ${wrapperPath}`); + } + let wrapperBody: string; + try { + wrapperBody = decodeWindowsText(readFileSync(wrapperPath)); + } catch (error) { + return unknown(`the launcher wrapper could not be read: ${String(error)}`); + } + + if (!wrapperLooksGenerated(wrapperBody)) { + return unknown(`the launcher wrapper does not look like a generated opencodex service wrapper: ${wrapperPath}`); + } + + const rawCodexHome = batchSetValue(wrapperBody, "CODEX_HOME"); + const rawOpencodexHome = batchSetValue(wrapperBody, "OPENCODEX_HOME"); + + return { + kind: "present", + claims: [{ + backend: "scheduler", + definitionPath, + homes: { + codexHome: rawCodexHome === null ? null : decodeBatchPathValue(rawCodexHome), + opencodexHome: rawOpencodexHome === null ? null : decodeBatchPathValue(rawOpencodexHome), + }, + registration: "absent", + }], + }; +} + +/** + * Walk the WinSW native-backend definition. SCM registration is queried via a + * trusted, bounded `sc.exe query`; the WinSW executable itself is never run by + * this read-only ownership probe. + */ +function walkWinswChain( + deps: Required> & Pick, +): ServiceManagerInstallation { + const configDir = windowsConfigDirPath(deps); + const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`); + const xmlPath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.xml`); + const xml = artifactPresence(xmlPath); + const exe = artifactPresence(exePath); + const registration = probeWinswRegistration(deps); + + if (xml === "absent" && exe === "absent" && registration === "absent") return { kind: "absent" }; + if (registration === "unknown") { + return unknown("the native WinSW service registration could not be verified"); + } + if (xml === "absent" || exe === "absent") { + return unknown("the native WinSW service registration could not be verified"); + } + + let body: string; + try { + body = decodeWindowsText(readFileSync(xmlPath)); + } catch (error) { + return unknown(`the WinSW XML could not be read: ${String(error)}`); + } + if (!winswXmlLooksGenerated(body)) { + return unknown(`the WinSW XML does not look like a generated opencodex service definition: ${xmlPath}`); + } + + const envValue = (name: string): string | null => { + const tag = new RegExp(`]*\\bname=["']${name}["'][^>]*>`, "i").exec(body); + if (!tag) return null; + const value = /value=(["'])(.*?)\1/i.exec(tag[0]); + return value ? decodeXmlEntities(value[2]!) : null; + }; + + return { + kind: "present", + claims: [{ + backend: "winsw", + definitionPath: exePath, + homes: { + codexHome: envValue("CODEX_HOME"), + opencodexHome: envValue("OPENCODEX_HOME"), + }, + registration, + }], + }; +} + +/** The generated WinSW XML embeds the SCM id and a `start --port` invocation. */ +function winswXmlLooksGenerated(body: string): boolean { + return /\s*opencodex-proxy-native\s*<\/id>/i.test(body) + && /.*?start\s+--port\b/i.test(body); } export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation { const platform = deps.platform ?? process.platform; const run = deps.run ?? defaultProbeRunner; + const runRaw = deps.runRaw ?? defaultRawProbeRunner; const home = deps.home ?? homedir(); if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home }); if (platform === "linux") return inspectSystemd({ run, home }); - if (platform === "win32") return inspectWindows(); + if (platform === "win32") { + return inspectWindows({ + runRaw, + home, + configDir: deps.configDir, + winswStatus: deps.winswStatus, + }); + } return unknown(`no service manager probe for platform ${platform}`); } diff --git a/tests/codex-service-manager-probe-hardening.test.ts b/tests/codex-service-manager-probe-hardening.test.ts new file mode 100644 index 000000000..620e40433 --- /dev/null +++ b/tests/codex-service-manager-probe-hardening.test.ts @@ -0,0 +1,311 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + inspectServiceManagerInstallation, + type RawProbeRunner, +} from "../src/service-manager-probe"; +import { inspectNativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; +import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation"; + +let home = ""; +let configDir = ""; +let trustedSystem32 = ""; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-probe-hardening-")); + configDir = join(home, "custom-opencodex"); + trustedSystem32 = join(home, "System32"); + mkdirSync(configDir, { recursive: true }); + mkdirSync(trustedSystem32, { recursive: true }); + writeFileSync(join(trustedSystem32, "schtasks.exe"), ""); + writeFileSync(join(trustedSystem32, "sc.exe"), ""); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); +}); + +afterEach(() => { + setTrustedWindowsSystemDirectoryResolverForTests(null); + rmSync(home, { recursive: true, force: true }); +}); + +function raw( + status: number | null, + stdout = "", + stderr = "", + extra: Partial> = {}, +): ReturnType { + return { + status, + stdout: Buffer.from(stdout, "utf8"), + stderr: Buffer.from(stderr, "utf8"), + timedOut: false, + spawnFailed: false, + ...extra, + }; +} + +function schedulerXml(launcherPath: string): string { + const escaped = launcherPath.replace(/&/g, "&").replace(/"/g, """); + return [ + '', + "", + " ", + " ", + ` /b /nologo "${escaped}"`, + " ", + " ", + "", + ].join("\n"); +} + +function writeSchedulerChain( + dir: string, + codexHome: string, + opencodexHome: string, + options: { writeTaskXml?: boolean } = {}, +): { launcher: string; wrapper: string; taskXml: string } { + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + const launcher = join(dir, "opencodex-service-launcher.vbs"); + const taskXml = join(dir, "opencodex-service-task.xml"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + `set "CODEX_HOME=${codexHome}"`, + `set "OPENCODEX_HOME=${opencodexHome}"`, + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + if (options.writeTaskXml !== false) writeFileSync(taskXml, schedulerXml(launcher)); + return { launcher, wrapper, taskXml }; +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function writeWinsw( + dir: string, + codexHome: string, + opencodexHome: string, +): void { + const winswDir = join(dir, "winsw"); + mkdirSync(winswDir, { recursive: true }); + writeFileSync(join(winswDir, "opencodex-proxy-native.exe"), "not-executable-test-placeholder"); + writeFileSync(join(winswDir, "opencodex-proxy-native.xml"), [ + '', + "", + " opencodex-proxy-native", + ` `, + ` `, + ' "C:\\cli\\index.ts" start --port 10100', + "", + ].join("\n")); +} + +function taskAbsentRunner(calls: Array<{ file: string; args: readonly string[] }>): RawProbeRunner { + return (file, args) => { + calls.push({ file, args }); + if (args[0]?.toLowerCase() === "query" && args.includes("/xml")) { + return raw(1, "", "ERROR: The system cannot find the file specified."); + } + if (args[0]?.toLowerCase() === "/query" && args.includes("/xml")) { + return raw(1, "", "ERROR: The system cannot find the file specified."); + } + if (args.includes("/fo")) return raw(0, ""); + return raw(1, "", "ERROR: The system cannot find the file specified."); + }; +} + +describe("Windows ownership probe hardening regressions", () => { + test("ownership inspects the effective current OPENCODEX_HOME without an injected configDir", () => { + const currentCodexHome = "C:\\current\\.codex"; + const foreignCodexHome = "C:\\foreign\\.codex"; + writeSchedulerChain(configDir, foreignCodexHome, configDir); + const statePath = join(configDir, "service-state.json"); + writeFileSync(statePath, JSON.stringify({ + version: 2, + backend: "scheduler", + codexHome: currentCodexHome, + opencodexHome: configDir, + })); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + runRaw, + winswStatus: () => "nonexistent", + statePaths: [statePath], + currentHomes: { codexHome: currentCodexHome, opencodexHome: configDir }, + }); + + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("different homes"); + }); + + test("the default WinSW registration probe uses bounded trusted sc.exe instead of executing the WinSW binary", () => { + const codexHome = "C:\\owned\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw: RawProbeRunner = (file, args) => { + calls.push({ file, args }); + if (file.toLowerCase().endsWith("sc.exe")) return raw(0, "SERVICE_NAME: opencodex-proxy-native"); + if (args.includes("/xml")) return raw(1, "", "ERROR: Das System kann die angegebene Datei nicht finden."); + if (args.includes("/fo")) return raw(0, ""); + return raw(1, "", "unexpected query"); + }; + + const result = inspectServiceManagerInstallation({ platform: "win32", home, configDir, runRaw }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].backend).toBe("winsw"); + expect(calls.some(call => call.file.toLowerCase().endsWith("sc.exe") + && call.args[0] === "query" + && call.args[1] === "opencodex-proxy-native")).toBe(true); + expect(calls.every(call => call.file.toLowerCase().includes("system32"))).toBe(true); + }); + + test("registered scheduler plus registered WinSW conflicts even when staged task XML is missing", () => { + const codexHome = "C:\\owned\\.codex"; + const scheduler = writeSchedulerChain(configDir, codexHome, configDir, { writeTaskXml: false }); + writeWinsw(configDir, codexHome, configDir); + const runRaw: RawProbeRunner = (_file, args) => { + if (args.includes("/xml")) return raw(0, schedulerXml(scheduler.launcher)); + return raw(1, "", "unexpected query"); + }; + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "started", + }); + + expect(result.kind).toBe("conflict"); + if (result.kind !== "conflict") return; + expect(result.claims.map(claim => claim.backend).sort()).toEqual(["scheduler", "winsw"]); + }); + + test("a scheduler definition cannot make the probe follow a launcher outside the generated config chain", () => { + const foreignDir = join(home, "foreign"); + const foreign = writeSchedulerChain(foreignDir, "C:\\foreign\\.codex", foreignDir); + writeFileSync(join(configDir, "opencodex-service-task.xml"), schedulerXml(foreign.launcher)); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "nonexistent", + }); + + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("expected launcher"); + }); + + test("localized schtasks task-not-found output falls back to the task listing before declaring absence", () => { + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw: RawProbeRunner = (file, args) => { + calls.push({ file, args }); + if (args.includes("/xml")) { + return raw(1, "", "FEHLER: Das System kann die angegebene Datei nicht finden."); + } + if (args.includes("/fo")) { + return raw(0, '"\\SomeOtherTask","N/A","Ready"\r\n'); + } + return raw(1, "", "unexpected query"); + }; + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "nonexistent", + }); + + expect(result.kind).toBe("absent"); + expect(calls.some(call => call.args.includes("/fo"))).toBe(true); + }); + + test("a staged but unregistered WinSW definition remains visible as a present claim", () => { + const codexHome = "C:\\staged\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "nonexistent", + }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].backend).toBe("winsw"); + expect(result.claims[0].registration).toBe("absent"); + }); + + test("legacy v1 service state means scheduler and cannot authorize a WinSW manager", () => { + const codexHome = "C:\\owned\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const statePath = join(configDir, "service-state.json"); + writeFileSync(statePath, JSON.stringify({ + version: 1, + codexHome, + opencodexHome: configDir, + })); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "started", + statePaths: [statePath], + currentHomes: { codexHome, opencodexHome: configDir }, + }); + + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("backend scheduler"); + }); + + test("WinSW home values are XML-unescaped before ownership comparison", () => { + const codexHome = "C:\\Users\\A&B\\.codex"; + writeWinsw(configDir, codexHome, configDir); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const runRaw = taskAbsentRunner(calls); + + const result = inspectServiceManagerInstallation({ + platform: "win32", + home, + configDir, + runRaw, + winswStatus: () => "started", + }); + + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes.codexHome).toBe(codexHome); + }); +}); diff --git a/tests/codex-service-manager-probe.test.ts b/tests/codex-service-manager-probe.test.ts index 006bba52e..d1c503536 100644 --- a/tests/codex-service-manager-probe.test.ts +++ b/tests/codex-service-manager-probe.test.ts @@ -15,13 +15,16 @@ import { join } from "node:path"; import { inspectServiceManagerInstallation, type ProbeRunner, + type RawProbeRunner, } from "../src/service-manager-probe"; import { inspectNativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; +import { setTrustedWindowsSystemDirectoryResolverForTests } from "../src/lib/windows-elevation"; let home = ""; const cleanup: string[] = []; let previousCodexHome: string | undefined; let previousOpencodexHome: string | undefined; +let trustedSystem32 = ""; /** Records exactly what production asked for, so the allowlist is observed. */ function recorder(reply: (file: string, args: readonly string[]) => Partial>) { @@ -30,16 +33,36 @@ function recorder(reply: (file: string, args: readonly string[]) => Partial { + calls.push({ file, args }); + const r = { status: 0, stdout: "", stderr: "", timedOut: false, spawnFailed: false, ...reply(file, args) }; + return { + status: r.status, + stdout: Buffer.from(r.stdout, "utf8"), + stderr: Buffer.from(r.stderr, "utf8"), + timedOut: r.timedOut, + spawnFailed: r.spawnFailed, + }; + }; + return { run, runRaw, calls }; } +// Linux CI fakes win32 but has no kernel32/System32, so the trusted schtasks +// resolver throws unless pointed at a fake system directory with schtasks.exe +// present. Mirror windows-elevation.test.ts: wire the resolver seam for every +// test so the Windows chain-walk suites run anywhere. beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-probe-")); cleanup.push(home); + trustedSystem32 = join(home, "System32"); + mkdirSync(trustedSystem32, { recursive: true }); + writeFileSync(join(trustedSystem32, "schtasks.exe"), ""); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); previousCodexHome = process.env.CODEX_HOME; previousOpencodexHome = process.env.OPENCODEX_HOME; }); afterEach(() => { + setTrustedWindowsSystemDirectoryResolverForTests(null); if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -213,7 +236,23 @@ describe("could not ask is not an answer", () => { * two produced it is not observable from outside, so claiming to test it * would be claiming more than this proves. */ - test("a dangling plist symlink does not read as a clean machine", () => { + // Windows without Developer Mode / elevated privileges cannot create + // symlinks (EPERM). Detect once so this test reports a visible skip there + // instead of a spurious failure. Mirrors the probe in claude-agents-inject. + const canSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), "ocx-symlink-probe-")); + try { + symlinkSync(join(dir, "probe-target"), join(dir, "probe-link")); + return true; + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === "EPERM") return false; + throw e; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + })(); + + test.skipIf(!canSymlink)("a dangling plist symlink does not read as a clean machine", () => { const agents = join(home, "Library", "LaunchAgents"); mkdirSync(agents, { recursive: true }); symlinkSync(join(home, "nothing-here.plist"), join(agents, "com.opencodex.proxy.plist")); @@ -249,11 +288,15 @@ describe("could not ask is not an answer", () => { expect(result.kind === "unknown" && result.reason).toContain("daemon-reload"); }); - test("Windows refuses rather than guessing at a chain it does not walk", () => { - // The task XML names only the launcher; the homes are in the batch wrapper. - // Parsing the XML and stopping would find no homes and read that as - // agreement, so until the chain walk exists the honest answer is unknown. - expect(inspectServiceManagerInstallation({ platform: "win32", home }).kind).toBe("unknown"); + test("Windows refuses when the chain walk finds no definition", () => { + // Nothing staged on disk, and the query is not asked (no run injected) — + // the default spawn would fail on non-Windows, so this uses the injected + // recorder to prove absence is only claimed when schtasks answers absent. + const { runRaw, calls } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("absent"); + expect(calls).toHaveLength(1); + expect(calls[0].args[0]).toBe("/query"); }); }); @@ -283,6 +326,489 @@ describe("a definition that cannot supply homes is not present", () => { }); }); +describe("the Windows chain walk", () => { + function writeWindowsTask(launcherPath: string): string { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "opencodex-service-task.xml"); + writeFileSync(path, windowsTaskXmlFor(launcherPath)); + return path; + } + + function windowsTaskXmlFor(launcherPath: string): string { + return [ + '', + "", + " ", + " ", + ` C:\\WINDOWS\\System32\\wscript.exe`, + ` /b /nologo "${launcherPath.replace(/&/g, "&").replace(/"/g, """)}"`, + " ", + " ", + "", + ].join("\n"); + } + + function writeWindowsLauncher(wrapperPath: string): string { + const path = join(home, ".opencodex", "opencodex-service-launcher.vbs"); + mkdirSync(join(home, ".opencodex"), { recursive: true }); + writeFileSync(path, [ + "Set shell = CreateObject(\"WScript.Shell\")", + `shell.Run """${wrapperPath}""", 0, True`, + ].join("\r\n")); + return path; + } + + function writeWindowsWrapper(codexHome?: string, opencodexHome?: string): string { + const path = join(home, ".opencodex", "opencodex-service.cmd"); + mkdirSync(join(home, ".opencodex"), { recursive: true }); + const lines = ["@echo off", "setlocal"]; + if (codexHome) lines.push(`set "CODEX_HOME=${codexHome}"`); + if (opencodexHome) lines.push(`set "OPENCODEX_HOME=${opencodexHome}"`); + // The tail that `buildWindowsServiceScript` emits; the probe keys wrapper + // validity on it so an empty/unrelated wrapper cannot read as an install + // that deliberately omitted both homes. + lines.push( + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '>>"%OCX_SERVICE_LOG%" echo start', + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ); + writeFileSync(path, lines.join("\r\n")); + return path; + } + + 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); + const taskXml = writeWindowsTask(launcher); + // The registered task points at the SAME launcher as the staged definition, + // so the two chains agree and the staged homes are reported. + const { runRaw, calls } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(launcher) })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims).toHaveLength(1); + expect(result.claims[0].backend).toBe("scheduler"); + expect(result.claims[0].definitionPath).toBe(taskXml); + expect(result.claims[0].registration).toBe("present"); + expect(result.claims[0].homes).toEqual({ + codexHome: "C:\\Users\\ws\\.codex", + opencodexHome: "C:\\Users\\ws\\.opencodex", + }); + // One bounded query via the trusted System32 schtasks, nothing that mutates. + expect(calls).toHaveLength(1); + const schtasksPath = calls[0].file; + expect(schtasksPath.toLowerCase().replace(/\\/g, "/")).toContain("system32/schtasks.exe"); + expect(calls[0].args).toEqual(["/query", "/tn", "opencodex-proxy", "/xml"]); + }); + + 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); + writeWindowsTask(launcher); + // The registered task (from /query /xml) points at a COMPLETE foreign chain + // whose homes differ from the staged on-disk definition — interrupted reinstall. + const foreignWrapper = join(home, ".opencodex", "foreign-wrapper.cmd"); + writeFileSync(foreignWrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=C:\\foreign\\.codex"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].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 { runRaw } = recorder(() => ({ status: 0, stdout: registeredXml })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("different homes"); + }); + + test("batch env-token homes are decoded before they are compared", () => { + // The real builder emits `%USERPROFILE%\.codex` and doubles `%` as `%%`. + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=%USERPROFILE%\\.codex"', + 'set "OPENCODEX_HOME=%%PROFILE_VAR%%\\custom"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + // %USERPROFILE% expands to the test's real home; %% stays a literal %. + const profile = process.env.USERPROFILE ?? ""; + expect(result.claims[0].homes.codexHome).toBe(`${profile}\\.codex`); + expect(result.claims[0].homes.opencodexHome).toBe(`%PROFILE_VAR%\\custom`); + }); + + test("escaped and lowercase env tokens decode correctly with a controlled env", () => { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=%%USERPROFILE%%\\literal"', + 'set "OPENCODEX_HOME=%userprofile%\\lower"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + // %%USERPROFILE%% is an escaped literal (stays %USERPROFILE%), while the + // lowercase %userprofile% is a real token and expands (case-insensitive). + expect(result.claims[0].homes.codexHome).toBe(`%USERPROFILE%\\literal`); + expect(result.claims[0].homes.opencodexHome).toBe(`${process.env.USERPROFILE ?? ""}\\lower`); + }); + + test("a wrapper with :loop and rem bun is not a generated wrapper", () => { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=C:\\x\\.codex"', + ":loop", + "rem bun start --port 10100", + ].join("\r\n")); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + }); + + test("scheduler assets are located under the effective OPENCODEX_HOME", () => { + // A decoy chain in the DEFAULT /.opencodex location must NOT win over + // the customized OPENCODEX_HOME passed as configDir. + const decoyWrapper = writeWindowsWrapper("C:\\decoy\\.codex", "C:\\decoy\\.opencodex"); + const decoyLauncher = writeWindowsLauncher(decoyWrapper); + writeWindowsTask(decoyLauncher); + + // The real custom config dir (customized OPENCODEX_HOME) holds the actual chain. + const custom = join(home, "custom-home"); + const wrapper = join(custom, "opencodex-service.cmd"); + mkdirSync(custom, { recursive: true }); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + 'set "CODEX_HOME=C:\\custom\\.codex"', + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = join(custom, "opencodex-service-launcher.vbs"); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + writeFileSync(join(custom, "opencodex-service-task.xml"), [ + '', + `/b /nologo "${launcher}"`, + ].join("\n")); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent", configDir: custom }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + // The custom chain's homes win over the default-mirror decoy. + expect(result.claims[0].homes.codexHome).toBe("C:\\custom\\.codex"); + }); + + test("a malformed wrapper is unknown, not a deliberate omission", () => { + // A truncated wrapper with set lines but no generated tail is NOT a + // legitimate install that omitted the homes — it is residue. + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, "@echo off\r\nsetlocal\r\nset \"CODEX_HOME=C:\\x\\.codex\""); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason) + .toContain("the launcher wrapper does not look like a generated opencodex service wrapper"); + }); + + test("an empty wrapper is unknown, not absence", () => { + const dir = join(home, ".opencodex"); + mkdirSync(dir, { recursive: true }); + const wrapper = join(dir, "opencodex-service.cmd"); + writeFileSync(wrapper, ""); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + }); + + test("a staged task that was never registered is present but registration=absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].registration).toBe("absent"); + }); + + test("an omitted home in the wrapper stays null, not empty", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes.codexHome).toBe("C:\\a\\.codex"); + expect(result.claims[0].homes.opencodexHome).toBeNull(); + }); + + test("a broken link in the chain is unknown, not absence", () => { + // Task XML points at a launcher that does not exist. + const missing = join(home, ".opencodex", "no-such.vbs"); + writeWindowsTask(missing); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("launcher"); + }); + + test("a UTF-16LE task XML on disk is decoded before the chain is walked", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + const xml = [ + '', + "", + " ", + " ", + ` /b /nologo "${launcher.replace(/&/g, "&").replace(/"/g, """)}"`, + " ", + " ", + "", + ].join("\n"); + writeFileSync(join(home, ".opencodex", "opencodex-service-task.xml"), Buffer.from(`\uFEFF${xml}`, "utf16le")); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); + }); + + test("a UTF-16LE VBS launcher is decoded before the chain is walked", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + // Overwrite the launcher with a UTF-16LE encoding, like the real install. + const launcher = join(home, ".opencodex", "opencodex-service-launcher.vbs"); + writeFileSync(launcher, Buffer.from( + `\uFEFF' launcher\r\nSet shell = CreateObject("WScript.Shell")\r\nshell.Run """${wrapper}""", 0, True\r\n`, + "utf16le", + )); + const xml = join(home, ".opencodex", "opencodex-service-task.xml"); + writeFileSync(xml, [ + '', + `/b /nologo "${launcher.replace(/&/g, "&").replace(/"/g, """)}"`, + ].join("\n")); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); + }); + + test("a task registered but with no XML on disk is unknown", () => { + const { runRaw } = recorder(() => ({ status: 0, stdout: "" })); + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("missing"); + }); + + test("an unaskable schtasks is unknown even with a full chain", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: null, spawnFailed: true, stderr: "spawn ENOENT" })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + }); + + /* + * schtasks exits 1 for BOTH "task not found" and "access denied"; only the + * stderr message distinguishes them. A nonzero exit whose message does NOT + * state the task is missing cannot be treated as absence — a locked-down Task + * Scheduler would otherwise read as a clean machine and an unattended write + * would proceed into a home another process owns. + */ + test("an access-denied schtasks response is unknown, not absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: Access is denied. (0x80070005)" })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("could not be asked"); + }); + + test("a null-status schtasks response with no stderr is unknown, not absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: null, stderr: "" })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + }); + + test("a timed-out schtasks response is unknown, not absent", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + const { runRaw } = recorder(() => ({ status: null, timedOut: true })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + }); + + test("a registered task whose launcher is missing is unknown", () => { + const wrapper = writeWindowsWrapper("C:\\Users\\ws\\.codex", "C:\\Users\\ws\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + // The registered task points at a launcher that does not exist — the + // registered definition cannot be trusted, so the probe fails closed. + const missingLauncher = join(home, ".opencodex", "no-such.vbs"); + const { runRaw } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(missingLauncher) })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "nonexistent" }); + expect(result.kind).toBe("unknown"); + expect(result.kind === "unknown" && result.reason).toContain("launcher"); + }); + + function writeWinswXml(dir: string, codexHome: string, opencodexHome: string): void { + // The probe resolves the winsw dir under the effective config dir + // (default /.opencodex/winsw), matching winswDir() in src/lib/winsw.ts. + const winswDir = join(dir, ".opencodex", "winsw"); + mkdirSync(winswDir, { recursive: true }); + // The exe must be present for the SCM claim to be verifiable (fail-closed). + writeFileSync(join(winswDir, "opencodex-proxy-native.exe"), "placeholder"); + writeFileSync(join(winswDir, "opencodex-proxy-native.xml"), [ + '', + "", + " opencodex-proxy-native", + ` `, + ` `, + ' "C:\\cli\\index.ts" start --port 10100', + "", + ].join("\n")); + } + + test("WinSW and Task Scheduler both present is a conflict", () => { + const wrapper = writeWindowsWrapper("C:\\a\\.codex", "C:\\a\\.opencodex"); + const launcher = writeWindowsLauncher(wrapper); + writeWindowsTask(launcher); + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(launcher) })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "started" }); + expect(result.kind).toBe("conflict"); + if (result.kind !== "conflict") return; + // The staged scheduler claim is real (not fabricated null homes) and + // registered. + expect(result.claims).toHaveLength(2); + expect(result.claims[0].backend).toBe("winsw"); + expect(result.claims[1].backend).toBe("scheduler"); + expect(result.claims[1].registration).toBe("present"); + expect(result.claims[1].homes).toEqual({ codexHome: "C:\\a\\.codex", opencodexHome: "C:\\a\\.opencodex" }); + }); + + test("a broken scheduler chain alongside WinSW is unknown, not a fabricated conflict", () => { + // WinSW is installed and a scheduler task is registered, but the staged + // scheduler chain is broken (launcher missing). The probe must not + // fabricate a null-homes scheduler claim — it fails closed. + writeWindowsTask(join(home, ".opencodex", "no-such.vbs")); + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 0, stdout: windowsTaskXmlFor(join(home, ".opencodex", "no-such.vbs")) })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "started" }); + expect(result.kind).toBe("unknown"); + }); + + test("WinSW homes are parsed from the XML env entries", () => { + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "stopped" }); + expect(result.kind).toBe("present"); + if (result.kind !== "present") return; + expect(result.claims[0].backend).toBe("winsw"); + expect(result.claims[0].homes).toEqual({ codexHome: "C:\\winsw\\.codex", opencodexHome: "C:\\winsw\\.opencodex" }); + }); + + test("WinSW with unverifiable SCM state is unknown", () => { + writeWinswXml(home, "C:\\winsw\\.codex", "C:\\winsw\\.opencodex"); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "unknown" }); + expect(result.kind).toBe("unknown"); + }); + + test("WinSW XML present but exe missing is unknown (fail closed)", () => { + // Only the XML exists (no exe): the SCM claim cannot be verified, so the + // probe must refuse rather than report an owned WinSW backend. + const winswDir = join(home, ".opencodex", "winsw"); + mkdirSync(winswDir, { recursive: true }); + writeFileSync(join(winswDir, "opencodex-proxy-native.xml"), [ + '', + "", + " opencodex-proxy-native", + ' ', + ' "C:\\cli\\index.ts" start --port 10100', + "", + ].join("\n")); + const { runRaw } = recorder(() => ({ status: 1, stderr: "ERROR: The system cannot find the file specified." })); + + const result = inspectServiceManagerInstallation({ platform: "win32", home, runRaw, winswStatus: () => "stopped" }); + expect(result.kind).toBe("unknown"); + }); +}); + describe("ownership refuses what it cannot prove", () => { /* * The default state paths include the DEFAULT home mirror, resolved from @@ -312,9 +838,12 @@ describe("ownership refuses what it cannot prove", () => { return { codexHome, opencodexHome }; } + // The darwin ownership tests drive a launchd claim; a launchd install writes + // a v1 state file (no `backend` field — that is Windows-only). v2 without a + // backend would be malformed, so this writes v1. function writeState(dir: string, codexHome: string, opencodexHome: string): void { writeFileSync(join(dir, "service-state.json"), JSON.stringify({ - version: 2, codexHome, opencodexHome, backend: "scheduler", + version: 1, codexHome, opencodexHome, })); } @@ -401,4 +930,81 @@ describe("ownership refuses what it cannot prove", () => { const { run } = recorder(() => ({ status: 112, stderr: "Could not find domain for user" })); expect(inspectNativeCodexOwnership(own({ run })).ownership).toBe("owned"); }); + + /* + * The interrupted-backend-switch case: a v2 state file records backend + * "native" (WinSW) but the probe finds a scheduler task. The homes agree, but + * the manager backend does not, so ownership cannot be proven. + */ + test("a state backend that disagrees with the installed manager is unknown", () => { + const { codexHome, opencodexHome } = useHomes(); + mkdirSync(opencodexHome, { recursive: true }); + writeFileSync(join(opencodexHome, "service-state.json"), JSON.stringify({ + version: 2, codexHome, opencodexHome, backend: "native", + })); + // A scheduler claim whose homes agree with the state. + const wrapper = join(opencodexHome, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + `set "CODEX_HOME=${codexHome}"`, + `set "OPENCODEX_HOME=${opencodexHome}"`, + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = join(opencodexHome, "opencodex-service-launcher.vbs"); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + writeFileSync(join(opencodexHome, "opencodex-service-task.xml"), [ + '', + `/b /nologo "${launcher}"`, + ].join("\n")); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + runRaw: () => ({ status: 1, stderr: Buffer.from("ERROR: The system cannot find the file specified."), stdout: Buffer.alloc(0), timedOut: false, spawnFailed: false }), + winswStatus: () => "nonexistent", + statePaths: [join(opencodexHome, "service-state.json")], + currentHomes: { codexHome, opencodexHome }, + }); + expect(result.ownership).toBe("unknown"); + expect(result.reason).toContain("backend"); + }); + + test("a state backend that agrees with the installed manager is owned", () => { + const { codexHome, opencodexHome } = useHomes(); + mkdirSync(opencodexHome, { recursive: true }); + writeFileSync(join(opencodexHome, "service-state.json"), JSON.stringify({ + version: 2, codexHome, opencodexHome, backend: "scheduler", + })); + const wrapper = join(opencodexHome, "opencodex-service.cmd"); + writeFileSync(wrapper, [ + "@echo off", + "setlocal", + `set "CODEX_HOME=${codexHome}"`, + `set "OPENCODEX_HOME=${opencodexHome}"`, + 'set "OCX_BUN=C:\\bun\\bun.exe"', + 'set "OCX_CLI=C:\\opencodex\\src\\cli\\index.ts"', + ":loop", + '"%OCX_BUN%" "%OCX_CLI%" start --port 10100', + ].join("\r\n")); + const launcher = join(opencodexHome, "opencodex-service-launcher.vbs"); + writeFileSync(launcher, `shell.Run """${wrapper}""", 0, True\r\n`); + writeFileSync(join(opencodexHome, "opencodex-service-task.xml"), [ + '', + `/b /nologo "${launcher}"`, + ].join("\n")); + + const result = inspectNativeCodexOwnership({ + platform: "win32", + home, + runRaw: () => ({ status: 1, stderr: Buffer.from("ERROR: The system cannot find the file specified."), stdout: Buffer.alloc(0), timedOut: false, spawnFailed: false }), + winswStatus: () => "nonexistent", + statePaths: [join(opencodexHome, "service-state.json")], + currentHomes: { codexHome, opencodexHome }, + }); + expect(result.ownership).toBe("owned"); + }); });