diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs index f76fbb0ed..5b03d2edd 100644 --- a/scripts/verify-real-ghidra.mjs +++ b/scripts/verify-real-ghidra.mjs @@ -187,6 +187,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) { bridgeScriptPath: fileURLToPath( new URL("../bridge/ghidra/ReaGhidraBridge.java", import.meta.url), ), + platform: installation.platform, }), targetPath: parsedTarget.value.path, targetSha256: parsedTarget.value.sha256, diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index ee7929d08..18e5c86e3 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -1,11 +1,15 @@ import { mkdir } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, win32 } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { AnalysisCancelledError } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; -import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; +import { + cleanupOwnedProcessGroup, + cleanupWindowsProcessTree, + type ProcessCleanupResult, +} from "../process/ProcessOwnership.js"; import { type ProviderProcessLaunch, type SpawnedOwnedProviderProcess, @@ -60,6 +64,8 @@ export interface GhidraHeadlessLauncherOptions { readonly analyzeHeadlessPath: string; readonly javaHome?: string; readonly bridgeScriptPath: string; + readonly platform?: NodeJS.Platform; + readonly comSpec?: string; } /** Launch Ghidra without copying scripts into or modifying its installation. */ @@ -73,6 +79,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { if (isAborted(options.signal)) return err(new AnalysisCancelledError("open_binary")); const paths = ghidraRuntimePaths(session.runtimeRoot); + const platform = this.options.platform ?? process.platform; let started: SpawnedOwnedProviderProcess | undefined; try { await Promise.all( @@ -101,21 +108,30 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ); if (isAborted(options.signal)) return err(new AnalysisCancelledError("open_binary")); + const headlessArguments = ghidraHeadlessArguments({ + projectRoot: paths.projectRoot, + targetPath: session.targetPath, + bridgeScriptPath: this.options.bridgeScriptPath, + descriptorPath: paths.descriptorPath, + ghidraLogPath: paths.ghidraLogPath, + scriptLogPath: paths.scriptLogPath, + }); + const command = ghidraHeadlessCommand({ + platform, + analyzeHeadlessPath: this.options.analyzeHeadlessPath, + arguments: headlessArguments, + ...(this.options.comSpec === undefined + ? {} + : { comSpec: this.options.comSpec }), + }); started = await spawnOwnedProviderProcess({ - command: this.options.analyzeHeadlessPath, - arguments: ghidraHeadlessArguments({ - projectRoot: paths.projectRoot, - targetPath: session.targetPath, - bridgeScriptPath: this.options.bridgeScriptPath, - descriptorPath: paths.descriptorPath, - ghidraLogPath: paths.ghidraLogPath, - scriptLogPath: paths.scriptLogPath, - }), + command: command.command, + arguments: command.arguments, runId: session.runId, // analyzeHeadless is an interpreter-driven script. Parent identity and // the per-process run token remain the cleanup authority. expectedCommand: null, - env: ghidraLaunchEnvironment(paths, this.options.javaHome), + env: ghidraLaunchEnvironment(paths, this.options.javaHome, platform), }); await writeFileAtomic( paths.ownershipPath, @@ -125,29 +141,31 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { pid: started.ownership.leaderPid, process_group_id: started.ownership.processGroupId, parent_pid: process.pid, + ownership_kind: + platform === "win32" + ? "windows-process-tree-p0" + : "posix-process-group", launcher: this.options.analyzeHeadlessPath, created_at: new Date().toISOString(), })}\n`, { encoding: "utf8", mode: 0o600 }, ); if (isAborted(options.signal)) { - await cleanupOwnedProcessGroup(started.ownership); + await cleanupStartedProcess(started, platform); return err(new AnalysisCancelledError("open_binary")); } - const ownership = started.ownership; + const spawned = started; return ok({ - process: started.process, + process: spawned.process, ownsProcessLifetime: true, - cleanup: () => cleanupOwnedProcessGroup(ownership), + cleanup: () => cleanupStartedProcess(spawned, platform), projectRoot: paths.projectRoot, ghidraLogPath: paths.ghidraLogPath, scriptLogPath: paths.scriptLogPath, }); } catch (cause: unknown) { if (started !== undefined) - await cleanupOwnedProcessGroup(started.ownership).catch( - () => undefined, - ); + await cleanupStartedProcess(started, platform).catch(() => undefined); return isAborted(options.signal) ? err(new AnalysisCancelledError("open_binary")) : err( @@ -157,6 +175,67 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { } } +/** Exact executable and argv passed to shell-free process creation. */ +export interface GhidraHeadlessCommand { + readonly command: string; + readonly arguments: readonly string[]; +} + +/** Build a direct POSIX launch or a conservatively quoted Windows batch call. */ +export const ghidraHeadlessCommand = (options: { + readonly platform: NodeJS.Platform; + readonly analyzeHeadlessPath: string; + readonly arguments: readonly string[]; + readonly comSpec?: string; +}): GhidraHeadlessCommand => { + if (options.platform !== "win32") + return { + command: options.analyzeHeadlessPath, + arguments: [...options.arguments], + }; + const comSpec = + options.comSpec ?? + process.env.ComSpec ?? + (process.env.SystemRoot === undefined + ? "C:\\Windows\\System32\\cmd.exe" + : win32.join(process.env.SystemRoot, "System32", "cmd.exe")); + if ( + !win32.isAbsolute(comSpec) || + win32.basename(comSpec).toLowerCase() !== "cmd.exe" + ) + throw new GhidraLaunchError( + "Windows ComSpec must be an absolute cmd.exe path", + ); + const tokens = [options.analyzeHeadlessPath, ...options.arguments].map( + quoteWindowsBatchToken, + ); + const invocation = `"${tokens.join(" ")}"`; + if (invocation.length > 30_000) + throw new GhidraLaunchError( + "Windows Ghidra command exceeds the P0 length limit", + ); + return { + command: comSpec, + arguments: ["/d", "/e:on", "/v:off", "/s", "/c", invocation], + }; +}; + +const quoteWindowsBatchToken = (value: string): string => { + if (value.length === 0 || /[\u0000\r\n"%^!&|<>]/u.test(value)) + throw new GhidraLaunchError( + "Windows Ghidra P0 paths cannot contain command-interpreter metacharacters", + ); + return `"${value}"`; +}; + +const cleanupStartedProcess = ( + started: SpawnedOwnedProviderProcess, + platform: NodeJS.Platform, +): Promise => + platform === "win32" + ? cleanupWindowsProcessTree(started.ownership.leaderPid) + : cleanupOwnedProcessGroup(started.ownership); + /** Paths encoded into one bounded analyzeHeadless invocation. */ export interface GhidraHeadlessArgumentOptions { readonly projectRoot: string; @@ -208,15 +287,29 @@ const ghidraRuntimePaths = (runtimeRoot: string) => ({ const ghidraLaunchEnvironment = ( paths: ReturnType, javaHome: string | undefined, -): NodeJS.ProcessEnv => ({ - ...ghidraJavaEnvironment(javaHome), - HOME: paths.homeRoot, - TMPDIR: paths.tempRoot, - XDG_CACHE_HOME: paths.cacheRoot, - XDG_CONFIG_HOME: paths.configRoot, - XDG_DATA_HOME: paths.dataRoot, - GHIDRA_HEADLESS_MAXMEM: GHIDRA_MAX_HEAP, - GHIDRA_HEADLESS_JAVA_OPTIONS: `-Duser.home=${paths.homeRoot} -Djava.io.tmpdir=${paths.tempRoot}`, -}); + platform: NodeJS.Platform, +): NodeJS.ProcessEnv => { + const javaOptions = + platform === "win32" + ? `"-Duser.home=${paths.homeRoot}" "-Djava.io.tmpdir=${paths.tempRoot}"` + : `-Duser.home=${paths.homeRoot} -Djava.io.tmpdir=${paths.tempRoot}`; + return { + ...ghidraJavaEnvironment(javaHome, process.env, platform), + HOME: paths.homeRoot, + TMPDIR: paths.tempRoot, + ...(platform === "win32" + ? { + USERPROFILE: paths.homeRoot, + TEMP: paths.tempRoot, + TMP: paths.tempRoot, + } + : {}), + XDG_CACHE_HOME: paths.cacheRoot, + XDG_CONFIG_HOME: paths.configRoot, + XDG_DATA_HOME: paths.dataRoot, + GHIDRA_HEADLESS_MAXMEM: GHIDRA_MAX_HEAP, + GHIDRA_HEADLESS_JAVA_OPTIONS: javaOptions, + }; +}; const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 527f633aa..6cce99a1f 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -109,6 +109,11 @@ const healthLimitations = Object.freeze([ "The session serves operations only after default Ghidra auto-analysis completes; an analysis timeout fails the open instead of exposing partial results.", "The imported Program and temporary project are ephemeral, read-only to REA, and deleted on close.", ]); +const windowsP0Limitations = Object.freeze([ + "Windows Ghidra P0 accepts approved native x86-64 PE applications only; DLL, managed, hostile, sensitive, and mutable-path targets are unsupported.", + "The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.", + "Windows P0 process-tree cleanup uses bounded taskkill termination and does not claim Job Object ownership, private DACL enforcement, or reparse-point-safe path authority.", +]); const limitationsFor = (operation: string): readonly string[] => { const common = [ @@ -228,6 +233,17 @@ const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( }), ), ); +const WINDOWS_P0_CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( + CAPABILITIES.map((capability) => + Object.freeze({ + ...capability, + limitations: Object.freeze([ + ...capability.limitations, + ...windowsP0Limitations, + ]), + }), + ), +); const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]); const SUPPORTED_FORMATS = new Set(["elf", "pe", "mach-o"]); @@ -254,7 +270,9 @@ export class GhidraProvider implements AnalysisProviderCandidate { } capabilities(): readonly CapabilityDescriptor[] { - return CAPABILITIES; + return (this.installationHost?.platform ?? process.platform) === "win32" + ? WINDOWS_P0_CAPABILITIES + : CAPABILITIES; } inspectAvailability(): ProviderAvailability { @@ -345,6 +363,8 @@ export class GhidraProvider implements AnalysisProviderCandidate { ); if (!prerequisites.ok) return unavailableClient(prerequisites.error); const committedProfile = prerequisites.value.profile; + const providerLimitations = + installation.platform === "win32" ? windowsP0Limitations : []; const client = this.clientFactory({ launcher: new GhidraHeadlessLauncher({ analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath, @@ -354,6 +374,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { bridgeScriptPath: fileURLToPath( new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url), ), + platform: installation.platform, }), targetPath: target.path, targetSha256: target.sha256, @@ -386,7 +407,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { return ok( createAnalysisExecution(started.value, committedProfile.provider, { analysisProfile: committedProfile, - limitations: healthLimitations, + limitations: [...healthLimitations, ...providerLimitations], }), ); } @@ -410,7 +431,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { createAnalysisExecution(result.value, committedProfile.provider, { rawResult: called.value, analysisProfile: committedProfile, - limitations: limitationsFor(operation), + limitations: [...limitationsFor(operation), ...providerLimitations], }), ); }, diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index 490b3e337..0852aa9f0 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -39,6 +39,11 @@ export interface ProcessOwnershipHost { signalGroup(processGroupId: number, signal: NodeJS.Signals): void; } +/** Narrow Windows P0 seam for bounded process-tree termination. */ +export interface WindowsProcessTreeHost { + terminateTree(rootPid: number): Promise<"terminated" | "missing">; +} + /** Per-member reason that token-verified cleanup failed closed. */ interface ProcessOwnershipValidationFailure { readonly pid: number; @@ -63,6 +68,30 @@ export type ProcessGroupObservation = | { readonly state: "alive" } | { readonly state: "unverifiable"; readonly reason: string }; +/** + * Terminate one Windows process tree through the platform utility. + * + * This is a bounded P0 cleanup mechanism, not Job Object ownership proof. The + * caller-visible Windows limitations must preserve that distinction. + */ +export const cleanupWindowsProcessTree = async ( + rootPid: number, + host: WindowsProcessTreeHost = systemWindowsProcessTreeHost, +): Promise => { + if (!Number.isSafeInteger(rootPid) || rootPid <= 0) + return { cleaned: false, reason: "Windows process-tree PID is invalid" }; + try { + const result = await host.terminateTree(rootPid); + return { cleaned: true, signaled: result === "terminated" }; + } catch { + return { + cleaned: false, + reason: + "Windows P0 process-tree termination failed; Job Object ownership is unavailable", + }; + } +}; + /** Select the PTY root group and groups led by an observed captured process. */ export const selectCapturedProcessGroupIds = ( rootPid: number, @@ -135,6 +164,27 @@ const systemHost: ProcessOwnershipHost = { }, }; +const systemWindowsProcessTreeHost: WindowsProcessTreeHost = { + async terminateTree(rootPid) { + try { + await execFileAsync( + "taskkill.exe", + ["/pid", String(rootPid), "/t", "/f"], + { windowsHide: true, timeout: 5_000 }, + ); + return "terminated"; + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === 128 || cause.code === "ESRCH") + ) + return "missing"; + throw cause; + } + }, +}; + /** Read the capture run token currently exposed by one live process. */ export const readProcessRunId = async ( pid: number, diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index be3b4d01f..6891efff0 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -105,6 +105,7 @@ export const spawnOwnedProviderProcess = async ( ): Promise => { const child = spawn(options.command, [...options.arguments], { shell: false, + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", env: { diff --git a/tests/fixtures/captureGhidraLaunch.cmd b/tests/fixtures/captureGhidraLaunch.cmd new file mode 100644 index 000000000..e73e9b9f0 --- /dev/null +++ b/tests/fixtures/captureGhidraLaunch.cmd @@ -0,0 +1,2 @@ +@echo off +node "%~dp0captureGhidraLaunch.mjs" %* diff --git a/tests/fixtures/captureGhidraLaunch.mjs b/tests/fixtures/captureGhidraLaunch.mjs index 0a5d88598..556c47316 100755 --- a/tests/fixtures/captureGhidraLaunch.mjs +++ b/tests/fixtures/captureGhidraLaunch.mjs @@ -14,7 +14,10 @@ await writeFile( arguments: arguments_, environment: { HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, TMPDIR: process.env.TMPDIR, + TEMP: process.env.TEMP, + TMP: process.env.TMP, XDG_CACHE_HOME: process.env.XDG_CACHE_HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME, diff --git a/tests/ghidraLauncher.test.ts b/tests/ghidraLauncher.test.ts index 18285fb73..8e30f461c 100644 --- a/tests/ghidraLauncher.test.ts +++ b/tests/ghidraLauncher.test.ts @@ -7,15 +7,23 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { ghidraHeadlessArguments, + ghidraHeadlessCommand, GhidraHeadlessLauncher, } from "../src/ghidra/GhidraLauncher.js"; const fixturePath = fileURLToPath( - new URL("./fixtures/captureGhidraLaunch.mjs", import.meta.url), + new URL( + process.platform === "win32" + ? "./fixtures/captureGhidraLaunch.cmd" + : "./fixtures/captureGhidraLaunch.mjs", + import.meta.url, + ), ); const roots: string[] = []; -beforeAll(() => chmod(fixturePath, 0o755)); +beforeAll(async () => { + if (process.platform !== "win32") await chmod(fixturePath, 0o755); +}); afterEach(async () => { vi.unstubAllEnvs(); await Promise.all( @@ -57,6 +65,48 @@ describe("Ghidra headless launcher", () => { ]); }); + it("wraps the Windows batch launcher without enabling a Node shell", () => { + const command = ghidraHeadlessCommand({ + platform: "win32", + analyzeHeadlessPath: + "C:\\Program Files\\Ghidra 12.1.2\\support\\analyzeHeadless.bat", + arguments: [ + "C:\\REA Runtime\\project", + "rea-project", + "-import", + "C:\\REA Runtime\\target.exe", + ], + comSpec: "C:\\Windows\\System32\\cmd.exe", + }); + + expect(command).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + arguments: [ + "/d", + "/e:on", + "/v:off", + "/s", + "/c", + '""C:\\Program Files\\Ghidra 12.1.2\\support\\analyzeHeadless.bat" "C:\\REA Runtime\\project" "rea-project" "-import" "C:\\REA Runtime\\target.exe""', + ], + }); + }); + + it.each([ + "C:\\targets\\%TEMP%\\fixture.exe", + "C:\\targets\\fixture & calc.exe", + 'C:\\targets\\fixture".exe', + ])("rejects a Windows command-interpreter path: %s", (targetPath) => { + expect(() => + ghidraHeadlessCommand({ + platform: "win32", + analyzeHeadlessPath: "C:\\Ghidra\\support\\analyzeHeadless.bat", + arguments: ["-import", targetPath], + comSpec: "C:\\Windows\\System32\\cmd.exe", + }), + ).toThrow(/metacharacters/u); + }); + it("keeps authority in a private descriptor and isolates Ghidra state", async () => { vi.stubEnv("GHIDRA_JAVA_OPTIONS", "-javaagent:/unapproved/agent.jar"); vi.stubEnv("JAVA_TOOL_OPTIONS", "-Duser.home=/unapproved/home"); @@ -65,9 +115,11 @@ describe("Ghidra headless launcher", () => { const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-launcher-test-")); roots.push(runtimeRoot); const token = "secret-token-that-must-not-leak"; + const javaHome = + process.platform === "win32" ? "C:\\Java\\jdk-21" : "/opt/jdk-21"; const launcher = new GhidraHeadlessLauncher({ analyzeHeadlessPath: fixturePath, - javaHome: "/opt/jdk-21", + javaHome, bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java", }); const launched = await launcher.launch({ @@ -91,10 +143,17 @@ describe("Ghidra headless launcher", () => { expect(encodedArguments).not.toContain(token); expect(encodedEnvironment).not.toContain(token); expect(capture).toMatchObject({ - descriptor_mode: 0o600, + ...(process.platform === "win32" ? {} : { descriptor_mode: 0o600 }), descriptor_has_token: true, environment: { HOME: join(runtimeRoot, "home"), + ...(process.platform === "win32" + ? { + USERPROFILE: join(runtimeRoot, "home"), + TEMP: join(runtimeRoot, "tmp"), + TMP: join(runtimeRoot, "tmp"), + } + : {}), TMPDIR: join(runtimeRoot, "tmp"), XDG_CACHE_HOME: join(runtimeRoot, "cache"), XDG_CONFIG_HOME: join(runtimeRoot, "config"), @@ -104,17 +163,22 @@ describe("Ghidra headless launcher", () => { JAVA_TOOL_OPTIONS: "", JDK_JAVA_OPTIONS: "", _JAVA_OPTIONS: "", - JAVA_HOME: "/opt/jdk-21", + JAVA_HOME: javaHome, REA_PROCESS_RUN_ID: "d6fcbb66-e829-4ff6-a535-0035aec63139", }, }); - expect(capture.environment.PATH).toMatch(/^\/opt\/jdk-21\/bin:/u); + expect(capture.environment.PATH).toMatch( + process.platform === "win32" + ? /^C:\\Java\\jdk-21\\bin;/u + : /^\/opt\/jdk-21\/bin:/u, + ); expect(capture.environment.GHIDRA_HEADLESS_JAVA_OPTIONS).toContain( `-Duser.home=${join(runtimeRoot, "home")}`, ); - expect((await stat(join(runtimeRoot, "ownership.json"))).mode & 0o777).toBe( - 0o600, - ); + if (process.platform !== "win32") + expect( + (await stat(join(runtimeRoot, "ownership.json"))).mode & 0o777, + ).toBe(0o600); const cleaned = await launched.value.cleanup?.(); expect(cleaned).toMatchObject({ cleaned: true }); diff --git a/tests/ghidraProvider.test.ts b/tests/ghidraProvider.test.ts index 41a68999f..414b8747b 100644 --- a/tests/ghidraProvider.test.ts +++ b/tests/ghidraProvider.test.ts @@ -144,6 +144,17 @@ describe("Ghidra provider", () => { managed: false, }, }); + expect(ghidra.capabilities()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "list_procedures", + limitations: expect.arrayContaining([ + expect.stringContaining("bounded taskkill termination"), + expect.stringContaining("private DACL enforcement"), + ]), + }), + ]), + ); expect( ghidra.inspectTargetSupport({ ...nativeApplication, diff --git a/tests/processOwnership.test.ts b/tests/processOwnership.test.ts index 1fec52758..19dbb8f0f 100644 --- a/tests/processOwnership.test.ts +++ b/tests/processOwnership.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { cleanupOwnedProcessGroup, + cleanupWindowsProcessTree, observeOwnedProcessGroup, parseProcessEnvironment, selectCapturedProcessGroupIds, type ProcessOwnershipHost, + type WindowsProcessTreeHost, } from "../src/process/ProcessOwnership.js"; const ownership = { @@ -224,3 +226,39 @@ describe("owned process-group cleanup", () => { expect(signalGroup).not.toHaveBeenCalled(); }); }); + +describe("Windows P0 process-tree cleanup", () => { + it("reports whether taskkill signaled or found an exited tree", async () => { + const terminated: WindowsProcessTreeHost = { + terminateTree: () => Promise.resolve("terminated"), + }; + const missing: WindowsProcessTreeHost = { + terminateTree: () => Promise.resolve("missing"), + }; + + await expect(cleanupWindowsProcessTree(42, terminated)).resolves.toEqual({ + cleaned: true, + signaled: true, + }); + await expect(cleanupWindowsProcessTree(42, missing)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + }); + + it("keeps invalid identity and termination failures explicit", async () => { + const failing: WindowsProcessTreeHost = { + terminateTree: () => Promise.reject(new Error("taskkill failed")), + }; + + await expect(cleanupWindowsProcessTree(0, failing)).resolves.toEqual({ + cleaned: false, + reason: "Windows process-tree PID is invalid", + }); + await expect(cleanupWindowsProcessTree(42, failing)).resolves.toEqual({ + cleaned: false, + reason: + "Windows P0 process-tree termination failed; Job Object ownership is unavailable", + }); + }); +});