From 6add17a317460acd78985ae4227c73d6b98998ac Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:06:19 +0800 Subject: [PATCH] fix(windows): explain missing native artifacts and verify output drain --- .github/workflows/ci.yml | 1 + scripts/ci/scopes.mjs | 1 + src/ghidra/GhidraDoctor.test.ts | 2 +- src/process/WindowsAuthority.ts | 2 +- src/windows/WindowsNativeLoader.test.ts | 26 +++- src/windows/WindowsNativeLoader.ts | 26 +++- .../process/windowsOutputCapture.test.ts | 111 ++++++++++++++++++ .../process-global/curatedWindowsLane.test.ts | 6 + 8 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 tests/boundary/process/windowsOutputCapture.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25627d4b9a..c10ef0d9c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -741,6 +741,7 @@ jobs: tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts tests/boundary/filesystem/webScriptManifestPaths.test.ts tests/boundary/process/ProcessEvidence.host.test.ts + tests/boundary/process/windowsOutputCapture.test.ts tests/acceptance/webModuleCli.test.ts tests/acceptance/webSourceLocationCli.test.ts tests/boundary/cli/referenceSourceImportStatus.test.ts diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 3b237a7bf0..6da5769a96 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -138,6 +138,7 @@ export const SCOPES = { "src/domain/javascript/javascriptRuntimeStaticCandidates.ts", "tests/boundary/filesystem/webScriptManifestPaths.test.ts", "tests/boundary/process/ProcessEvidence.host.test.ts", + "tests/boundary/process/windowsOutputCapture.test.ts", "tests/acceptance/webModuleCli.test.ts", "tests/acceptance/webSourceLocationCli.test.ts", "tests/boundary/cli/referenceSourceImportStatus.test.ts", diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 6a85045474..c15ff8a101 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -273,7 +273,7 @@ describe("Windows native doctor failure diagnostics", () => { "artifact", "runtime_missing", "missing_dependency", - "bundled native addon", + "native/windows/README.md", ], [ "self-test", diff --git a/src/process/WindowsAuthority.ts b/src/process/WindowsAuthority.ts index e05080739e..3c83fe6885 100644 --- a/src/process/WindowsAuthority.ts +++ b/src/process/WindowsAuthority.ts @@ -89,7 +89,7 @@ export const inspectWindowsNativeReadiness = ( failureStage === "host" ? "Use a Windows x64 host and a supported Node.js runtime with Node-API 8 or newer." : failureStage === "artifact" - ? "Install the matching Windows x64 REA package with its bundled native addon; check the reported artifact load or integrity failure." + ? "Use the matching Windows x64 REA package, or restore native/windows/build/manifest.json and native/windows/build/rea-windows-x64.node from the same-version rea-agents tarball or build them with native/windows/README.md. The loader checks version, ABI, architecture, and digest before granting authority; no download or install is performed automatically." : failureStage === "inspection" ? "Install the matching Windows x64 REA package; its native inspection must prove every required security control." : "Inspect the preserved native self-test error. Check that the process's temporary directory is writable local NTFS and that its filesystem, DACL, Job Object, and cleanup operations are permitted. Resolve the reported constraint, then rerun rea doctor; reinstalling Ghidra or Java cannot fix this self-test.", diff --git a/src/windows/WindowsNativeLoader.test.ts b/src/windows/WindowsNativeLoader.test.ts index 9f2b5623d4..eada66fa8b 100644 --- a/src/windows/WindowsNativeLoader.test.ts +++ b/src/windows/WindowsNativeLoader.test.ts @@ -66,7 +66,7 @@ const fixture = ( return { host, manifest, bytes, loads: () => loads }; }; -describe("packaged Windows native admission", () => { +describe("packaged Windows native compatibility", () => { it.each([true, false])( "preserves protocol stdin capability %s alongside existing job controls", (protocolStdin) => { @@ -127,6 +127,30 @@ describe("packaged Windows native admission", () => { }, ); + it("names the absent packaged manifest and matching-version recovery", () => { + const value = fixture(); + const missing = Object.assign(new Error("file absent"), { + code: "ENOENT", + }); + expect( + loadWindowsNativeAuthority({ + ...value.host, + readManifest: () => { + throw missing; + }, + }), + ).toMatchObject({ + available: false, + failureStage: "artifact", + reason: expect.stringMatching( + /Missing packaged Windows native file native\/windows\/build\/manifest\.json \(ENOENT\).*native\/windows\/build\/rea-windows-x64\.node.*native\/windows\/README\.md/u, + ), + }); + expect(value.loads()).toBe(0); + }); +}); + +describe("packaged Windows native artifact validation", () => { it("rejects tampering even when the original file was a valid PE image", () => { const value = fixture(); value.bytes[127] = 1; diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts index 37620f53a2..4c4289635c 100644 --- a/src/windows/WindowsNativeLoader.ts +++ b/src/windows/WindowsNativeLoader.ts @@ -87,6 +87,24 @@ export interface WindowsNativeLoadHost { loadArtifact(): unknown; } +const missingWindowsNativeFileReason = (file: string): string => + `Missing packaged Windows native file native/windows/build/${file} (ENOENT). Restore both native/windows/build/manifest.json and native/windows/build/rea-windows-x64.node from the rea-agents@${PACKAGE_METADATA.version} tarball or build them with native/windows/README.md; no native artifact was loaded.`; + +const readPackagedWindowsNativeFile = (file: string, read: () => T): T => { + try { + return read(); + } catch (cause: unknown) { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ) + throw new Error(missingWindowsNativeFileReason(file), { cause }); + throw cause; + } +}; + /** Validate compatibility and artifact identity before invoking the native addon. */ export const loadWindowsNativeAuthority = ( host: WindowsNativeLoadHost, @@ -105,10 +123,14 @@ export const loadWindowsNativeAuthority = ( reason: "REA Windows native controls require Node-API 8 or newer.", }; try { - const manifest = windowsNativeManifestSchema.parse(host.readManifest()); + const manifest = windowsNativeManifestSchema.parse( + readPackagedWindowsNativeFile("manifest.json", () => host.readManifest()), + ); if (manifest.packageVersion !== host.packageVersion) throw new Error("Windows native artifact belongs to another REA version"); - const artifact = host.readArtifact(); + const artifact = readPackagedWindowsNativeFile("rea-windows-x64.node", () => + host.readArtifact(), + ); if ( createHash("sha256").update(artifact).digest("hex") !== manifest.artifactSha256 diff --git a/tests/boundary/process/windowsOutputCapture.test.ts b/tests/boundary/process/windowsOutputCapture.test.ts new file mode 100644 index 0000000000..cc5feec5fa --- /dev/null +++ b/tests/boundary/process/windowsOutputCapture.test.ts @@ -0,0 +1,111 @@ +import { createHash, randomUUID } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../../../src/process/ProviderProcess.js"; + +const OUTPUT_BYTES = 8 * 1024 * 1024 + 64 * 1024; +const WRITE_BYTES = 64 * 1024; +const WINDOWS_OUTPUT_DEADLINE_MS = 30_000; + +const expectedDigest = (): string => { + const hash = createHash("sha256"); + const chunk = Buffer.alloc(WRITE_BYTES, 0x61); + let remaining = OUTPUT_BYTES; + while (remaining > 0) { + const bytes = Math.min(remaining, chunk.length); + hash.update(chunk.subarray(0, bytes)); + remaining -= bytes; + } + return hash.digest("hex"); +}; + +describe.skipIf(process.platform !== "win32")( + "Windows owned process output capture", + () => { + it( + "drains and retains a multi-megabyte stdout stream before the workflow deadline", + async () => { + const script = [ + `let remaining = ${OUTPUT_BYTES};`, + `const chunk = Buffer.alloc(${WRITE_BYTES}, 0x61);`, + "function write() {", + " while (remaining > 0) {", + " const bytes = Math.min(remaining, chunk.length);", + " remaining -= bytes;", + " if (!process.stdout.write(chunk.subarray(0, bytes))) {", + " process.stdout.once('drain', write);", + " return;", + " }", + " }", + "}", + "write();", + ].join("\n"); + const startedAt = performance.now(); + const launched = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: ["-e", script], + runId: `rea-windows-output-${randomUUID()}`, + }); + const supervisor = new ProviderProcessSupervisor( + { + process: launched.process, + ownsProcessLifetime: true, + ownership: launched.ownership, + ...(launched.cleanup === undefined + ? {} + : { cleanup: launched.cleanup }), + }, + { maxDiagnosticBytes: OUTPUT_BYTES + WRITE_BYTES }, + ); + + try { + const remainingDeadlineMs = Math.max( + 0, + WINDOWS_OUTPUT_DEADLINE_MS - (performance.now() - startedAt), + ); + expect( + await supervisor.waitForOutputClose(remainingDeadlineMs), + `expected ${OUTPUT_BYTES} bytes before the ${WINDOWS_OUTPUT_DEADLINE_MS} ms workflow deadline`, + ).toBe(true); + const snapshot = supervisor.snapshot(); + expect(snapshot.exitCode).toBe(0); + expect(snapshot.signal).toBeNull(); + expect(snapshot.stdout.observedBytes).toBe(OUTPUT_BYTES); + expect(snapshot.stdout.bytes).toBe(OUTPUT_BYTES); + expect(snapshot.stderr.observedBytes).toBe(0); + expect(snapshot.diagnosticTruncated).toBeUndefined(); + expect(snapshot.outputIncomplete).toBeUndefined(); + const elapsedMs = Math.round(performance.now() - startedAt); + const digest = createHash("sha256") + .update(snapshot.stdout.text, "utf8") + .digest("hex"); + expect(digest).toBe(expectedDigest()); + expect(await supervisor.stop()).toMatchObject({ + status: "verified-cleanup", + }); + console.info( + JSON.stringify({ + test: "windows-owned-process-output-capture", + bytes: snapshot.stdout.bytes, + elapsed_ms: elapsedMs, + sha256: digest, + deadline_ms: WINDOWS_OUTPUT_DEADLINE_MS, + }), + ); + } finally { + const cleanup = await supervisor.stop(); + supervisor.dispose(); + if (cleanup.status === "incomplete") + throw new Error( + `Windows output fixture cleanup failed: ${cleanup.reason}`, + ); + } + }, + WINDOWS_OUTPUT_DEADLINE_MS + 5_000, + ); + }, +); diff --git a/tests/process-global/curatedWindowsLane.test.ts b/tests/process-global/curatedWindowsLane.test.ts index 6f3b897ffc..e126d6c82a 100644 --- a/tests/process-global/curatedWindowsLane.test.ts +++ b/tests/process-global/curatedWindowsLane.test.ts @@ -63,6 +63,12 @@ describe("curated Windows test lane", () => { ); expect(missing.filter((path) => path !== undefined)).toEqual([]); }, 10_000); + + it("keeps the native multi-megabyte output regression in the lane", async () => { + expect(await explicitPaths("windows")).toContain( + "tests/boundary/process/windowsOutputCapture.test.ts", + ); + }); }); it("keeps every explicit focused CI test path executable after file moves", async () => {