fix(windows): preserve capture input, environment and frame bounds

Send bounded UTF-8 requests through supervised stdin rather than expanded Windows command lines. Carry the selected provider environment to the helper and reject changed client dimensions after rendering.

Update the effects audit and test real command input delivery, blocked readers, early close, long request encoding and provider environment propagation. Real Windows non-admin CLI/MCP and Character Map validation passed for the stdin transport.
This commit is contained in:
N0zoM1z0
2026-10-15 22:37:29 +08:00
parent 61b6408bb7
commit bb9df948cc
14 changed files with 230 additions and 19 deletions
+3
View File
@@ -118,6 +118,9 @@ automatically. Alternatively, build it using the Linux/MinGW lane above.
a 256 MiB Job Object commit limit and a 64 MiB JavaScript heap. It retains any
failed cleanup owner and retries it before admitting another capture. It does
not retain a detached native thread or target-owned surface in the server.
Requests use bounded UTF-8 JSON on owned protocol stdin, avoiding Windows
command-line expansion of long paths. The helper receives the provider's
selected environment with ambient Node preload options removed.
A thread-local DPI context gives physical client dimensions without changing
the parent DPI policy. The implementation never enumerates windows, captures
a desktop/region, searches titles, sends input, changes permissions, launches
+6
View File
@@ -604,6 +604,12 @@ WindowImage captureWindow(DWORD pid, ULONG_PTR handle, int maximumDimension,
surface->makeOpaque();
validateWindowOwner(window, pid);
auto observedProcess = validateProcessIdentity(pid, startToken, imagePath);
RECT observedBounds{};
require(GetClientRect(window, &observedBounds) != FALSE,
"Read selected window client bounds after rendering failed");
require(EqualRect(&bounds, &observedBounds) != FALSE,
"Selected window client area changed during capture; retry after resizing stops", L"",
ERROR_INVALID_DATA, FailureKind::WindowState);
const int longest = std::max(width, height);
const double scale = longest <= maximumDimension ? 1.0 : static_cast<double>(maximumDimension) / longest;
const int renderedWidth = std::max(1, static_cast<int>(std::floor(width * scale)));
@@ -50,7 +50,7 @@ export const auxiliaryAnalysisProviderDeclarations = (
load: async () => {
const { NativeWindowsUiProvider } =
await import("../windows/NativeWindowsUiProvider.js");
return new NativeWindowsUiProvider(undefined, platform);
return new NativeWindowsUiProvider(environment, undefined, platform);
},
},
{
+1
View File
@@ -137,6 +137,7 @@ describe("tool effects", () => {
"capture_electron_scenario",
"capture_native_ui_scenario",
"capture_process_scenario",
"capture_windows_window_screenshot",
"close_gdb_session",
"close_rizin_debug_session",
"cutter_command",
+23 -1
View File
@@ -63,6 +63,8 @@ export const runOwnedCommand = async (
},
options: {
readonly signal?: AbortSignal | undefined;
/** Complete protocol input, written and closed under the same deadline. */
readonly input?: Uint8Array;
/** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */
readonly retainCleanup?: (
owner: ProviderProcessSupervisor,
@@ -88,6 +90,7 @@ export const runOwnedCommand = async (
deadline,
callerSignal,
options.retainCleanup,
options.input,
);
} finally {
deadline.dispose();
@@ -110,12 +113,14 @@ const collectOwnedCommand = async (
owner: ProviderProcessSupervisor,
result: Extract<ProviderProcessStopResult, { status: "incomplete" }>,
) => void,
input?: Uint8Array,
): Promise<ProviderProcessSnapshot> => {
const signal = deadline.signal;
let launched: SpawnedOwnedProviderProcess;
try {
launched = await launcher({
...spawn,
...(input === undefined ? {} : { stdin: "pipe" as const }),
signal,
});
} catch (cause: unknown) {
@@ -152,8 +157,25 @@ const collectOwnedCommand = async (
},
);
let failure: OwnedCommandFailure | undefined;
let inputPending = input !== undefined;
try {
while (!(await supervisor.waitForOutputClose(10))) {
if (input !== undefined) {
const stream = launched.process.stdin;
if (stream == null)
throw new Error("Owned command protocol input is unavailable.");
const inputError = (cause: Error) => {
inputPending = false;
processFailure ??= `Command protocol input failed: ${cause.message}`;
};
// Retain this listener through cleanup: stopping a blocked writer can
// emit a late error, including when its supervisor must be retained.
stream.on("error", inputError);
stream.end(input, (cause?: Error | null) => {
inputPending = false;
if (cause != null) inputError(cause);
});
}
while (!(await supervisor.waitForOutputClose(10)) || inputPending) {
const interruption = deadline.interruption;
if (interruption !== undefined)
throw new OwnedCommandFailure(
+6 -4
View File
@@ -236,9 +236,11 @@ it("retains and retries an executable descriptor whose close first fails", async
"Selected executable descriptor cleanup failed",
);
close.mockRestore();
const client = new NativeWindowsUiProvider(host(), "linux").createClient(
TARGET,
);
const client = new NativeWindowsUiProvider(
{},
host(),
"linux",
).createClient(TARGET);
await expect(client.close()).resolves.toMatchObject({ ok: true });
await expect(
hashWindowsCaptureExecutable(executable),
@@ -309,7 +311,7 @@ it("retains both final identity owners before returning their primary failure",
cleanup: { resources: [TARGET.path, processPath] },
},
});
const client = new NativeWindowsUiProvider(host(), "linux").createClient(
const client = new NativeWindowsUiProvider({}, host(), "linux").createClient(
TARGET,
);
await expect(client.close()).resolves.toMatchObject({ ok: true });
+9 -3
View File
@@ -139,7 +139,9 @@ export interface WindowsWindowCaptureHost {
): Promise<ExecutableIdentity>;
}
const systemCaptureHost = (): WindowsWindowCaptureHost => ({
const systemCaptureHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): WindowsWindowCaptureHost => ({
capture: async (
pid,
windowHandle,
@@ -166,6 +168,7 @@ const systemCaptureHost = (): WindowsWindowCaptureHost => ({
target_sha256: targetIdentity.sha256,
target_file_identity: targetIdentity.fileIdentity,
},
environment,
signal,
);
},
@@ -177,7 +180,10 @@ export class NativeWindowsUiProvider implements AnalysisProvider {
readonly #capabilities: readonly CapabilityDescriptor[];
constructor(
private readonly captureHost: WindowsWindowCaptureHost = systemCaptureHost(),
environment: Readonly<NodeJS.ProcessEnv> = process.env,
private readonly captureHost: WindowsWindowCaptureHost = systemCaptureHost(
environment,
),
platform: NodeJS.Platform = process.platform,
) {
this.#capabilities = nativeWindowsUiCapabilities(platform);
@@ -246,7 +252,7 @@ export class NativeWindowsUiProvider implements AnalysisProvider {
export const captureWindowsWindowScreenshot = async (
target: BinaryTarget,
parameters: Readonly<Record<string, unknown>>,
host: WindowsWindowCaptureHost = systemCaptureHost(),
host: WindowsWindowCaptureHost = systemCaptureHost(process.env),
options: ExecutionOptions = {},
): Promise<Result<WindowsWindowScreenshotResult, AnalysisError>> => {
if (options.signal?.aborted)
+14 -2
View File
@@ -100,7 +100,7 @@ describe("selected-window native admission", () => {
});
it("refuses an addon that does not expose selected-window inspection", () => {
const value = fixture({ jobMemoryLimit: true });
const value = fixture({ jobMemoryLimit: true, protocolStdin: true });
expect(loadWindowsWindowCaptureAuthority(value.host)).toMatchObject({
available: false,
failureStage: "inspection",
@@ -109,12 +109,24 @@ describe("selected-window native admission", () => {
it("requires capture support and the helper's enforced memory control", () => {
expect(
loadWindowsWindowCaptureAuthority(
fixture({ windowCapture: true, jobMemoryLimit: true }).host,
fixture({
windowCapture: true,
jobMemoryLimit: true,
protocolStdin: true,
}).host,
),
).toMatchObject({ available: true });
expect(
loadWindowsWindowCaptureAuthority(fixture({ windowCapture: true }).host),
).toMatchObject({ available: false, failureStage: "inspection" });
expect(
loadWindowsWindowCaptureAuthority(
fixture({ windowCapture: true, jobMemoryLimit: true }).host,
),
).toMatchObject({
available: false,
reason: expect.stringContaining("protocol stdin"),
});
});
});
+7
View File
@@ -247,6 +247,13 @@ export const loadWindowsWindowCaptureAuthority = (
reason:
"Selected-window capture requires owned helper Job Object memory limits; install the matching native artifact.",
};
if (binding.inspection.protocolStdin !== true)
return {
available: false,
failureStage: "inspection",
reason:
"Selected-window capture requires owned helper protocol stdin; install the matching native artifact.",
};
try {
let observed: unknown;
try {
@@ -0,0 +1,98 @@
import { createHash } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import * as command from "../process/OwnedCommand.js";
import { executeWindowsCapture } from "./WindowsWindowCaptureExecution.js";
import { WINDOWS_CAPTURE_REQUEST_BYTES } from "./WindowsWindowCaptureProtocol.js";
import * as native from "./WindowsNativeLoader.js";
import { NativeWindowsUiProvider } from "./NativeWindowsUiProvider.js";
import { pe } from "../domain/binaryTarget.fixture.js";
afterEach(() => vi.restoreAllMocks());
it("sends long selected paths through stdin and preserves only the selected environment", async () => {
const run = vi
.spyOn(command, "runOwnedCommand")
.mockRejectedValue(
new command.OwnedCommandFailure(
"cancelled",
"Fixture stopped after launch inspection",
),
);
const request = {
pid: 42,
window_handle: "0x1234",
max_dimension: 320,
target_path: "C:" + "\\a".repeat(16000),
target_sha256: "a".repeat(64),
target_file_identity: "1:42",
};
await expect(
executeWindowsCapture(request, {
TEMP: "selected-temp",
REA_CAPTURE_SELECTED: "selected",
NODE_OPTIONS: "--require=never-run",
}),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
expect(run).toHaveBeenCalledTimes(1);
const [spawn, , options] = run.mock.calls[0]!;
expect(spawn.arguments).toHaveLength(2);
expect(spawn.hostEnvironment).toEqual({
TEMP: "selected-temp",
REA_CAPTURE_SELECTED: "selected",
});
const input = Buffer.from(options?.input ?? []);
expect(input.byteLength).toBeGreaterThan(32767);
expect(input.byteLength).toBeLessThanOrEqual(WINDOWS_CAPTURE_REQUEST_BYTES);
expect(JSON.parse(input.toString("utf8"))).toEqual(request);
});
it("carries the provider's selected environment through executable admission to helper launch", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-capture-environment-"));
const path = join(directory, "fixture.exe");
const bytes = pe(0x8664);
await writeFile(path, bytes);
vi.spyOn(native, "requireWindowsWindowCaptureAuthority").mockReturnValue({
call: () => {
throw new Error("Admission must not invoke native paint directly");
},
});
const run = vi
.spyOn(command, "runOwnedCommand")
.mockRejectedValue(
new command.OwnedCommandFailure(
"cancelled",
"Fixture stopped before native launch",
),
);
const client = new NativeWindowsUiProvider({
TEMP: "provider-selected",
}).createClient({
kind: "executable",
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
format: "pe",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
executableRole: "application",
managed: false,
});
try {
expect(
await client.execute("capture_windows_window_screenshot", {
pid: 42,
window_handle: "0x1234",
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
expect(run).toHaveBeenCalledTimes(1);
expect(run.mock.calls[0]![0].hostEnvironment).toEqual({
TEMP: "provider-selected",
});
} finally {
await client.close();
await rm(directory, { recursive: true, force: true });
}
});
+10 -4
View File
@@ -18,7 +18,7 @@ import {
WINDOWS_CAPTURE_REPLY_BYTES,
WINDOWS_CAPTURE_TIMEOUT_MS,
windowsCaptureReplySchema,
type windowsCaptureRequestSchema,
windowsCaptureRequestSchema,
} from "./WindowsWindowCaptureProtocol.js";
import type { z } from "zod";
@@ -50,6 +50,7 @@ export const retryWindowsCaptureCleanup = async (): Promise<
/** Isolate the blocking OS API in one bounded, supervised helper at a time. */
export const executeWindowsCapture = async (
request: z.output<typeof windowsCaptureRequestSchema>,
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
signal?: AbortSignal,
): Promise<unknown> => {
if (active)
@@ -67,7 +68,7 @@ export const executeWindowsCapture = async (
// Preserve inherited values without allowing ambient Node options to run
// preload scripts or replace this helper's explicit heap limit.
const environment = Object.fromEntries(
Object.entries(process.env).filter(
Object.entries(selectedEnvironment).filter(
([name]) => name.toUpperCase() !== "NODE_OPTIONS",
),
);
@@ -79,7 +80,6 @@ export const executeWindowsCapture = async (
fileURLToPath(
new URL("../windows-capture-runtime/worker.js", import.meta.url),
),
JSON.stringify(request),
],
runId: `rea-window-capture-${randomUUID()}`,
expectedCommand: null,
@@ -90,7 +90,13 @@ export const executeWindowsCapture = async (
timeoutMs: WINDOWS_CAPTURE_TIMEOUT_MS,
diagnosticBytes: WINDOWS_CAPTURE_REPLY_BYTES,
},
{ signal },
{
signal,
input: Buffer.from(
JSON.stringify(windowsCaptureRequestSchema.parse(request)),
"utf8",
),
},
);
const value: unknown = JSON.parse(snapshot.stdout.text);
const reply = windowsCaptureReplySchema.parse(value);
+3 -2
View File
@@ -7,6 +7,7 @@ import {
export const WINDOWS_CAPTURE_TIMEOUT_MS = 5000;
const MAX_WINDOWS_TEXT_UNITS = 32767;
export const WINDOWS_CAPTURE_REQUEST_BYTES = MAX_WINDOWS_TEXT_UNITS * 6 + 4096;
// Title and image path can each expand to six ASCII bytes per UTF-16 unit
// under JSON escaping. Reserve the remaining fixed fields independently.
export const WINDOWS_CAPTURE_REPLY_BYTES =
@@ -17,9 +18,9 @@ export const WINDOWS_CAPTURE_REPLY_BYTES =
/** Private process protocol; identity is validated again by the native owner. */
export const windowsCaptureRequestSchema = z.strictObject({
...windowsWindowScreenshotInputSchema.shape,
target_path: z.string().min(1),
target_path: z.string().min(1).max(MAX_WINDOWS_TEXT_UNITS),
target_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
target_file_identity: z.string().min(1),
target_file_identity: z.string().min(1).max(32),
});
const identityFields = {
+14 -2
View File
@@ -1,6 +1,7 @@
import { requireWindowsWindowCaptureAuthority } from "./WindowsNativeLoader.js";
import {
nativeWindowsCaptureSchema,
WINDOWS_CAPTURE_REQUEST_BYTES,
windowsCaptureFailureSchema,
windowsCaptureRequestSchema,
} from "./WindowsWindowCaptureProtocol.js";
@@ -9,8 +10,19 @@ import {
// calls it; the parent can terminate its Job Object without blocking the server
// or leaving a detached native thread/surface in the long-lived REA process.
try {
const encoded = process.argv[2];
if (encoded === undefined) throw new Error("Missing capture request");
const chunks: Buffer[] = [];
let bytes = 0;
for await (const chunk of process.stdin) {
if (!Buffer.isBuffer(chunk))
throw new Error("Invalid capture request bytes");
bytes += chunk.byteLength;
if (bytes > WINDOWS_CAPTURE_REQUEST_BYTES)
throw new Error("Capture request exceeds its protocol byte limit");
chunks.push(chunk);
}
const encoded = new TextDecoder("utf-8", { fatal: true }).decode(
Buffer.concat(chunks, bytes),
);
const value: unknown = JSON.parse(encoded);
const input = windowsCaptureRequestSchema.parse(value);
const authority = requireWindowsWindowCaptureAuthority();
@@ -101,6 +101,41 @@ it("collects a real owned command and verifies its exit before returning diagnos
});
});
it("delivers complete protocol input larger than the Windows command-line limit", async () => {
const input = Buffer.from(
JSON.stringify({ path: "C:" + "\\a".repeat(16000) }),
);
expect(input.byteLength).toBeGreaterThan(32767);
const result = await runOwnedCommand(
command(
"const chunks=[]; for await (const chunk of process.stdin) chunks.push(chunk); process.stdout.write(Buffer.concat(chunks));",
),
{ timeoutMs: 2000, diagnosticBytes: input.byteLength + 1024 },
{ input },
);
expect(result.stdout.text).toBe(input.toString("utf8"));
});
it("times out and cleans up a command whose protocol reader never consumes input", async () => {
await expect(
runOwnedCommand(
command("setInterval(() => {}, 1000)"),
{ timeoutMs: 500, diagnosticBytes: 1024 },
{ input: Buffer.alloc(2 * 1024 * 1024) },
),
).rejects.toMatchObject({ reason: "timeout", cleanupFailure: null });
});
it("reports a protocol write failure when the child closes its input early", async () => {
await expect(
runOwnedCommand(
command("process.stdin.destroy()"),
{ timeoutMs: 2000, diagnosticBytes: 1024 },
{ input: Buffer.alloc(2 * 1024 * 1024) },
),
).rejects.toMatchObject({ reason: "process", collectionFailure: true });
});
it("fails an accepted nonzero command when an output stream errors and cleans it up", async () => {
const stdout = new PassThrough();
const stderr = new PassThrough();