Compare commits

...
Author SHA1 Message Date
N0zoM1z0 283292edda fix(windows): preserve unresolved readiness and cleanup ownership
Do not report a retryable failed capture admission as available. A fresh capability declaration must observe actual admission success. Report combined window resources when closing a session, preserving helper and executable cleanup observations without assigning the wrong owner.

Both regressions fail before the change. Focused provider, loader, helper and lazy-client checks pass after the fixes.
2026-10-15 23:38:41 +08:00
N0zoM1z0 dcccb4fca7 fix(windows): admit only the capture facet inside the worker
Keep filesystem and owned-process admission in the parent. The supervised worker verifies artifact identity and the in-memory capture facet without probing its selected temporary directory.

Reproduced failure with TEMP and TMP pointing to a source-owned file before the fix. The updated packed runtime passes that real Windows non-admin case plus the full CLI/MCP and Character Map matrix. One earlier Character Map CLI failure lacked exit diagnostics; unchanged-artifact retry passed, with the unexplained attempt retained in verification notes.
2026-10-15 23:16:59 +08:00
N0zoM1z0 62a20fff19 fix(windows): validate screenshot headers with the canonical PNG parser
Reject malformed IHDR lengths and high-bit chunk names instead of interpreting them through lossy ASCII decoding. Reproduced both false accepts before the change; focused parser, capture and delivery checks pass after sharing the existing byte-level header owner.
2026-10-15 22:50:32 +08:00
N0zoM1z0 bb9df948cc 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.
2026-10-15 22:37:29 +08:00
N0zoM1z0 61b6408bb7 feat(windows): capture selected windows with bounded owned helpers
Follow up #2199 and #2183. Replace invalid cross-process WM_PRINT DC delivery with PrintWindow in one owned, deadline- and memory-bounded helper. Return metadata summaries by default through MCP with explicit retained image reads.

Verified actual pixels through CLI and MCP on Windows x64, including a non-elevated desktop, live redraws, PNG capacity refusal and retry, and target identity rejection. Final expanded acceptance and CI results are recorded in the pull request.
2026-10-15 22:15:15 +08:00
53 changed files with 4609 additions and 180 deletions

No files matched your search

@@ -109,6 +109,18 @@ run, use `observe_native_calls` with explicit breakpoints and a bounded
runs and may change files or use the network. Hardened-runtime targets need the
`get-task-allow` entitlement.
For one passive runtime frame of an already-running Windows PE application,
use `capture_windows_window_screenshot` only after opening that exact PE target.
Supply the process PID and its exact canonical lowercase HWND (`0x...`). The
result binds that window to the target's process image path, start time and
on-disk SHA-256 before/after capture, then retains one bounded inline PNG. Do
not substitute a title, foreground window, desktop, region, accessibility tree,
or arbitrary screen-control tool. This Windows facet sends no UI input and does
not start the target, but its bounded helper's `PrintWindow` call can run a selected
window's paint handler. A captured image may contain private data and does not
prove a physically visible frame, full GPU compositing, or UI accessibility.
Failure to capture must remain a gap; never fall back to a broad desktop shot.
Use `binary_session` with no arguments to check the open target, selected
provider, and alignment. Its default `result.tool_availability` carries a
complete inventory grouped by shared facts. Expand each group's `names` and
+10
View File
@@ -529,6 +529,10 @@ jobs:
x86_64-w64-mingw32-g++ -municode -std=c++17 -O2 -static -Wall -Wextra \
-o .cache/windows-process-boundary/process-boundary.exe \
tests/fixtures/windows/processBoundary.cc -ladvapi32
x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -Wall -Wextra \
-Wl,--no-insert-timestamp \
-o .cache/windows-process-boundary/window-capture.exe \
tests/fixtures/windows/windowCapture.cc -lgdi32 -luser32
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: windows-process-boundary-fixture
@@ -780,6 +784,12 @@ jobs:
path: .cache/windows-native-inspection-fixture/
- name: Verify native inspection failure ownership and recovery
run: node scripts/verify-windows-native-inspection.mjs
- name: Verify selected-window pixels through CLI and MCP
timeout-minutes: 5
env:
NODE_OPTIONS: "--max-old-space-size=512"
UV_THREADPOOL_SIZE: "2"
run: node scripts/verify-windows-window-capture.mjs . .cache/windows-process-boundary/window-capture.exe
- name: Verify real Node Inspector discovery and observation
timeout-minutes: 10
env:
+53
View File
@@ -424,6 +424,59 @@ owned helper per scenario, removes its temporary compiler cache and stops its
helper on cancellation. It does not launch or own the selected application. UI
actions may change application data or trigger network activity.
### Windows selected-window screenshots
`capture_windows_window_screenshot` is a separate, narrower Windows x64
capability for an active PE **application** target. It captures one passive PNG
for an exact existing PID and canonical lowercase `HWND` string, for example:
```sh
rea capture-windows-window-screenshot C:\Apps\Example.exe --pid 1234 --window-handle 0x1234 --json
```
In an MCP session, call `open_binary` for the PE application first, then supply
the same `pid` and `window_handle`. REA verifies that the HWND belongs to that
PID, reads the process start time and image path, and checks the selected
on-disk executable SHA-256 before and after capture. The result retains a
PNG with its digest, byte count, rendered/source dimensions, title, and the exact
process/window identity. MCP defaults to `detail: "summary"`: the session retains
the complete frame and returns only metadata and `parent_evidence_id`. To read
that same image without another capture, call `inspect_analysis_view` with that
retained Evidence ID and `view: {kind: "windows-window", facet: "screenshot"}`.
Use `detail: "complete"` for inline PNG bytes. The one-shot CLI returns complete
Evidence, suitable for redirecting to a local file. `--max-dimension` applies to
both transports.
The capture is confined to the selected client area through `PrintWindow`
(`PW_CLIENTONLY`). REA launches one private Node helper in an owned Windows Job
Object; the selected application is not launched or owned. A five-second helper
deadline and cancellation terminate that helper through the shared process
supervisor, including when the target paint handler blocks. Capture is serialized
per REA process, with a 64 MiB helper JavaScript heap and a 256 MiB Job Object
commit limit. Failed cleanup retains its supervisor for retry and refuses new
captures until that cleanup succeeds.
REA captures at most 4,194,304 source pixels, uses physical client dimensions
without changing the parent process DPI policy, scales the returned image to a
caller-selected maximum dimension (2,048 by default), and retains at most 3 MiB
of PNG bytes. It does not enumerate windows, search titles, capture a desktop or
region, send input, request elevation, or expose UI Automation. Protected,
elevated, minimized, GPU-composited or custom-drawn windows can refuse or render
incompletely. Successful API completion and valid PNG bytes do not establish
complete rendering; no broader desktop fallback is attempted.
`PrintWindow` can execute the selected target's paint handler, which may change
application UI state or cause target filesystem and network effects. A returned
frame is one observation at read time, not proof of physical-display visibility
or an accessibility tree. The title and image can contain private application
data and are retained as Evidence for this explicitly selected window. Captures
are fresh observations; REA does not cache frames or skip target identity checks.
Use `--max-dimension` to request the image resolution needed for the question.
Accessibility capture, UI Automation, and action scenarios are intentionally
not part of this Windows screenshot-only facet. Do not present it as parity with
the macOS `observe_native_ui` workflow.
## Native call observation
`observe_native_calls` / `rea observe-native-calls <app-or-mach-o> <input-json>`
+16
View File
@@ -493,6 +493,22 @@ rename, later-query handle-based recovery, successful-admission caching, and
environment-teardown cleanup. It neither
establishes the cause of the intermittent #1701 host failure nor replaces
verification with the shipped production artifact.
A selected-window screenshot claim needs a separate interactive ordinary-user
Windows desktop fixture: the fixture must create one source-owned PE window,
provide its PID/HWND to both CLI and stdio MCP, and verify target binding, PNG
digest/dimensions, destroyed-window refusal, and the absence of
desktop/region/title-search fallbacks. Cross-compiling the addon or a hosted
Windows runner without an interactive desktop does not prove that capture lane.
Run `node scripts/verify-windows-window-capture.mjs PACKAGE_ROOT FIXTURE_EXE`
against the built source-owned `tests/fixtures/windows/windowCapture.cc` window.
The Windows CI lane cross-builds that fixture and verifies pixels through CLI
and stdio MCP, fresh frames after repaint, retained-image reads, ANSI captions,
destroyed HWNDs, exact file identity, source/PNG capacity refusals, a smaller-image
retry, request cancellation and recovery, and cleanup. Cancellation verifies the
written RPC; it does not establish that the target paint handler was blocked.
Run it against an independently
installed npm artifact for a packaged-runtime claim. Report the actual host and
scope; source and simulated-provider tests alone do not establish GUI capture.
`npm run verify:ghidra:windows:package` additionally packs and installs REA into
an isolated prefix and runs ordinary-user CLI/MCP operations against their
canonical schemas. Run the controlled fixture generator before that lane, or
+1
View File
@@ -4,6 +4,7 @@
"src/main.ts",
"src/cli.ts",
"src/composition/javascriptAnalysisWorkerProcess.ts",
"src/windows/WindowsWindowCaptureWorker.ts",
"src/contracts/errorSchemas.ts",
"src/evaluation/AgentEvaluationFixtures.ts",
"src/application/RealHopperAssertions.ts",
+23
View File
@@ -108,6 +108,29 @@ automatically. Alternatively, build it using the Linux/MinGW lane above.
Environment names are sorted and deduplicated using Windows
ordinal comparison, preserving their native Unicode semantics and
caller-selected values.
- The selected-window PNG operation admits one caller-supplied canonical HWND
only after its owner PID, process start time, stable executable file identity
and SHA-256 agree with the active PE application target. Read-only file
handles pin target/process-image identity and bytes before the paint request. It renders the client area through a
synchronous `PrintWindow(PW_CLIENTONLY)` call into an in-memory DIB and WIC PNG
encoder. The call runs only in a private helper supervised through the shared
process owner. The parent enforces a five-second helper deadline, cancellation,
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.
The parent admits the Job and private-runtime controls. The worker validates
only its artifact and capture facet; its selected TEMP need not be writable.
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
the target or falls back to BitBlt. Protected, elevated, hung, minimized,
GPU-composited or custom-drawn windows can refuse or render incompletely;
a successful PNG does not prove complete rendering. PrintWindow can execute
the selected target's paint handler, which may have target UI, filesystem or
network effects. The target process is never terminated by capture cleanup.
- Cleanup walks locked directory objects, removes files by handle, and unlinks
reparse entries without traversing their targets. Failed removal retains the
root and ancestor leases for retry; a partially removed runtime accepts only
+20 -2
View File
@@ -43,10 +43,12 @@ std::wstring wide(napi_env env, napi_value value) {
double numeric(napi_env env, napi_value value) {
double result; check(napi_get_value_double(env, value, &result)); return result;
}
std::vector<napi_value> array(napi_env env, napi_value value) {
std::vector<napi_value> array(napi_env env, napi_value value, uint32_t maximumLength) {
bool valid; check(napi_is_array(env, value, &valid));
require(valid, "Native arguments must be an array", L"", ERROR_INVALID_PARAMETER);
uint32_t length; check(napi_get_array_length(env, value, &length));
require(length <= maximumLength, "Native argument list exceeds the fixed operation limit", L"",
ERROR_INVALID_PARAMETER);
std::vector<napi_value> result(length);
for (uint32_t index = 0; index < length; ++index)
check(napi_get_element(env, value, index, &result[index]));
@@ -102,6 +104,16 @@ napi_value failureValue(napi_env env, const Failure& failure) {
set(env, error, "constraint", string(env, failure.constraint));
set(env, error, "requestedPath", string(env, failure.path));
set(env, error, "win32Code", number(env, failure.win32));
const char* semantic = nullptr;
switch (failure.kind) {
case FailureKind::Native: break;
case FailureKind::ArtifactChanged: semantic = "artifact_changed"; break;
case FailureKind::WindowOwnerMismatch: semantic = "window_owner_mismatch"; break;
case FailureKind::WindowState: semantic = "window_state"; break;
case FailureKind::ProcessIdentityMismatch: semantic = "process_identity_mismatch"; break;
case FailureKind::SourcePixelLimit: semantic = "source_pixel_limit"; break;
}
if (semantic != nullptr) set(env, error, "failureKind", string(env, std::string(semantic)));
set(env, error, "win32Message", string(env, detail));
if (!failure.cleanupConstraint.empty()) {
set(env, error, "cleanupConstraint", string(env, failure.cleanupConstraint));
@@ -146,12 +158,18 @@ static napi_value call(napi_env env, napi_callback_info info) {
check(napi_get_cb_info(env, info, &count, args.data(), nullptr, &data));
require(count == 2, "Native call requires operation and arguments", L"", ERROR_INVALID_PARAMETER);
const auto operation = wide(env, args[0]);
const auto values = array(env, args[1]);
const auto values = array(env, args[1], 64);
auto& context = *static_cast<RuntimeContext*>(data);
if (operation == L"inspect_window_capture") {
require(values.empty(), "Selected-window capture inspection takes no arguments", L"",
ERROR_INVALID_PARAMETER);
return inspectWindowCapture(env);
}
if (operation == L"inspect") {
require(values.empty(), "Inspect takes no arguments", L"", ERROR_INVALID_PARAMETER);
return inspect(env, context.pending);
}
if (operation == L"capture_window_png") return windowCaptureCall(env, values);
if (operation.rfind(L"process_", 0) == 0) return processCall(env, operation, values);
return filesystemCall(env, operation, values, context);
} catch (const Failure& failure) {
+56 -5
View File
@@ -29,8 +29,18 @@ inline std::wstring ordinaryDriveSeparators(std::wstring path) {
return path;
}
enum class FailureKind {
Native,
ArtifactChanged,
WindowOwnerMismatch,
WindowState,
ProcessIdentityMismatch,
SourcePixelLimit,
};
struct Failure : std::runtime_error {
DWORD win32;
FailureKind kind;
std::wstring path;
std::string constraint;
std::string cleanupConstraint;
@@ -38,8 +48,9 @@ struct Failure : std::runtime_error {
std::wstring cleanupPath;
// Affirmative proof for a failed runtime-parent admission; omission is unknown.
bool runtimeDirectoryUnallocated = false;
Failure(std::string constraint, std::wstring path = L"", DWORD code = GetLastError())
: std::runtime_error(constraint), win32(code), path(std::move(path)),
Failure(std::string constraint, std::wstring path = L"", DWORD code = GetLastError(),
FailureKind kind = FailureKind::Native)
: std::runtime_error(constraint), win32(code), kind(kind), path(std::move(path)),
constraint(std::move(constraint)) {}
};
@@ -49,8 +60,9 @@ inline void require(bool condition, const char* constraint, const std::wstring&
throw Failure(constraint, path, code);
}
}
inline void require(bool condition, const char* constraint, const std::wstring& path, DWORD code) {
if (!condition) throw Failure(constraint, path, code);
inline void require(bool condition, const char* constraint, const std::wstring& path, DWORD code,
FailureKind kind = FailureKind::Native) {
if (!condition) throw Failure(constraint, path, code, kind);
}
class Handle {
@@ -124,11 +136,48 @@ napi_value null(napi_env env);
void set(napi_env env, napi_value object, const char* key, napi_value value);
std::wstring wide(napi_env env, napi_value value);
double numeric(napi_env env, napi_value value);
std::vector<napi_value> array(napi_env env, napi_value value);
std::vector<napi_value> array(napi_env env, napi_value value, uint32_t maximumLength = UINT32_MAX);
napi_value wrap(napi_env env, std::unique_ptr<Resource> resource);
void publishRuntimeHandle(napi_env env, napi_value observation, std::unique_ptr<Runtime>& runtime);
Resource& resource(napi_env env, napi_value value, Kind kind);
/** One owner for BCrypt SHA-256 state used by retained snapshots and capture admission. */
class Sha256Hash {
public:
explicit Sha256Hash(std::wstring path) : path_(std::move(path)) {
require(BCryptOpenAlgorithmProvider(&algorithm_, BCRYPT_SHA256_ALGORITHM, nullptr, 0) == 0,
"Initialize SHA-256 failed", path_, ERROR_GEN_FAILURE);
try {
require(BCryptCreateHash(algorithm_, &hash_, nullptr, 0, nullptr, 0, 0) == 0,
"Create SHA-256 state failed", path_, ERROR_GEN_FAILURE);
} catch (...) {
BCryptCloseAlgorithmProvider(algorithm_, 0);
throw;
}
}
~Sha256Hash() {
BCryptDestroyHash(hash_);
BCryptCloseAlgorithmProvider(algorithm_, 0);
}
Sha256Hash(const Sha256Hash&) = delete;
Sha256Hash& operator=(const Sha256Hash&) = delete;
void update(const unsigned char* bytes, ULONG count) {
require(BCryptHashData(hash_, const_cast<PUCHAR>(bytes), count, 0) == 0,
"Hash source bytes failed", path_, ERROR_GEN_FAILURE);
}
std::array<unsigned char, 32> finish() {
std::array<unsigned char, 32> digest;
require(BCryptFinishHash(hash_, digest.data(), digest.size(), 0) == 0,
"Finish SHA-256 failed", path_, ERROR_GEN_FAILURE);
return digest;
}
private:
std::wstring path_;
BCRYPT_ALG_HANDLE algorithm_ = nullptr;
BCRYPT_HASH_HANDLE hash_ = nullptr;
};
std::unique_ptr<File> openFile(const std::wstring& path, DWORD access = GENERIC_READ,
bool directory = false,
DWORD finalSharing = FILE_SHARE_READ);
@@ -144,6 +193,8 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
bool protocolStdin = false, ULONGLONG memoryLimit = 0);
napi_value processCall(napi_env env, const std::wstring& operation,
const std::vector<napi_value>& args);
napi_value windowCaptureCall(napi_env env, const std::vector<napi_value>& args);
napi_value inspectWindowCapture(napi_env env);
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& pending);
} // namespace rea
+3 -13
View File
@@ -603,14 +603,7 @@ struct SnapshotWork {
static void copySnapshot(napi_env, void* pointer) {
auto& work = *static_cast<SnapshotWork*>(pointer);
try {
BCRYPT_ALG_HANDLE algorithm = nullptr;
require(BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) == 0,
"Initialize snapshot SHA-256 failed", work.source->path, ERROR_GEN_FAILURE);
struct Algorithm { BCRYPT_ALG_HANDLE handle; ~Algorithm() { BCryptCloseAlgorithmProvider(handle, 0); } } owner{algorithm};
BCRYPT_HASH_HANDLE hash = nullptr;
require(BCryptCreateHash(algorithm, &hash, nullptr, 0, nullptr, 0, 0) == 0,
"Create snapshot SHA-256 state failed", work.source->path, ERROR_GEN_FAILURE);
struct Hash { BCRYPT_HASH_HANDLE handle; ~Hash() { BCryptDestroyHash(handle); } } hashOwner{hash};
Sha256Hash hash(work.source->path);
std::array<unsigned char, 65536> buffer;
while (true) {
require(!work.root->snapshotCancelled.load(), "Admitted snapshot was cancelled", work.path, ERROR_OPERATION_ABORTED);
@@ -619,12 +612,9 @@ static void copySnapshot(napi_env, void* pointer) {
"Read admitted source handle failed", work.source->path);
if (count == 0) break;
writeBytes(work.destination.get(), buffer.data(), count, work.path);
require(BCryptHashData(hash, buffer.data(), count, 0) == 0,
"Hash admitted snapshot bytes failed", work.source->path, ERROR_GEN_FAILURE);
hash.update(buffer.data(), count);
}
std::array<unsigned char, 32> digest;
require(BCryptFinishHash(hash, digest.data(), digest.size(), 0) == 0,
"Finish snapshot SHA-256 failed", work.source->path, ERROR_GEN_FAILURE);
const auto digest = hash.finish();
require(FlushFileBuffers(work.destination.get()), "Flush private snapshot failed", work.path);
work.digest = hex(digest.data(), digest.size());
} catch (const Failure& failure) {
+9
View File
@@ -454,6 +454,15 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v
throw Failure("Unknown native process operation", L"", ERROR_INVALID_PARAMETER);
}
napi_value inspectWindowCapture(napi_env env) {
auto result = object(env);
set(env, result, "abiVersion", number(env, 1));
set(env, result, "nodeApiVersion", number(env, 8));
set(env, result, "architecture", string(env, std::string("x64")));
set(env, result, "windowCapture", boolean(env, true));
return result;
}
napi_value inspect(napi_env env, std::unique_ptr<Runtime>& pending) {
// Settle the previous owner before admitting another probe. Failed cleanup
// retains its handles, including when identity construction itself failed.
+662
View File
@@ -0,0 +1,662 @@
#include "authority.hpp"
#include <cmath>
#include <cstring>
#include <limits>
#include <objbase.h>
#include <objidl.h>
#include <wincodec.h>
namespace rea {
namespace {
constexpr uint64_t MAX_SOURCE_PIXELS = 4ULL * 1024ULL * 1024ULL;
constexpr size_t MAX_PNG_BYTES = 3ULL * 1024ULL * 1024ULL;
class ComApartment {
public:
ComApartment() {
const auto result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
if (result == S_OK || result == S_FALSE) owns_ = true;
else if (result != RPC_E_CHANGED_MODE)
throw Failure("Initialize Windows image encoder failed", L"", static_cast<DWORD>(result));
}
~ComApartment() {
if (owns_) CoUninitialize();
}
private:
bool owns_ = false;
};
template <typename T>
class ComPointer {
public:
ComPointer() = default;
~ComPointer() { reset(); }
ComPointer(const ComPointer&) = delete;
ComPointer& operator=(const ComPointer&) = delete;
T* get() const { return value_; }
T** out() {
reset();
return &value_;
}
void reset(T* next = nullptr) {
if (value_ != nullptr) value_->Release();
value_ = next;
}
private:
T* value_ = nullptr;
};
/** Fixed-capacity IStream prevents a WIC encoder from growing an unbounded HGLOBAL. */
class FixedMemoryStream final : public IStream {
public:
explicit FixedMemoryStream(std::vector<BYTE>& buffer) : buffer_(buffer) {}
STDMETHODIMP QueryInterface(REFIID iid, void** result) override {
if (result == nullptr) return E_POINTER;
*result = nullptr;
if (IsEqualIID(iid, IID_IUnknown) || IsEqualIID(iid, IID_ISequentialStream) ||
IsEqualIID(iid, IID_IStream)) {
*result = static_cast<IStream*>(this);
AddRef();
return S_OK;
}
return E_NOINTERFACE;
}
STDMETHODIMP_(ULONG) AddRef() override { return references_.fetch_add(1) + 1; }
STDMETHODIMP_(ULONG) Release() override {
const ULONG remaining = references_.fetch_sub(1) - 1;
if (remaining == 0) delete this;
return remaining;
}
STDMETHODIMP Read(void* target, ULONG requested, ULONG* observed) override {
if (observed != nullptr) *observed = 0;
if (target == nullptr && requested != 0) return STG_E_INVALIDPOINTER;
const size_t available = position_ >= size_ ? 0 : size_ - position_;
const size_t count = std::min(available, static_cast<size_t>(requested));
if (count != 0) std::memcpy(target, buffer_.data() + position_, count);
position_ += count;
if (observed != nullptr) *observed = static_cast<ULONG>(count);
return count == requested ? S_OK : S_FALSE;
}
STDMETHODIMP Write(const void* source, ULONG requested, ULONG* observed) override {
if (observed != nullptr) *observed = 0;
if (source == nullptr && requested != 0) return STG_E_INVALIDPOINTER;
const size_t count = static_cast<size_t>(requested);
if (position_ > buffer_.size() || count > buffer_.size() - position_) {
capacityExceeded_ = true;
return STG_E_MEDIUMFULL;
}
if (count != 0) std::memcpy(buffer_.data() + position_, source, count);
position_ += count;
size_ = std::max(size_, position_);
if (observed != nullptr) *observed = requested;
return S_OK;
}
STDMETHODIMP Seek(LARGE_INTEGER move, DWORD origin, ULARGE_INTEGER* observed) override {
const LONGLONG base = origin == STREAM_SEEK_SET ? 0
: origin == STREAM_SEEK_CUR ? static_cast<LONGLONG>(position_)
: origin == STREAM_SEEK_END ? static_cast<LONGLONG>(size_)
: -1;
if (base < 0) return STG_E_INVALIDFUNCTION;
const LONGLONG delta = move.QuadPart;
if ((delta > 0 && base > std::numeric_limits<LONGLONG>::max() - delta) ||
(delta < 0 && base < std::numeric_limits<LONGLONG>::min() - delta))
return STG_E_INVALIDFUNCTION;
const LONGLONG next = base + delta;
if (next < 0) return STG_E_INVALIDFUNCTION;
if (static_cast<ULONGLONG>(next) > buffer_.size()) {
capacityExceeded_ = true;
return STG_E_MEDIUMFULL;
}
position_ = static_cast<size_t>(next);
if (observed != nullptr) observed->QuadPart = position_;
return S_OK;
}
STDMETHODIMP SetSize(ULARGE_INTEGER requested) override {
if (requested.QuadPart > buffer_.size()) {
capacityExceeded_ = true;
return STG_E_MEDIUMFULL;
}
size_ = static_cast<size_t>(requested.QuadPart);
position_ = std::min(position_, size_);
return S_OK;
}
STDMETHODIMP CopyTo(IStream* destination, ULARGE_INTEGER requested,
ULARGE_INTEGER* read, ULARGE_INTEGER* written) override {
if (read != nullptr) read->QuadPart = 0;
if (written != nullptr) written->QuadPart = 0;
if (destination == nullptr) return STG_E_INVALIDPOINTER;
const size_t count = std::min(
position_ >= size_ ? 0 : size_ - position_,
requested.QuadPart > std::numeric_limits<size_t>::max()
? buffer_.size()
: static_cast<size_t>(requested.QuadPart));
ULONG copied = 0;
const HRESULT result = destination->Write(buffer_.data() + position_, static_cast<ULONG>(count), &copied);
position_ += copied;
if (read != nullptr) read->QuadPart = copied;
if (written != nullptr) written->QuadPart = copied;
return result;
}
STDMETHODIMP Commit(DWORD) override { return S_OK; }
STDMETHODIMP Revert() override { return E_NOTIMPL; }
STDMETHODIMP LockRegion(ULARGE_INTEGER, ULARGE_INTEGER, DWORD) override { return STG_E_INVALIDFUNCTION; }
STDMETHODIMP UnlockRegion(ULARGE_INTEGER, ULARGE_INTEGER, DWORD) override { return STG_E_INVALIDFUNCTION; }
STDMETHODIMP Stat(STATSTG* observed, DWORD) override {
if (observed == nullptr) return STG_E_INVALIDPOINTER;
std::memset(observed, 0, sizeof(*observed));
observed->type = STGTY_STREAM;
observed->cbSize.QuadPart = size_;
observed->grfMode = STGM_READWRITE;
return S_OK;
}
STDMETHODIMP Clone(IStream**) override { return E_NOTIMPL; }
size_t size() const { return size_; }
bool capacityExceeded() const { return capacityExceeded_; }
private:
std::atomic<ULONG> references_{1};
std::vector<BYTE>& buffer_;
size_t position_ = 0;
size_t size_ = 0;
bool capacityExceeded_ = false;
};
class DeviceContext {
public:
DeviceContext() : value_(CreateCompatibleDC(nullptr)) {
require(value_ != nullptr, "Create selected-window capture surface failed");
}
~DeviceContext() {
DWORD ignored = ERROR_SUCCESS;
(void)close(ignored);
}
DeviceContext(const DeviceContext&) = delete;
DeviceContext& operator=(const DeviceContext&) = delete;
HDC get() const { return value_; }
bool close(DWORD& code) {
if (value_ == nullptr) return true;
if (DeleteDC(value_) != FALSE) {
value_ = nullptr;
return true;
}
code = GetLastError();
return false;
}
private:
HDC value_ = nullptr;
};
class Bitmap {
public:
Bitmap(int width, int height) {
const uint64_t byteCount = static_cast<uint64_t>(width) * static_cast<uint64_t>(height) * 4ULL;
require(byteCount <= std::numeric_limits<size_t>::max(), "Selected-window bitmap exceeds memory limits", L"",
ERROR_NOT_ENOUGH_MEMORY);
bytes_ = static_cast<size_t>(byteCount);
BITMAPINFO description{};
description.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
description.bmiHeader.biWidth = width;
description.bmiHeader.biHeight = -height;
description.bmiHeader.biPlanes = 1;
description.bmiHeader.biBitCount = 32;
description.bmiHeader.biCompression = BI_RGB;
value_ = CreateDIBSection(nullptr, &description, DIB_RGB_COLORS, &pixels_, nullptr, 0);
require(value_ != nullptr && pixels_ != nullptr, "Create selected-window bitmap failed");
std::memset(pixels_, 0, bytes_);
}
~Bitmap() {
DWORD ignored = ERROR_SUCCESS;
(void)close(ignored);
}
Bitmap(const Bitmap&) = delete;
Bitmap& operator=(const Bitmap&) = delete;
HBITMAP get() const { return value_; }
void* pixels() const { return pixels_; }
bool close(DWORD& code) {
if (value_ == nullptr) return true;
if (DeleteObject(value_) != FALSE) {
value_ = nullptr;
pixels_ = nullptr;
return true;
}
code = GetLastError();
return false;
}
void setOpaqueAlpha() const {
auto* bytes = static_cast<BYTE*>(pixels_);
for (size_t index = 3; index < bytes_; index += 4) bytes[index] = 0xff;
}
private:
HBITMAP value_ = nullptr;
void* pixels_ = nullptr;
size_t bytes_ = 0;
};
class SelectedObject {
public:
SelectedObject(HDC dc, HGDIOBJ object) : dc_(dc), previous_(SelectObject(dc, object)) {
require(previous_ != nullptr && previous_ != HGDI_ERROR, "Select selected-window bitmap failed");
}
~SelectedObject() {
DWORD ignored = ERROR_SUCCESS;
(void)restore(ignored);
}
SelectedObject(const SelectedObject&) = delete;
SelectedObject& operator=(const SelectedObject&) = delete;
bool restore(DWORD& code) {
if (previous_ == nullptr || previous_ == HGDI_ERROR) return true;
const HGDIOBJ selected = SelectObject(dc_, previous_);
if (selected == nullptr || selected == HGDI_ERROR) {
code = GetLastError();
return false;
}
previous_ = nullptr;
return true;
}
private:
HDC dc_;
HGDIOBJ previous_;
};
// Own the bitmap and DC in the short-lived helper. Its supervised Job Object
// bounds the lifetime of both the blocking call and all GDI resources.
class CaptureSurface {
public:
CaptureSurface(int width, int height)
: bitmap_(width, height), selected_(dc_.get(), bitmap_.get()) {}
HDC dc() const { return dc_.get(); }
void* pixels() const { return bitmap_.pixels(); }
void makeOpaque() const { bitmap_.setOpaqueAlpha(); }
bool settle(DWORD& code) {
return selected_.restore(code) && bitmap_.close(code) && dc_.close(code);
}
private:
DeviceContext dc_;
Bitmap bitmap_;
SelectedObject selected_;
};
void checkHr(HRESULT result, const char* constraint) {
require(SUCCEEDED(result), constraint, L"", static_cast<DWORD>(result));
}
DWORD positiveDword(napi_env env, napi_value value, const char* constraint) {
const auto candidate = numeric(env, value);
require(std::isfinite(candidate) && std::floor(candidate) == candidate && candidate >= 1 &&
candidate <= static_cast<double>(std::numeric_limits<DWORD>::max()),
constraint, L"", ERROR_INVALID_PARAMETER);
return static_cast<DWORD>(candidate);
}
int positiveDimension(napi_env env, napi_value value) {
const auto candidate = numeric(env, value);
require(std::isfinite(candidate) && std::floor(candidate) == candidate && candidate >= 1 &&
candidate <= 4096,
"Selected-window dimension limit is invalid", L"", ERROR_INVALID_PARAMETER);
return static_cast<int>(candidate);
}
ULONG_PTR parseWindowHandle(const std::wstring& text) {
require(text.size() > 2 && text[0] == L'0' && text[1] == L'x',
"Window handle must be canonical lowercase hexadecimal", text, ERROR_INVALID_PARAMETER);
require(text.size() == 3 || text[2] != L'0',
"Window handle must not contain leading zeroes", text, ERROR_INVALID_PARAMETER);
ULONG_PTR result = 0;
for (size_t index = 2; index < text.size(); ++index) {
const wchar_t character = text[index];
const ULONG_PTR nibble = character >= L'0' && character <= L'9'
? static_cast<ULONG_PTR>(character - L'0')
: character >= L'a' && character <= L'f'
? static_cast<ULONG_PTR>(character - L'a' + 10)
: std::numeric_limits<ULONG_PTR>::max();
require(nibble != std::numeric_limits<ULONG_PTR>::max(),
"Window handle must be canonical lowercase hexadecimal", text, ERROR_INVALID_PARAMETER);
require(result <= (std::numeric_limits<ULONG_PTR>::max() - nibble) / 16,
"Window handle exceeds the native pointer range", text, ERROR_INVALID_PARAMETER);
result = result * 16 + nibble;
}
require(result != 0, "Window handle must not be zero", text, ERROR_INVALID_PARAMETER);
return result;
}
std::wstring hexHandle(ULONG_PTR value) {
const wchar_t digits[] = L"0123456789abcdef";
std::wstring rendered;
do {
rendered.push_back(digits[value & 0xf]);
value >>= 4;
} while (value != 0);
std::reverse(rendered.begin(), rendered.end());
return L"0x" + rendered;
}
Handle openProcessForCapture(DWORD pid) {
Handle process(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, pid));
const DWORD code = GetLastError();
require(process.valid(), "Open selected process for window identity failed", L"", code,
code == ERROR_INVALID_PARAMETER ? FailureKind::ProcessIdentityMismatch : FailureKind::Native);
return process;
}
std::wstring processImagePath(HANDLE process) {
std::vector<wchar_t> buffer(32768);
DWORD size = static_cast<DWORD>(buffer.size());
require(QueryFullProcessImageNameW(process, 0, buffer.data(), &size) != FALSE,
"Read selected process executable path failed");
require(size > 0 && size < buffer.size(), "Selected process executable path is invalid", L"",
ERROR_INVALID_DATA);
return std::wstring(buffer.data(), size);
}
uint64_t processStartTimeToken(HANDLE process) {
FILETIME created{}, exited{}, kernel{}, user{};
require(GetProcessTimes(process, &created, &exited, &kernel, &user) != FALSE,
"Read selected process start time failed");
ULARGE_INTEGER value{};
value.LowPart = created.dwLowDateTime;
value.HighPart = created.dwHighDateTime;
return value.QuadPart;
}
double processStartTimeMs(uint64_t token) {
constexpr uint64_t WINDOWS_TO_UNIX_EPOCH_100NS = 116444736000000000ULL;
require(token >= WINDOWS_TO_UNIX_EPOCH_100NS,
"Selected process start time precedes the Unix epoch", L"", ERROR_INVALID_DATA);
return static_cast<double>((token - WINDOWS_TO_UNIX_EPOCH_100NS) / 10000ULL);
}
Handle validateProcessIdentity(DWORD pid, uint64_t startToken, const std::wstring& imagePath) {
auto process = openProcessForCapture(pid);
require(processStartTimeToken(process.get()) == startToken && processImagePath(process.get()) == imagePath,
"Selected process identity changed during capture", L"", ERROR_INVALID_HANDLE,
FailureKind::ProcessIdentityMismatch);
const DWORD status = WaitForSingleObject(process.get(), 0);
require(status != WAIT_FAILED, "Read selected process liveness failed");
require(status == WAIT_TIMEOUT, "Selected process exited before capture settled", L"", ERROR_INVALID_HANDLE,
FailureKind::ProcessIdentityMismatch);
return process;
}
std::wstring windowTitle(HWND window) {
constexpr int MAX_WINDOW_TITLE_CHARS = 32767;
std::vector<wchar_t> buffer(static_cast<size_t>(MAX_WINDOW_TITLE_CHARS) + 1);
SetLastError(ERROR_SUCCESS);
const int copied = GetWindowTextW(window, buffer.data(), static_cast<int>(buffer.size()));
require(copied != 0 || GetLastError() == ERROR_SUCCESS, "Read selected window title failed");
require(copied >= 0 && copied <= MAX_WINDOW_TITLE_CHARS, "Read selected window title failed");
// GetWindowTextLengthW may overestimate ANSI/DBCS titles. The copied
// count is the observed title length; it is not an identity token.
return std::wstring(buffer.data(), static_cast<size_t>(copied));
}
void validateWindowOwner(HWND window, DWORD expectedPid) {
require(IsWindow(window) != FALSE, "Selected window no longer exists", L"", ERROR_INVALID_WINDOW_HANDLE);
DWORD observedPid = 0;
require(GetWindowThreadProcessId(window, &observedPid) != 0, "Read selected window owner failed");
require(observedPid == expectedPid, "Selected window belongs to a different process", L"",
ERROR_ACCESS_DENIED, FailureKind::WindowOwnerMismatch);
}
std::vector<BYTE> pngFromDib(int sourceWidth, int sourceHeight, void* pixels,
int maximumDimension) {
const uint64_t sourceBytes = static_cast<uint64_t>(sourceWidth) * static_cast<uint64_t>(sourceHeight) * 4ULL;
require(sourceBytes <= static_cast<uint64_t>(std::numeric_limits<UINT>::max()),
"Selected-window bitmap exceeds encoder limits", L"", ERROR_NOT_ENOUGH_MEMORY);
ComApartment apartment;
ComPointer<IWICImagingFactory> factory;
checkHr(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_IWICImagingFactory, reinterpret_cast<void**>(factory.out())),
"Create Windows image encoder failed");
ComPointer<IWICBitmap> bitmap;
checkHr(factory.get()->CreateBitmapFromMemory(
static_cast<UINT>(sourceWidth), static_cast<UINT>(sourceHeight), GUID_WICPixelFormat32bppBGRA,
static_cast<UINT>(sourceWidth * 4), static_cast<UINT>(sourceBytes),
static_cast<BYTE*>(pixels), bitmap.out()),
"Create selected-window image failed");
const int longest = std::max(sourceWidth, sourceHeight);
const double scale = longest <= maximumDimension ? 1.0 : static_cast<double>(maximumDimension) / longest;
const int width = std::max(1, static_cast<int>(std::floor(sourceWidth * scale)));
const int height = std::max(1, static_cast<int>(std::floor(sourceHeight * scale)));
IWICBitmapSource* source = bitmap.get();
ComPointer<IWICBitmapScaler> scaler;
if (width != sourceWidth || height != sourceHeight) {
checkHr(factory.get()->CreateBitmapScaler(scaler.out()), "Create selected-window image scaler failed");
checkHr(scaler.get()->Initialize(source, static_cast<UINT>(width), static_cast<UINT>(height),
WICBitmapInterpolationModeFant),
"Scale selected-window image failed");
source = scaler.get();
}
std::vector<BYTE> encoded(MAX_PNG_BYTES);
size_t byteCount = 0;
{
auto* fixedStream = new FixedMemoryStream(encoded);
ComPointer<IStream> stream;
stream.reset(fixedStream);
// WIC can replace STG_E_MEDIUMFULL with WINCODEC_ERR_STREAMWRITE.
// Preserve the actual refusal at the stream that owns the byte budget.
const auto checkEncoded = [fixedStream](HRESULT result, const char* constraint) {
require(!fixedStream->capacityExceeded(),
"Selected-window PNG exceeds the retained byte limit", L"",
static_cast<DWORD>(STG_E_MEDIUMFULL));
checkHr(result, constraint);
};
ComPointer<IWICBitmapEncoder> encoder;
checkEncoded(factory.get()->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.out()),
"Create selected-window PNG encoder failed");
checkEncoded(encoder.get()->Initialize(stream.get(), WICBitmapEncoderNoCache),
"Initialize selected-window PNG encoder failed");
ComPointer<IWICBitmapFrameEncode> frame;
ComPointer<IPropertyBag2> options;
checkEncoded(encoder.get()->CreateNewFrame(frame.out(), options.out()), "Create selected-window PNG frame failed");
checkEncoded(frame.get()->Initialize(options.get()), "Initialize selected-window PNG frame failed");
checkEncoded(frame.get()->SetSize(static_cast<UINT>(width), static_cast<UINT>(height)),
"Set selected-window PNG dimensions failed");
WICPixelFormatGUID format = GUID_WICPixelFormat32bppBGRA;
checkEncoded(frame.get()->SetPixelFormat(&format), "Set selected-window PNG format failed");
checkEncoded(frame.get()->WriteSource(source, nullptr), "Write selected-window PNG pixels failed");
checkEncoded(frame.get()->Commit(), "Commit selected-window PNG frame failed");
checkEncoded(encoder.get()->Commit(), "Commit selected-window PNG failed");
byteCount = fixedStream->size();
require(byteCount > 0 && byteCount <= MAX_PNG_BYTES,
"Selected-window PNG exceeds the retained byte limit", L"", ERROR_NOT_ENOUGH_MEMORY);
} // Release the encoder and stream before moving their backing bytes.
encoded.resize(byteCount);
return encoded;
}
struct WindowImage {
DWORD pid = 0;
ULONG_PTR windowHandle = 0;
std::wstring imagePath;
double startTimeMs = 0;
std::wstring title;
int sourceWidth = 0;
int sourceHeight = 0;
int width = 0;
int height = 0;
std::vector<BYTE> png;
};
Handle openCaptureExecutable(const std::wstring& path, bool admittedTarget = false) {
Handle file(CreateFileW(path.c_str(), GENERIC_READ | FILE_READ_ATTRIBUTES, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, FILE_FLAG_SEQUENTIAL_SCAN, nullptr));
const DWORD code = GetLastError();
require(file.valid(), "Open executable for window admission failed", path, code,
admittedTarget && (code == ERROR_FILE_NOT_FOUND || code == ERROR_PATH_NOT_FOUND)
? FailureKind::ArtifactChanged : FailureKind::Native);
require(GetFileType(file.get()) == FILE_TYPE_DISK, "Executable is not a disk file", path,
ERROR_NOT_SUPPORTED);
return file;
}
void authorizeCaptureExecutable(HANDLE target, HANDLE processImage, const std::wstring& path,
const std::wstring& expectedSha256, const std::wstring& expectedFileIdentity) {
BY_HANDLE_FILE_INFORMATION nodeIdentity{};
require(GetFileInformationByHandle(target, &nodeIdentity), "Read executable stat identity failed", path);
const uint64_t index = (static_cast<uint64_t>(nodeIdentity.nFileIndexHigh) << 32) | nodeIdentity.nFileIndexLow;
require(std::to_wstring(nodeIdentity.dwVolumeSerialNumber) + L":" + std::to_wstring(index) == expectedFileIdentity,
"Active executable identity changed before capture", path, ERROR_INVALID_HANDLE,
FailureKind::ArtifactChanged);
FILE_ID_INFO expected{}, observed{};
require(GetFileInformationByHandleEx(target, FileIdInfo, &expected, sizeof(expected)) &&
GetFileInformationByHandleEx(processImage, FileIdInfo, &observed, sizeof(observed)),
"Read capture executable identity failed", path);
require(expected.VolumeSerialNumber == observed.VolumeSerialNumber &&
std::memcmp(expected.FileId.Identifier, observed.FileId.Identifier, sizeof(expected.FileId.Identifier)) == 0,
"Selected process does not execute the active target file", path, ERROR_ACCESS_DENIED,
FailureKind::ProcessIdentityMismatch);
LARGE_INTEGER size{};
require(GetFileSizeEx(target, &size), "Read executable size failed", path);
require(size.QuadPart >= 0 && size.QuadPart <= 256LL * 1024LL * 1024LL,
"Executable exceeds the capture admission budget", path, ERROR_NOT_ENOUGH_MEMORY);
Sha256Hash hash(path);
std::array<unsigned char, 65536> buffer;
LONGLONG remaining = size.QuadPart;
while (remaining > 0) {
DWORD count = 0;
require(ReadFile(target, buffer.data(), static_cast<DWORD>(std::min<LONGLONG>(remaining, buffer.size())),
&count, nullptr), "Read capture executable bytes failed", path);
require(count > 0, "Executable ended before its declared size", path, ERROR_READ_FAULT);
hash.update(buffer.data(), count);
remaining -= count;
}
const auto digest = hash.finish();
constexpr wchar_t digits[] = L"0123456789abcdef";
std::wstring sha256;
for (const auto byte : digest) {
sha256.push_back(digits[byte >> 4]);
sha256.push_back(digits[byte & 15]);
}
require(sha256 == expectedSha256, "Active executable bytes changed before capture", path, ERROR_INVALID_DATA,
FailureKind::ArtifactChanged);
}
WindowImage captureWindow(DWORD pid, ULONG_PTR handle, int maximumDimension,
const std::wstring& targetPath, const std::wstring& targetSha256,
const std::wstring& targetFileIdentity) {
const auto previousDpi = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
require(previousDpi != nullptr, "Select capture-thread DPI context failed");
struct RestoreDpi {
DPI_AWARENESS_CONTEXT previous;
~RestoreDpi() { SetThreadDpiAwarenessContext(previous); }
} dpi{previousDpi};
const HWND window = reinterpret_cast<HWND>(handle);
validateWindowOwner(window, pid);
auto process = openProcessForCapture(pid);
const uint64_t startToken = processStartTimeToken(process.get());
const std::wstring imagePath = processImagePath(process.get());
// Read-only, non-write/delete-sharing handles pin the authorized file bytes
// and identity through the paint request. No target handler runs first.
auto targetFile = openCaptureExecutable(targetPath, true);
auto processFile = openCaptureExecutable(imagePath);
authorizeCaptureExecutable(targetFile.get(), processFile.get(), targetPath,
targetSha256, targetFileIdentity);
const double startTimeMs = processStartTimeMs(startToken);
const std::wstring title = windowTitle(window);
RECT bounds{};
require(GetClientRect(window, &bounds) != FALSE, "Read selected window client bounds failed");
const int width = bounds.right - bounds.left;
const int height = bounds.bottom - bounds.top;
require(width > 0 && height > 0, "Selected window has no captureable client area", L"",
ERROR_INVALID_DATA, FailureKind::WindowState);
require(static_cast<uint64_t>(width) * static_cast<uint64_t>(height) <= MAX_SOURCE_PIXELS,
"Selected window client area exceeds the capture limit", L"", ERROR_NOT_ENOUGH_MEMORY,
FailureKind::SourcePixelLimit);
auto surface = std::make_unique<CaptureSurface>(width, height);
// PrintWindow performs the cross-process DC transfer. Sending a raw HDC
// through SendMessageTimeout(WM_PRINT) leaves it invalid in the target.
// This blocking call runs only in a short-lived owned capture process;
// the parent enforces timeout, cancellation and Job Object cleanup.
auto paintProcess = validateProcessIdentity(pid, startToken, imagePath);
SetLastError(ERROR_SUCCESS);
const BOOL rendered = PrintWindow(window, surface->dc(), PW_CLIENTONLY);
const DWORD renderError = GetLastError();
require(rendered != FALSE, "Selected window refused client-area rendering", L"",
renderError == ERROR_SUCCESS ? ERROR_GEN_FAILURE : renderError);
require(GdiFlush() != FALSE, "Flush selected-window drawing commands failed");
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)));
const int renderedHeight = std::max(1, static_cast<int>(std::floor(height * scale)));
auto png = pngFromDib(width, height, surface->pixels(), maximumDimension);
WindowImage image{pid, handle, imagePath, startTimeMs, title, width, height,
renderedWidth, renderedHeight, std::move(png)};
DWORD cleanupError = ERROR_SUCCESS;
const bool settled = surface->settle(cleanupError);
require(settled, "Release selected-window capture surface failed", L"", cleanupError);
return image;
}
napi_value serializeCapture(napi_env env, const WindowImage& image) {
auto result = object(env);
set(env, result, "pid", number(env, image.pid));
set(env, result, "window_handle", string(env, hexHandle(image.windowHandle)));
set(env, result, "process_image_path", string(env, image.imagePath));
set(env, result, "process_start_time_unix_ms", number(env, image.startTimeMs));
set(env, result, "title", string(env, image.title));
set(env, result, "source_width", number(env, image.sourceWidth));
set(env, result, "source_height", number(env, image.sourceHeight));
set(env, result, "width", number(env, image.width));
set(env, result, "height", number(env, image.height));
napi_value png;
check(napi_create_buffer_copy(env, image.png.size(), image.png.data(), nullptr, &png));
set(env, result, "png", png);
return result;
}
} // namespace
napi_value windowCaptureCall(napi_env env, const std::vector<napi_value>& args) {
require(args.size() == 6, "Selected-window capture requires window inputs and active executable identity", L"",
ERROR_INVALID_PARAMETER);
const auto pid = positiveDword(env, args[0], "Selected process ID is invalid");
const auto windowHandle = parseWindowHandle(wide(env, args[1]));
const auto maximumDimension = positiveDimension(env, args[2]);
const auto targetPath = wide(env, args[3]);
const auto targetSha256 = wide(env, args[4]);
const auto targetFileIdentity = wide(env, args[5]);
require(targetSha256.size() == 64 &&
targetSha256.find_first_not_of(L"0123456789abcdef") == std::wstring::npos,
"Active target SHA-256 is invalid", L"", ERROR_INVALID_PARAMETER);
return serializeCapture(env, captureWindow(pid, windowHandle, maximumDimension,
targetPath, targetSha256, targetFileIdentity));
}
} // namespace rea
+12 -1
View File
@@ -91,7 +91,12 @@ const artifact = "rea-windows-x64.node";
// MSVC short-import libraries do not produce a correct Node import table with
// GNU ld. Generate a GNU delay-import library for the exact stable Node-API
// symbols; addon.cc binds it to the host executable, whatever its file name.
const sources = ["addon.cc", "filesystem.cc", "process.cc"];
const sources = [
"addon.cc",
"filesystem.cc",
"process.cc",
"window_capture.cc",
];
const sourceText = (
await Promise.all(
sources.map((path) =>
@@ -129,10 +134,16 @@ run(compiler, [
? "tests/fixtures/windows-native-inspection-failure.cc"
: "native/windows/src/filesystem.cc",
"native/windows/src/process.cc",
"native/windows/src/window_capture.cc",
library,
"-ladvapi32",
"-lbcrypt",
"-lgdi32",
"-lntdll",
"-lole32",
"-luser32",
"-lwindowscodecs",
"-luuid",
"-o",
join(output, artifact),
]);
+10 -1
View File
@@ -19,6 +19,10 @@ export const bundleRuntime = async (root) => {
);
for (const [directory, entries] of [
["mcp", { main: "main.js" }],
[
"windows-capture-runtime",
{ worker: "windows/WindowsWindowCaptureWorker.js" },
],
[
"cli-runtime",
{
@@ -40,7 +44,12 @@ export const bundleRuntime = async (root) => {
treeshake: false,
external: (source) =>
isMetadataModule(source) ||
(!source.startsWith(".") && !isAbsolute(source)),
(!source.startsWith(".") &&
!isAbsolute(source) &&
!(
directory === "windows-capture-runtime" &&
(source === "zod" || source.startsWith("zod/"))
)),
plugins: [
{
name: "preserve-compiled-module-locations",
+1
View File
@@ -752,6 +752,7 @@ setInterval(()=>{},1000);\n`,
`${quote(boundaryFixture)} environment`,
workspace,
[
...Array.from({ length: 80 }, (_, index) => `REA_EXTRA_${index}=value`),
"z_REA=z",
"_REA=underscore",
"A_REA=a",
+614
View File
@@ -0,0 +1,614 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { crc32, deflateSync } from "node:zlib";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
if (process.platform !== "win32" || process.arch !== "x64")
throw new Error(
"Selected-window acceptance requires a real Windows x64 desktop.",
);
const [packageArgument = ".", fixtureArgument, reportArgument] =
process.argv.slice(2);
assert.ok(
fixtureArgument,
"Supply the compiled source-owned windowCapture.cc fixture.",
);
const packageRoot = resolve(packageArgument);
const fixturePath = resolve(fixtureArgument);
const load = (path) => import(pathToFileURL(join(packageRoot, "dist", path)));
const { WindowsOwnedProcess } = await load("windows/WindowsOwnedProcess.js");
const { toolContract } = await load("contracts/toolContracts.js");
const { comparePngScreenshots } = await load("browser/PngVisualDiff.js");
const { windowsCaptureReplySchema } = await load(
"windows/WindowsWindowCaptureProtocol.js",
);
const { requireWindowsNativeAuthority } = await load(
"windows/WindowsNativeLoader.js",
);
const native = requireWindowsNativeAuthority();
const token = native.call("process_caller_token", []);
const exec = promisify(execFile);
const root = await mkdtemp(join(tmpdir(), "rea-window-acceptance-"));
const runtime = join(root, "runtime");
await mkdir(runtime);
const environment = {
...Object.fromEntries(
Object.entries(process.env).filter(
([name]) => !name.toUpperCase().startsWith("REA_"),
),
),
REA_ANALYSIS_PROVIDER: "auto",
REA_LOG_LEVEL: "error",
NODE_OPTIONS: "--max-old-space-size=512",
TEMP: runtime,
TMP: runtime,
APPDATA: join(root, "appdata"),
LOCALAPPDATA: join(root, "localappdata"),
HOME: root,
USERPROFILE: root,
};
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
const original = digest(await readFile(fixturePath));
const report = {
ok: false,
platform: "win32",
architecture: "x64",
elevated: token.elevated,
administrator: token.administrator,
fixtureSha256: original,
cases: {},
metrics: [],
};
const owned = new Set();
let client;
let transport;
// Independently encode a solid reference image. Pixel comparison catches the
// original valid-PNG/all-black false pass; dimensions and digests alone cannot.
const solid = (width, height, color) => {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
const chunk = (name, data) => {
const type = Buffer.from(name),
length = Buffer.alloc(4),
checksum = Buffer.alloc(4);
length.writeUInt32BE(data.length);
checksum.writeUInt32BE(crc32(Buffer.concat([type, data])));
return Buffer.concat([length, type, data, checksum]);
};
const rows = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++)
for (let x = 0; x < width; x++)
rows.set([...color, 255], y * (width * 4 + 1) + 1 + x * 4);
const bytes = Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", header),
chunk("IDAT", deflateSync(rows)),
chunk("IEND", Buffer.alloc(0)),
]);
return {
media_type: "image/png",
bytes: bytes.length,
sha256: digest(bytes),
data_base64: bytes.toString("base64"),
};
};
const pixels = (image, color) => {
const expected = solid(image.width, image.height, color);
const comparison = comparePngScreenshots({
before: image,
after: expected,
channel_threshold: 3,
});
assert.ok(
comparison.changed_ratio < 0.08,
"Captured pixels do not match the source-owned painted window",
);
return comparison.changed_ratio;
};
const launch = async (mode = "normal") => {
const process = new WindowsOwnedProcess(
fixturePath,
[mode],
dirname(fixturePath),
environment,
false,
{ protocolStdin: true, memoryLimitBytes: 128 * 1024 * 1024 },
);
owned.add(process);
const lines = [];
let buffered = "";
process.on("error", () => {});
process.stdout.on("data", (chunk) => {
buffered += chunk;
assert.ok(buffered.length < 8192, "Fixture protocol exceeded its budget");
let end;
while ((end = buffered.indexOf("\n")) !== -1) {
lines.push(JSON.parse(buffered.slice(0, end)));
buffered = buffered.slice(end + 1);
}
});
const next = async () => {
const until = performance.now() + 10000;
while (lines.length === 0) {
assert.ok(
performance.now() < until && process.exitCode === null,
"Window fixture failed before readiness",
);
await new Promise((resolve) => setTimeout(resolve, 25));
}
return lines.shift();
};
return {
process,
identity: await next(),
next,
command: (value) =>
new Promise((resolve, reject) =>
process.stdin.write(value + "\n", (error) =>
error ? reject(error) : resolve(),
),
),
};
};
const stop = async (fixture) => {
const result = await fixture.process.cleanup();
assert.equal(
result.cleaned,
true,
"Owned GUI fixture cleanup remains incomplete",
);
owned.delete(fixture.process);
};
const captureWithoutWritableTemp = async (fixture) => {
const unavailableTemp = join(root, "temp-is-a-file");
await writeFile(unavailableTemp, "Not a directory\n");
const file = await stat(fixturePath, { bigint: true });
const helper = new WindowsOwnedProcess(
process.execPath,
[
"--max-old-space-size=64",
join(packageRoot, "dist/windows-capture-runtime/worker.js"),
],
packageRoot,
{ ...environment, TEMP: unavailableTemp, TMP: unavailableTemp },
false,
{
protocolStdin: true,
memoryLimitBytes: 256 * 1024 * 1024,
},
);
owned.add(helper);
const chunks = [];
let bytes = 0,
overflow = false;
helper.stdout.on("data", (chunk) => {
bytes += chunk.byteLength;
if (bytes > 128 * 1024) overflow = true;
else chunks.push(chunk);
});
helper.stderr.resume();
helper.stdin.on("error", () => {});
let timer;
const finished = new Promise((resolve, reject) => {
timer = setTimeout(
() =>
reject(
new Error("Restricted-temp capture helper exceeded its deadline"),
),
10000,
);
helper.once("error", reject);
helper.once("close", resolve);
});
try {
helper.stdin.end(
Buffer.from(
JSON.stringify({
pid: fixture.identity.pid,
window_handle: fixture.identity.window_handle,
max_dimension: 320,
target_path: fixturePath,
target_sha256: original,
target_file_identity: `${file.dev}:${file.ino}`,
}),
),
);
assert.equal(await finished, 0);
assert.equal(overflow, false);
const reply = windowsCaptureReplySchema.parse(
JSON.parse(Buffer.concat(chunks).toString("utf8")),
);
assert.equal(
reply.ok,
true,
"Capture-only helper must not require writable TEMP",
);
const { png_base64, ...capture } = reply.capture;
const png = Buffer.from(png_base64, "base64");
pixels(
{
...capture,
media_type: "image/png",
data_base64: png_base64,
bytes: png.byteLength,
sha256: digest(png),
},
[40, 120, 200],
);
await stop({ process: helper });
return true;
} finally {
clearTimeout(timer);
}
};
const call = async (name, arguments_) => {
report.lastOperation = name;
const started = performance.now();
const response = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 60000 },
);
report.metrics.push({
name,
elapsedMs: performance.now() - started,
wireBytes: Buffer.byteLength(JSON.stringify(response)),
});
return response;
};
const outputOf = (response, name) => {
assert.notEqual(
response.isError,
true,
`${name} refused the selected fixture`,
);
return toolContract(name).outputSchema.parse(response.structuredContent);
};
const refusal = (response, expected) => {
assert.equal(response.isError, true);
const text = response.content.find((item) => item.type === "text").text;
const value = JSON.parse(text);
assert.equal(value.error.code, expected, JSON.stringify(value.error));
};
try {
transport = new StdioClientTransport({
command: process.execPath,
args: [join(packageRoot, "scripts/rea.mjs"), "mcp"],
env: environment,
stderr: "pipe",
});
// Drain diagnostics without retaining ambient paths or serializing them into
// the report. The process supervisor owns the helper diagnostics internally.
transport.stderr?.resume();
client = new Client({ name: "rea-selected-window-acceptance", version: "1" });
await client.connect(transport);
transport.stderr?.resume();
const first = await launch();
outputOf(await call("open_binary", { path: fixturePath }), "open_binary");
const input = {
pid: first.identity.pid,
window_handle: first.identity.window_handle,
max_dimension: 320,
};
const summary = outputOf(
await call("capture_windows_window_screenshot", input),
"capture_windows_window_screenshot",
);
assert.equal(summary.normalized_result.kind, "summary");
assert.equal(JSON.stringify(summary).includes("data_base64"), false);
const imageView = outputOf(
await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: summary.normalized_result.parent_evidence_id,
},
view: { kind: "windows-window", facet: "screenshot" },
}),
"inspect_analysis_view",
).normalized_result;
report.cases.firstFramePixelDifference = pixels(
imageView.item,
[40, 120, 200],
);
assert.equal(imageView.item.source_width, first.identity.width);
assert.equal(imageView.item.source_height, first.identity.height);
report.cases.helperWithoutWritableTemp =
await captureWithoutWritableTemp(first);
await first.command("change");
assert.equal((await first.next()).event, "changed");
const changed = outputOf(
await call("capture_windows_window_screenshot", {
...input,
detail: "complete",
}),
"capture_windows_window_screenshot",
);
report.cases.freshFramePixelDifference = pixels(
changed.normalized_result.screenshot,
[80, 180, 40],
);
assert.notEqual(
changed.normalized_result.screenshot.sha256,
imageView.item.sha256,
);
const reread = outputOf(
await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: summary.normalized_result.parent_evidence_id,
},
view: { kind: "windows-window", facet: "screenshot" },
}),
"inspect_analysis_view",
);
assert.equal(reread.normalized_result.item.sha256, imageView.item.sha256);
report.cases.retainedFrameImmutable = true;
refusal(
await call("capture_windows_window_screenshot", {
...input,
pid: process.pid,
}),
"invalid_request",
);
report.cases.wrongPidRefused = true;
const cli = await exec(
process.execPath,
[
join(packageRoot, "scripts/rea.mjs"),
"capture-windows-window-screenshot",
fixturePath,
"--pid",
String(input.pid),
"--window-handle",
input.window_handle,
"--max-dimension",
"320",
"--json",
],
{
env: environment,
timeout: 60000,
maxBuffer: 10 * 1024 * 1024,
windowsHide: true,
},
);
const cliEvidence = toolContract(
"capture_windows_window_screenshot",
).outputSchema.parse(JSON.parse(cli.stdout));
pixels(cliEvidence.normalized_result.screenshot, [80, 180, 40]);
report.cases.cliPixelsAndContract = true;
await stop(first);
refusal(
await call("capture_windows_window_screenshot", input),
"invalid_request",
);
report.cases.destroyedWindowRefused = true;
const ansi = await launch("ansi");
const ansiFrame = outputOf(
await call("capture_windows_window_screenshot", {
pid: ansi.identity.pid,
window_handle: ansi.identity.window_handle,
max_dimension: 320,
detail: "complete",
}),
"capture_windows_window_screenshot",
);
pixels(ansiFrame.normalized_result.screenshot, [40, 120, 200]);
report.cases.ansiTitle = {
estimate: ansi.identity.title_length_estimate,
copied: ansi.identity.title_copied,
};
await stop(ansi);
const oversized = await launch("large");
assert.ok(
oversized.identity.width * oversized.identity.height > 4 * 1024 * 1024,
);
refusal(
await call("capture_windows_window_screenshot", {
pid: oversized.identity.pid,
window_handle: oversized.identity.window_handle,
}),
"resource_constraint",
);
report.cases.sourcePixelLimit = true;
await stop(oversized);
const noisy = await launch("noise");
const noisyInput = {
pid: noisy.identity.pid,
window_handle: noisy.identity.window_handle,
};
refusal(
await call("capture_windows_window_screenshot", noisyInput),
"resource_constraint",
);
const noiseSummary = outputOf(
await call("capture_windows_window_screenshot", {
...noisyInput,
max_dimension: 512,
}),
"capture_windows_window_screenshot",
);
const summaryBytes = report.metrics.at(-1).wireBytes;
const noiseImage = outputOf(
await call("inspect_analysis_view", {
source: {
kind: "retained-evidence",
evidence_id: noiseSummary.normalized_result.parent_evidence_id,
},
view: { kind: "windows-window", facet: "screenshot" },
}),
"inspect_analysis_view",
).normalized_result.item;
const imageBytes = report.metrics.at(-1).wireBytes;
assert.ok(noiseImage.bytes > 16000 && imageBytes > summaryBytes * 10);
assert.equal(noiseImage.width, 512);
report.cases.pngCapacityAndRetry = {
pngBytes: noiseImage.bytes,
summaryWireBytes: summaryBytes,
imageWireBytes: imageBytes,
};
await stop(noisy);
const blocked = await launch();
await blocked.command("block");
assert.equal((await blocked.next()).event, "blocking");
const controller = new AbortController();
const reason = "REA selected-window acceptance cancellation";
const send = transport.send.bind(transport);
// Cancel after the selected request was written, instead of racing a cached
// compositor response or cancelling an RPC that never left the client.
transport.send = async (message, options) => {
await send(message, options);
if (
message.method === "tools/call" &&
message.params.name === "capture_windows_window_screenshot" &&
message.params.arguments.pid === blocked.identity.pid
)
controller.abort(reason);
};
const pending = client.callTool(
{
name: "capture_windows_window_screenshot",
arguments: {
pid: blocked.identity.pid,
window_handle: blocked.identity.window_handle,
},
},
{ signal: controller.signal, timeout: 60000 },
);
try {
await assert.rejects(
pending,
(cause) => cause instanceof Error && cause.message === reason,
);
} finally {
transport.send = send;
await stop(blocked);
}
const recovery = await launch();
let attempts = 0;
while (true) {
const response = await call("capture_windows_window_screenshot", {
pid: recovery.identity.pid,
window_handle: recovery.identity.window_handle,
max_dimension: 320,
detail: "complete",
});
attempts++;
if (response.isError !== true) {
pixels(
outputOf(response, "capture_windows_window_screenshot")
.normalized_result.screenshot,
[40, 120, 200],
);
break;
}
refusal(response, "resource_constraint");
assert.ok(
attempts < 10,
"Cancelled helper did not release capture admission",
);
await new Promise((resolve) => setTimeout(resolve, 100));
}
report.cases.cancellationAndRecovery = { attempts };
await stop(recovery);
const copy = join(root, "identical-other-file.exe");
await copyFile(fixturePath, copy);
const other = await launch();
outputOf(await call("open_binary", { path: copy }), "open_binary");
refusal(
await call("capture_windows_window_screenshot", {
pid: other.identity.pid,
window_handle: other.identity.window_handle,
detail: "complete",
}),
"invalid_request",
);
report.cases.identicalBytesDifferentFileRefused = true;
await stop(other);
outputOf(await call("close_binary", {}), "close_binary");
await client.close();
client = undefined;
assert.equal(digest(await readFile(fixturePath)), original);
report.cases.fixtureUnchanged = true;
report.runtimeResidualCount = (await readdir(runtime)).length;
assert.equal(report.runtimeResidualCount, 0);
report.ok = true;
} catch (cause) {
report.failure = {
name: cause instanceof Error ? cause.name : "UnknownError",
message: cause instanceof Error ? cause.message : String(cause),
};
if (cause !== null && typeof cause === "object") {
for (const name of ["code", "signal", "killed"])
if (["string", "number", "boolean"].includes(typeof cause[name]))
report.failure[name] = cause[name];
if (typeof cause.stderr === "string")
report.failure.stderr = cause.stderr.slice(0, 4096);
if (typeof cause.stdout === "string") {
report.failure.stdoutBytes = Buffer.byteLength(cause.stdout);
try {
report.failure.stdoutError = JSON.parse(cause.stdout).error ?? null;
} catch {
/* Exit diagnostics need not be JSON. */
}
}
}
throw cause;
} finally {
const cleanupFailures = [];
try {
await client?.close();
} catch (cause) {
cleanupFailures.push(cause);
}
for (const process of owned) {
try {
const stopped = await process.cleanup();
if (!stopped.cleaned)
throw new Error("Owned GUI fixture cleanup remains incomplete");
owned.delete(process);
} catch (cause) {
cleanupFailures.push(cause);
}
}
// Do not erase ownership evidence if any process cleanup remains unverified.
if (cleanupFailures.length === 0) {
await rm(root, { recursive: true, force: true });
report.workspaceCleanup = true;
} else {
report.ok = false;
report.workspaceCleanup = false;
report.cleanupFailureCount = cleanupFailures.length;
}
if (reportArgument !== undefined)
await writeFile(reportArgument, JSON.stringify(report, null, 2) + "\n");
if (cleanupFailures.length !== 0)
throw new AggregateError(
cleanupFailures,
"Acceptance cleanup remains incomplete; retain its workspace",
);
}
process.stdout.write(JSON.stringify(report) + "\n");
+25
View File
@@ -35,6 +35,9 @@ const statusSchema = z.object({
open: z.boolean(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
executable_role: z
.enum(["application", "shared-library", "non-executable"])
.optional(),
capabilities: z
.array(providerCapabilitySet)
.transform(expandProviderCapabilities),
@@ -97,6 +100,11 @@ type AvailabilityContext = {
| "artifact"
| undefined;
readonly targetFormat: string | undefined;
readonly targetExecutableRole:
| "application"
| "shared-library"
| "non-executable"
| undefined;
readonly descriptors: ReadonlyMap<string, ProviderDescriptor>;
readonly policy: AvailabilityPolicy;
};
@@ -152,6 +160,9 @@ const MACHO_TARGET_OPERATIONS: ReadonlySet<string> = new Set([
"observe_native_ui",
"capture_native_ui_scenario",
]);
const PE_APPLICATION_TARGET_OPERATIONS: ReadonlySet<string> = new Set([
"capture_windows_window_screenshot",
]);
const NAVIGATION_CONTEXT_MODES = [
{
@@ -185,6 +196,7 @@ export const buildCapabilityInventory = (
targetOpen: status.open,
targetKind: status.kind,
targetFormat: status.format,
targetExecutableRole: status.executable_role,
descriptors,
policy,
});
@@ -614,6 +626,7 @@ const targetAvailability = ({
kind,
targetKind,
targetFormat,
targetExecutableRole,
targetOpen,
}: AvailabilityContext): Availability | null => {
if (FRIDA_OPERATION_NAMES.has(name)) return null;
@@ -643,6 +656,18 @@ const targetAvailability = ({
remediation:
"Call open_binary with a Mach-O executable or an app bundle whose main executable is Mach-O.",
};
if (
targetOpen &&
PE_APPLICATION_TARGET_OPERATIONS.has(name) &&
(targetKind !== "executable" ||
targetFormat !== "pe" ||
targetExecutableRole !== "application")
)
return {
reason: "target_unsupported",
remediation:
"Call open_binary with a PE application executable on a Windows x64 host.",
};
return null;
};
@@ -0,0 +1,56 @@
import { expect, it } from "vitest";
import { buildCapabilityInventory } from "./CapabilityInventory.js";
const policy = { processCaptureEnabled: false };
const capability = (
format: string,
executableRole:
| "application"
| "shared-library"
| "non-executable" = "application",
) =>
buildCapabilityInventory(
{
open: true,
kind: "executable",
format,
executable_role: executableRole,
capabilities: [
{
provider: {
id: "native-windows-ui",
name: "Windows selected-window capture",
version: null,
},
shared_limitations: [],
groups: [
{
operations: ["capture_windows_window_screenshot"],
effects: [],
limitations: [],
available: true,
reason: null,
availability_code: null,
},
],
},
],
},
policy,
).find((entry) => entry.name === "capture_windows_window_screenshot");
it("advertises selected-window capture only for an active PE executable", () => {
expect(capability("pe")).toMatchObject({
available: true,
reason: "available",
});
expect(capability("mach-o")).toMatchObject({
available: false,
reason: "target_unsupported",
});
expect(capability("pe", "shared-library")).toMatchObject({
available: false,
reason: "target_unsupported",
});
});
@@ -105,6 +105,9 @@ export const binarySessionStatus = ({
open: true,
...common,
...binaryTargetSummary(target),
...(target.format === "pe"
? { executable_role: target.executableRole }
: {}),
};
};
+48
View File
@@ -14,6 +14,7 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { safeParseJson } from "../domain/safeJson.js";
import { nativeUiScenarioInputSchema } from "../domain/native/nativeUiObservation.js";
import { windowsWindowScreenshotInputSchema } from "../domain/native/windowsWindowScreenshot.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { logCliCommand } from "../cliLogging.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
@@ -34,6 +35,53 @@ export const registerUtilityCommands = (
registerNativeCommands(cli, logger, runProviderAnalysis);
registerNativeCallCommands(cli, logger, runProviderAnalysis);
registerNativeUiCommands(cli, logger, runProviderAnalysis);
cli.command(CLI_COMMANDS.captureWindowsWindowScreenshot, {
description:
"Capture one passive PNG of the exact HWND owned by an active PE application",
args: z.object({
path: z.string().describe("Local PE application path"),
}),
options: z.object({
pid: z.number().int().positive().describe("Existing application PID"),
windowHandle: z
.string()
.describe("Exact canonical lowercase HWND, for example 0x1234"),
maxDimension: z
.number()
.int()
.min(1)
.max(4096)
.default(2048)
.describe("Maximum rendered PNG width or height"),
}),
run: ({ args, options }) =>
logCliCommand(
logger,
CLI_COMMANDS.captureWindowsWindowScreenshot,
async () => {
const parsed = windowsWindowScreenshotInputSchema.safeParse({
pid: options.pid,
window_handle: options.windowHandle,
max_dimension: options.maxDimension,
});
if (!parsed.success)
return invalidNativeUiScenarioInput(
"capture_windows_window_screenshot",
projectInputIssues(parsed.error.issues, {
pid: options.pid,
window_handle: options.windowHandle,
max_dimension: options.maxDimension,
}),
);
return runProviderAnalysis(
args.path,
"capture_windows_window_screenshot",
parsed.data,
logger,
);
},
),
});
registerReferenceSourceCommand(cli, logger, environment);
};
+1
View File
@@ -115,6 +115,7 @@ export const CLI_COMMANDS = Object.freeze({
observeNativeUi: "observe-native-ui",
observeNativeCalls: "observe-native-calls",
captureNativeUiScenario: "capture-native-ui-scenario",
captureWindowsWindowScreenshot: "capture-windows-window-screenshot",
inspectMacho: "inspect-macho",
inspectSignature: "inspect-signature",
listArchitectures: "list-architectures",
+14 -1
View File
@@ -10,12 +10,16 @@ import {
NATIVE_MACOS_PROVIDER_IDENTITY,
nativeMacOSCapabilities,
} from "../native/NativeMacOSProviderMetadata.js";
import {
NATIVE_WINDOWS_UI_PROVIDER_IDENTITY,
nativeWindowsUiCapabilities,
} from "../windows/NativeWindowsUiProviderMetadata.js";
type AuxiliaryProviderDeclaration = ConstructorParameters<
typeof LazyAnalysisProvider
>[0];
/** Wire the three existing auxiliary AnalysisProvider implementations lazily. */
/** Wire auxiliary AnalysisProvider implementations lazily. */
export const auxiliaryAnalysisProviderDeclarations = (
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
@@ -40,6 +44,15 @@ export const auxiliaryAnalysisProviderDeclarations = (
return new NativeMacOSProvider(environment, undefined, platform);
},
},
{
identity: NATIVE_WINDOWS_UI_PROVIDER_IDENTITY,
capabilities: nativeWindowsUiCapabilities(platform),
load: async () => {
const { NativeWindowsUiProvider } =
await import("../windows/NativeWindowsUiProvider.js");
return new NativeWindowsUiProvider(environment, undefined, platform);
},
},
{
identity: MANAGED_STATIC_PROVIDER,
capabilities: managedStaticCapabilities(),
@@ -4,6 +4,7 @@ import {
nativeUiObservationInputSchema,
nativeUiScenarioInputSchema,
} from "../../domain/native/nativeUiObservation.js";
import { windowsWindowScreenshotInputSchema } from "../../domain/native/windowsWindowScreenshot.js";
import type { ToolContract } from "../toolContractTypes.js";
import { nativeOutputSchemas } from "../toolOutputSchemaGroups.js";
@@ -19,6 +20,10 @@ const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
inspect_plist: { path: "/Applications/Example.app/Contents/Info.plist" },
list_architectures: {},
observe_native_ui: { pid: 123, window_id: 456 },
capture_windows_window_screenshot: {
pid: 123,
window_handle: "0x1234",
},
capture_native_ui_scenario: {
pid: 123,
window_id: 456,
@@ -76,6 +81,13 @@ export const swiftSymbolsSchema = z.array(
/** Provider-neutral semantic operations backed initially by macOS utilities. */
export const NATIVE_TOOL_CONTRACTS = [
native(
"capture_windows_window_screenshot",
"Capture one PNG of the exact existing HWND owned by the active PE application, with identity checks before and after capture. Default summary retains the full frame and returns metadata; read the same image through inspect_analysis_view kind windows-window facet screenshot using parent_evidence_id, or request detail complete for inline PNG bytes. No desktop, region, title-search, input or target launch. An owned helper enforces cancellation and timeout; PrintWindow can run target paint code.",
windowsWindowScreenshotInputSchema.extend({
detail: analysisDetailSchema.unwrap().default("summary"),
}),
),
native(
"observe_native_ui",
"Observe an already-running native app by PID and window ID, bound to the active Mach-O target. Captures a bounded accessibility tree and selected-window screenshot; OS permission denial fails without broad capture or target launch.",
+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",
+10
View File
@@ -565,6 +565,16 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
launchesProcess: true,
writesFilesystem: true,
}),
capture_windows_window_screenshot: effects({
launchesProcess: true,
mutatesTarget: true,
mutatesSession: true,
writesFilesystem: true,
accessesNetwork: true,
changesUiState: true,
executesForeignCode: true,
idempotent: false,
}),
capture_native_ui_scenario: effects({
writesFilesystem: true,
mutatesTarget: true,
+11
View File
@@ -4,6 +4,7 @@ import {
artifactInventorySummaryViewResultSchema,
interfaceBuilderSummaryViewResultSchema,
machoSummaryViewResultSchema,
windowsWindowSummaryViewResultSchema,
managedArtifactSummaryViewResultSchema,
managedMemberSummaryViewResultSchema,
} from "../domain/analysisView/analysisView.js";
@@ -16,6 +17,7 @@ import {
import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js";
import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js";
import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js";
import { windowsWindowScreenshotResultSchema } from "../domain/native/windowsWindowScreenshot.js";
import { nativeValueTraceSchema } from "../domain/native/nativeValueTrace.js";
import { nativeDataTypeSchema } from "../domain/native/nativeDataType.js";
import { nativeCallObservationResultSchema } from "../domain/native/nativeCallObservation.js";
@@ -388,6 +390,12 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
/** Exact Evidence schemas for provider-neutral native inspection. */
export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
capture_windows_window_screenshot: evidenceResultOf(
z.union([
windowsWindowScreenshotResultSchema,
windowsWindowSummaryViewResultSchema,
]),
),
observe_native_ui: evidenceResultOf(nativeUiResultSchema),
capture_native_ui_scenario: evidenceResultOf(nativeUiResultSchema),
observe_native_calls: evidenceResultOf(nativeCallObservationResultSchema),
@@ -464,6 +472,9 @@ const lifecycleTargetFields = {
};
const detectedLifecycleTargetSchema = z.object({
...lifecycleTargetFields,
executable_role: z
.enum(["application", "shared-library", "non-executable"])
.exactOptional(),
format: targetFormatSchema.exclude(["raw-binary"]),
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
});
+14 -3
View File
@@ -163,19 +163,30 @@ export class AnalysisTimeoutError extends AnalysisError {
}
}
/** Host filesystem permissions denied the selected read; this is not malformed caller input. */
/** Host permissions denied a selected filesystem read or process/window request. */
export class AnalysisAccessDeniedError extends AnalysisError {
readonly _tag = "AnalysisAccessDeniedError";
readonly processWindow:
| { readonly pid: number; readonly windowHandle: string }
| undefined;
constructor(
readonly operation: string,
readonly path: string,
readonly systemCode: "EACCES" | "EPERM",
options?: AnalysisErrorOptions,
options?: AnalysisErrorOptions & {
readonly processWindow?: {
readonly pid: number;
readonly windowHandle: string;
};
},
) {
super(
`Host filesystem read access denied (${systemCode}) for ${path} during ${operation}`,
options?.processWindow === undefined
? `Host filesystem read access denied (${systemCode}) for ${path} during ${operation}`
: `Host process/window access denied (${systemCode}) during ${operation}`,
options,
);
this.processWindow = options?.processWindow;
}
}
+10
View File
@@ -102,6 +102,11 @@ export const analysisErrorRemediationAction = (
error.constraint === "wasm_requires_static_inspection"
)
return `For static inspection, call inspect_wasm_artifact with ${JSON.stringify({ path: error.path })} or run \`rea inspect-wasm-artifact <path> --format json\`. This separate lane requires caller-supplied WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; check its availability in binary_session. REA does not execute WASM or install tools, and the header alone does not establish that the complete module is valid.`;
if (
error instanceof AnalysisAccessDeniedError &&
error.processWindow !== undefined
)
return "Check the current process's permission to query the selected process and request its window paint handler, then retry the same selected window.";
if (
error instanceof AnalysisAccessDeniedError ||
(error instanceof BinaryTargetError && error.systemCode !== undefined)
@@ -225,6 +230,11 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
if (error instanceof AnalysisSnapshotMismatchError) return error.message;
if (error instanceof AnalysisUnsupportedTargetError) return error.message;
if (error instanceof AnalysisResourceConstraintError) return error.reason;
if (
error instanceof AnalysisAccessDeniedError &&
error.processWindow !== undefined
)
return "Windows denied access to the selected process or window.";
if (error instanceof AnalysisAccessDeniedError)
return "Host filesystem permissions denied read access to the selected path.";
if (error instanceof AnalysisArtifactChangedError)
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
AnalysisCapabilityUnavailableError,
AnalysisAccessDeniedError,
AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
@@ -866,3 +867,28 @@ it("retains the primary failure and both cleanup reports after a secondary failu
},
});
});
it("preserves process/window access refusal without inventing a filesystem path", () => {
const projected = projectAnalysisError(
new AnalysisAccessDeniedError(
"capture_windows_window_screenshot",
"",
"EACCES",
{ processWindow: { pid: 42, windowHandle: "0x1234" } },
),
);
expect(projected).toMatchObject({
code: "access_denied",
category: "unavailable",
details: {
boundary: "process-window",
pid: 42,
window_handle: "0x1234",
system_code: "EACCES",
},
});
expect(projected.details).not.toHaveProperty("path");
expect(projected.remediation.action).toContain("selected process");
expect(projected.remediation.action).not.toContain("readable local file");
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
});
+11
View File
@@ -266,6 +266,17 @@ const requestErrorDetails = (
reason: error.reason,
boundary: "stable-artifact-read",
};
if (
error instanceof AnalysisAccessDeniedError &&
error.processWindow !== undefined
)
return {
operation: error.operation,
pid: error.processWindow.pid,
window_handle: error.processWindow.windowHandle,
system_code: error.systemCode,
boundary: "process-window",
};
if (error instanceof AnalysisAccessDeniedError)
return {
operation: error.operation,
+30 -53
View File
@@ -115,6 +115,13 @@ import {
dartAotViewContextSchema,
} from "./dartAotView.js";
import {
projectWindowsWindowView,
windowsWindowSummarySchema,
windowsWindowViewRequestSchema,
} from "./windowsWindowView.js";
import { windowsWindowScreenshotResultSchema } from "../native/windowsWindowScreenshot.js";
const summaryViewSchema = z.strictObject({ kind: z.literal("summary") });
const facetViewSchema = z.strictObject({
kind: z.literal("facet"),
@@ -226,6 +233,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
inventoryViewSchema,
wasmViewRequestSchema,
machoViewRequestSchema,
windowsWindowViewRequestSchema,
interfaceBuilderViewRequestSchema,
evmViewRequestSchema,
managedViewRequestSchema,
@@ -348,6 +356,7 @@ const viewResultBase = {
"inventory_artifact",
"inspect_wasm_artifact",
"inspect_macho",
"capture_windows_window_screenshot",
"decode_interface_builder",
"inspect_evm_interface",
"inspect_managed_artifact",
@@ -416,6 +425,13 @@ export const machoSummaryViewResultSchema = z.strictObject({
summary: machoSummarySchema,
});
/** Exact metadata-only summary for a retained selected-window frame. */
export const windowsWindowSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("capture_windows_window_screenshot"),
summary: windowsWindowSummarySchema,
});
/** Decoded documents and graph counts for Interface Builder's selected delivery. */
export const interfaceBuilderSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -492,6 +508,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
inventorySummarySchema,
wasmSummarySchema,
machoSummarySchema,
windowsWindowSummarySchema,
interfaceBuilderSummarySchema,
evmSummarySchema,
managedSummarySchema,
@@ -557,6 +574,13 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
item: wasmViewItemSchema,
...viewResultBase,
}),
z.strictObject({
kind: z.literal("windows-window"),
view: windowsWindowViewRequestSchema,
item: windowsWindowScreenshotResultSchema.shape.screenshot,
...viewResultBase,
parent_operation: z.literal("capture_windows_window_screenshot"),
}),
z.strictObject({
kind: z.literal("macho"),
view: machoViewRequestSchema,
@@ -652,59 +676,11 @@ export interface AnalysisViewParent {
}
/** View payload before the content digest is attached. */
export type UnsignedAnalysisView =
| Omit<
Extract<AnalysisViewResult, { readonly kind: "api-surface" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "summary" }>,
"view_digest"
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "facet" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "item" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "page" }>, "view_digest">
| Omit<
Extract<AnalysisViewResult, { readonly kind: "javascript" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "native" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "inventory" }>,
"view_digest"
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "macho" }>, "view_digest">
| Omit<
Extract<AnalysisViewResult, { readonly kind: "interface-builder" }>,
"view_digest"
>
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "view_digest">
| Omit<
Extract<AnalysisViewResult, { readonly kind: "capture" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "managed" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "managed-members" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "android" }>,
"view_digest"
>
| Omit<
Extract<AnalysisViewResult, { readonly kind: "dart-aot" }>,
"view_digest"
>;
type WithoutViewDigest<View extends AnalysisViewResult> =
View extends AnalysisViewResult ? Omit<View, "view_digest"> : never;
/** Distribute over every named view so new variants retain their exact fields. */
export type UnsignedAnalysisView = WithoutViewDigest<AnalysisViewResult>;
/** Attach the content digest that must change when projected bytes change. */
export const sealAnalysisView = (
@@ -777,6 +753,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_wasm_artifact", projectWasmArtifactView],
["inspect_macho", projectMachoView],
["capture_windows_window_screenshot", projectWindowsWindowView],
["decode_interface_builder", projectInterfaceBuilderView],
["inspect_evm_interface", projectEvmInterfaceView],
["inspect_managed_artifact", projectManagedArtifactView],
@@ -0,0 +1,84 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { windowsWindowScreenshotResultSchema } from "../native/windowsWindowScreenshot.js";
import { err, ok, type Result } from "../result.js";
import type {
AnalysisViewParent,
AnalysisViewRequest,
UnsignedAnalysisView,
} from "./analysisView.js";
import { completeWithinViewCoverage } from "./analysisViewCoverage.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
const { data_base64: _imageBytes, ...metadata } =
windowsWindowScreenshotResultSchema.shape.screenshot.shape;
/** Window identity and image facts without allocating another base64 response. */
export const windowsWindowSummarySchema = z.strictObject({
target_sha256: windowsWindowScreenshotResultSchema.shape.target_sha256,
window: windowsWindowScreenshotResultSchema.shape.window,
screenshot: z.strictObject(metadata),
gaps: windowsWindowScreenshotResultSchema.shape.gaps,
});
/** An explicit image read reuses the retained frame, without recapturing UI. */
export const windowsWindowViewRequestSchema = z.strictObject({
kind: z.literal("windows-window"),
facet: z.literal("screenshot"),
});
export const projectWindowsWindowView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "windows-window")
return err(
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
"Windows window capture supports summary and windows-window screenshot views.",
},
]),
);
const parsed = parseAnalysisViewResult(
windowsWindowScreenshotResultSchema,
parent,
);
if (!parsed.ok) return parsed;
const report = parsed.value;
const common = {
parent_evidence_id: parent.evidenceId,
parent_operation: "capture_windows_window_screenshot" as const,
parent_digest: parent.evidenceId.slice(3),
artifact:
parent.subject == null
? { path: "", sha256: null }
: {
path: parent.subject.local_path,
sha256: parent.subject.digest.sha256,
},
coverage: completeWithinViewCoverage(1, 1),
limitations: [...parent.limitations],
unknowns: [
"Successful PNG capture does not establish complete rendering or physical-display visibility.",
],
};
if (view.kind === "windows-window")
return ok({
kind: "windows-window",
view,
item: report.screenshot,
...common,
});
const { data_base64: _bytes, ...screenshot } = report.screenshot;
return ok({
kind: "summary",
view,
...common,
summary: { ...report, screenshot },
});
};
@@ -0,0 +1,103 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import {
MAX_WINDOWS_WINDOW_PNG_BYTES,
windowsWindowPngSchema,
windowsWindowScreenshotInputSchema,
} from "./windowsWindowScreenshot.js";
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAF/gJ+Qv1aRwAAAABJRU5ErkJggg==",
"base64",
);
it("accepts a self-verifying canonical PNG for a selected Windows window", () => {
expect(
windowsWindowPngSchema.parse({
media_type: "image/png",
data_base64: PNG.toString("base64"),
sha256: createHash("sha256").update(PNG).digest("hex"),
bytes: PNG.byteLength,
width: 1,
height: 1,
source_width: 1,
source_height: 1,
}),
).toMatchObject({ width: 1, height: 1 });
});
it("rejects a non-canonical or zero HWND before native capture", () => {
expect(
windowsWindowScreenshotInputSchema.safeParse({
pid: 1,
window_handle: "0x0001",
}).success,
).toBe(false);
expect(
windowsWindowScreenshotInputSchema.safeParse({
pid: 1,
window_handle: "0x0",
}).success,
).toBe(false);
});
it("rejects PNG dimensions that disagree with its IHDR", () => {
expect(
windowsWindowPngSchema.safeParse({
media_type: "image/png",
data_base64: PNG.toString("base64"),
sha256: createHash("sha256").update(PNG).digest("hex"),
bytes: PNG.byteLength,
width: 2,
height: 1,
source_width: 2,
source_height: 1,
}).success,
).toBe(false);
});
it.each(["chunk-length", "high-bit-chunk-type"] as const)(
"rejects a self-consistent digest with a malformed %s",
(kind) => {
const bytes = Buffer.from(PNG);
if (kind === "chunk-length") bytes.writeUInt32BE(12, 8);
else for (let index = 12; index < 16; index++) bytes[index]! |= 0x80;
expect(
windowsWindowPngSchema.safeParse({
media_type: "image/png",
data_base64: bytes.toString("base64"),
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.byteLength,
width: 1,
height: 1,
source_width: 1,
source_height: 1,
}).success,
).toBe(false);
},
);
it("bounds encoded PNG bytes independently of the declared size", () => {
const result = windowsWindowPngSchema.safeParse({
media_type: "image/png",
data_base64: "A".repeat(
4 * Math.ceil(MAX_WINDOWS_WINDOW_PNG_BYTES / 3) + 4,
),
sha256: "a".repeat(64),
bytes: 1,
width: 1,
height: 1,
source_width: 1,
source_height: 1,
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.issues).toEqual([
expect.objectContaining({
path: ["data_base64"],
message: "PNG base64 exceeds the retained byte limit",
}),
]);
});
@@ -0,0 +1,103 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { decodeCanonicalBase64 } from "../webScreenshot.js";
import { readPngHeaderDimensions } from "../pngHeader.js";
/** Fits the duplicated structured/text MCP delivery inside the default 10 MiB buffer. */
export const MAX_WINDOWS_WINDOW_PNG_BYTES = 3 * 1024 * 1024;
/** A Win64 HWND stays textual so no JavaScript number truncates its identity. */
export const windowsWindowHandleSchema = z
.string()
.regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/u)
.refine((value) => value !== "0x0", "Window handle must not be zero");
/** Passive capture admits exactly one already-running selected native window. */
export const windowsWindowScreenshotInputSchema = z.strictObject({
pid: z.number().int().positive().max(0xffff_ffff),
window_handle: windowsWindowHandleSchema,
max_dimension: z.number().int().min(1).max(4096).default(2048),
});
/** Self-verifying inline PNG, including both rendered and pre-scale dimensions. */
export const windowsWindowPngSchema = z
.strictObject({
media_type: z.literal("image/png"),
data_base64: z.string().min(4),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
bytes: z.number().int().min(1).max(MAX_WINDOWS_WINDOW_PNG_BYTES),
width: z.number().int().positive(),
height: z.number().int().positive(),
source_width: z.number().int().positive(),
source_height: z.number().int().positive(),
})
.superRefine((screenshot, context) => {
// Enforce the encoded bound before decoding, even if the declared byte
// count is small. Base64 expands each three bytes to four characters.
if (
screenshot.data_base64.length >
4 * Math.ceil(MAX_WINDOWS_WINDOW_PNG_BYTES / 3)
) {
context.addIssue({
code: "custom",
path: ["data_base64"],
message: "PNG base64 exceeds the retained byte limit",
});
return;
}
const bytes = decodeCanonicalBase64(screenshot.data_base64);
if (bytes === undefined) {
context.addIssue({
code: "custom",
message: "Invalid canonical PNG base64",
});
return;
}
if (bytes.byteLength !== screenshot.bytes)
context.addIssue({
code: "custom",
path: ["bytes"],
message: "PNG byte count mismatch",
});
if (createHash("sha256").update(bytes).digest("hex") !== screenshot.sha256)
context.addIssue({
code: "custom",
path: ["sha256"],
message: "PNG digest mismatch",
});
const dimensions = readPngHeaderDimensions(bytes);
if (dimensions === undefined) {
context.addIssue({
code: "custom",
message: "Screenshot bytes are not a PNG image",
});
return;
}
if (
dimensions.width !== screenshot.width ||
dimensions.height !== screenshot.height
)
context.addIssue({
code: "custom",
message: "PNG dimensions mismatch",
});
});
/** Target-bound native-window evidence; UIA and input control are intentionally absent. */
export const windowsWindowScreenshotResultSchema = z.strictObject({
target_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
window: z.strictObject({
pid: z.number().int().positive(),
window_handle: windowsWindowHandleSchema,
executable: z.string().min(1),
process_image_path: z.string().min(1),
process_start_time_unix_ms: z.number().int().nonnegative(),
title: z.string(),
}),
screenshot: windowsWindowPngSchema,
gaps: z.array(z.string()),
});
export type WindowsWindowScreenshotResult = z.infer<
typeof windowsWindowScreenshotResultSchema
>;
+1
View File
@@ -465,6 +465,7 @@ class NativeMacOSClient implements AnalysisClient {
case "observe_native_ui":
case "capture_native_ui_scenario":
case "observe_native_calls":
case "capture_windows_window_screenshot":
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
+13 -1
View File
@@ -15,6 +15,16 @@ const OWNED_PROCESS_OPERATIONS: ReadonlySet<string> = new Set([
"capture_native_ui_scenario",
"observe_native_calls",
]);
const MACOS_NATIVE_OPERATIONS: ReadonlySet<string> = new Set([
"observe_native_ui",
"capture_native_ui_scenario",
"observe_native_calls",
"inspect_macho",
"inspect_signature",
"inspect_plist",
"list_architectures",
"demangle_swift",
]);
/** Declare native host coverage without creating a command runner. */
export const nativeMacOSCapabilities = (
@@ -23,7 +33,9 @@ export const nativeMacOSCapabilities = (
const available = platform === "darwin";
return Object.freeze(
[
...NATIVE_TOOL_CONTRACTS,
...NATIVE_TOOL_CONTRACTS.filter((contract) =>
MACOS_NATIVE_OPERATIONS.has(contract.name),
),
{ name: "inspect_native_dispatch_metadata" as const },
].map((contract): CapabilityDescriptor => {
const availability = available
+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(
+5 -2
View File
@@ -8,9 +8,12 @@ import {
type EvidenceToolFamilyOptions,
} from "./retainedSummaryDelivery.js";
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set(["inspect_macho"]);
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
"inspect_macho",
"capture_windows_window_screenshot",
]);
/** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */
/** Register native contracts with caller-selected retained summaries. */
export const registerNativeTools = (
server: EvidenceMcpServer,
options: EvidenceToolFamilyOptions,
@@ -0,0 +1,28 @@
import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { MAX_WINDOWS_WINDOW_PNG_BYTES } from "../domain/native/windowsWindowScreenshot.js";
import { encodeToolResult } from "./toolResultEncoding.js";
it("keeps the largest Windows window PNG inside the default duplicated MCP response budget", () => {
const bytes = Buffer.alloc(MAX_WINDOWS_WINDOW_PNG_BYTES);
const maximumPath = "C:" + "\\a".repeat(16382);
const result = {
subject: { local_path: maximumPath },
normalized_result: {
window: {
title: "\u0001".repeat(32767),
executable: maximumPath,
process_image_path: maximumPath,
},
screenshot: { data_base64: bytes.toString("base64") },
},
};
const encoded = encodeToolResult(
result,
STDIO_DEFAULT_MAX_BUFFER_SIZE - 1024,
);
expect(encoded.ok).toBe(true);
});
+415
View File
@@ -0,0 +1,415 @@
import { createHash } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { MAX_WINDOWS_WINDOW_PNG_BYTES } from "../domain/native/windowsWindowScreenshot.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { RegularFileAdmissionFailure } from "../filesystem/RegularFile.js";
import * as execution from "./WindowsWindowCaptureExecution.js";
import {
captureWindowsWindowScreenshot,
hashWindowsCaptureExecutable,
NativeWindowsUiProvider,
type WindowsWindowCaptureHost,
} from "./NativeWindowsUiProvider.js";
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAF/gJ+Qv1aRwAAAABJRU5ErkJggg==",
"base64",
);
const SHA256 = "a".repeat(64);
const TARGET = {
path: "C:\\Apps\\fixture.exe",
sha256: SHA256,
kind: "executable",
format: "pe",
architecture: "x86_64",
availableArchitectures: ["x86_64"],
executableRole: "application",
managed: false,
} satisfies BinaryTarget;
const captureReply = () => ({
pid: 42,
window_handle: "0x1234",
process_image_path: "C:\\Apps\\fixture.exe",
process_start_time_unix_ms: 1_700_000_000_000,
title: "Fixture",
source_width: 1,
source_height: 1,
width: 1,
height: 1,
png: PNG,
});
const host = (
overrides: Partial<WindowsWindowCaptureHost> = {},
): WindowsWindowCaptureHost => ({
capture: captureReply,
identifyExecutable: async () => ({
fileIdentity: "c:\\apps\\fixture.exe",
sha256: SHA256,
}),
...overrides,
});
it("attributes a helper-only close failure to selected-window resources", async () => {
const observation = {
reason: "Owned capture Job cleanup remains pending",
resources: ["capture-run:42"],
};
const cleanup = vi
.spyOn(execution, "retryWindowsCaptureCleanup")
.mockResolvedValue(observation);
try {
const client = new NativeWindowsUiProvider(
{},
host(),
"linux",
).createClient(TARGET);
expect(await client.close()).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
reason: "Selected-window capture resource cleanup remains incomplete.",
cleanup: observation,
},
});
} finally {
cleanup.mockRestore();
}
});
it("captures only an HWND whose live executable identity matches the active PE target", async () => {
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host(),
);
expect(captured.ok).toBe(true);
if (!captured.ok) return;
expect(captured.value).toMatchObject({
target_sha256: SHA256,
window: { pid: 42, window_handle: "0x1234" },
screenshot: {
sha256: createHash("sha256").update(PNG).digest("hex"),
width: 1,
height: 1,
},
});
});
it("fails closed when the selected PID maps to different executable bytes", async () => {
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({
capture: () => ({
pid: 42,
window_handle: "0x1234",
process_image_path: "C:\\Apps\\other.exe",
process_start_time_unix_ms: 1_700_000_000_000,
title: "Fixture",
source_width: 1,
source_height: 1,
width: 1,
height: 1,
png: PNG,
}),
identifyExecutable: async (path) => ({
fileIdentity: path.toLowerCase(),
sha256: path === TARGET.path ? SHA256 : "b".repeat(64),
}),
}),
);
expect(captured).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it("does not invoke native capture for a malformed HWND", async () => {
let invoked = false;
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x0001" },
host({
capture: () => {
invoked = true;
return {};
},
}),
);
expect(invoked).toBe(false);
expect(captured).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
it("classifies malformed native replies as output failures", async () => {
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({ capture: () => ({ pid: 42 }) }),
);
expect(captured).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
});
it("rejects an oversized native PNG before base64 allocation or post-capture hashing", async () => {
const png = Buffer.alloc(MAX_WINDOWS_WINDOW_PNG_BYTES + 1);
const encode = vi.spyOn(png, "toString");
const identifyExecutable = vi.fn(async () => ({
fileIdentity: "fixture",
sha256: SHA256,
}));
const captureHost = host({ identifyExecutable });
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
{
...captureHost,
capture: () => ({ ...captureReply(), png }),
},
);
expect(captured).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
expect(encode).not.toHaveBeenCalled();
expect(identifyExecutable).toHaveBeenCalledTimes(1);
});
it.each([
[2, "artifact_changed", "AnalysisArtifactChangedError"],
[3, "artifact_changed", "AnalysisArtifactChangedError"],
[6, "artifact_changed", "AnalysisArtifactChangedError"],
[13, "artifact_changed", "AnalysisArtifactChangedError"],
[5, "window_owner_mismatch", "AnalysisInputError"],
[13, "window_state", "AnalysisInputError"],
[5, "process_identity_mismatch", "AnalysisInputError"],
[87, "process_identity_mismatch", "AnalysisInputError"],
] as const)(
"preserves native refusal semantics for code %s and %s",
async (code, kind, tag) => {
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({
capture: () => {
throw Object.assign(new Error("Native refusal"), {
win32Code: code,
failureKind: kind,
});
},
}),
);
expect(captured).toMatchObject({ ok: false, error: { _tag: tag } });
},
);
it("keeps source-pixel capacity distinct from selected window state", async () => {
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({
capture: () => {
throw Object.assign(
new Error("Source area exceeds the capture limit"),
{
win32Code: 8,
failureKind: "source_pixel_limit",
},
);
},
}),
);
expect(captured).toMatchObject({
ok: false,
error: {
_tag: "AnalysisResourceConstraintError",
resource: "capacity",
reportedLimits: { maximum_source_pixels: 4 * 1024 * 1024 },
},
});
});
it("retains and retries an executable descriptor whose close first fails", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-window-capture-"));
const executable = join(directory, "fixture.exe");
const originalClose = OwnedFileHandle.prototype.close;
let firstClose = true;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle): Promise<void> {
if (firstClose) {
firstClose = false;
throw new Error("injected executable close failure");
}
await originalClose.call(this);
});
try {
await writeFile(executable, "fixture");
await expect(hashWindowsCaptureExecutable(executable)).rejects.toThrow(
"Selected executable descriptor cleanup failed",
);
close.mockRestore();
const client = new NativeWindowsUiProvider(
{},
host(),
"linux",
).createClient(TARGET);
await expect(client.close()).resolves.toMatchObject({ ok: true });
await expect(
hashWindowsCaptureExecutable(executable),
).resolves.toMatchObject({
sha256: createHash("sha256").update("fixture").digest("hex"),
});
} finally {
close.mockRestore();
await rm(directory, { force: true, recursive: true });
}
});
it("retains both final identity owners before returning their primary failure", async () => {
const targetClose = vi.fn(async () => undefined);
const processClose = vi.fn(async () => undefined);
const targetOwner = new OwnedFileHandle({
fd: 701,
close: targetClose,
}) as unknown as OwnedFileHandle;
const processOwner = new OwnedFileHandle({
fd: 702,
close: processClose,
}) as unknown as OwnedFileHandle;
const targetFailure = new RegularFileAdmissionFailure(
TARGET.path,
new Error("target admission failed"),
new Error("target close failed"),
targetOwner,
);
const processPath = "C:\\Apps\\process.exe";
const processFailure = new RegularFileAdmissionFailure(
processPath,
new Error("process admission failed"),
new Error("process close failed"),
processOwner,
);
let initialIdentity = true;
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({
capture: () => ({
pid: 42,
window_handle: "0x1234",
process_image_path: processPath,
process_start_time_unix_ms: 1_700_000_000_000,
title: "Fixture",
source_width: 1,
source_height: 1,
width: 1,
height: 1,
png: PNG,
}),
identifyExecutable: async (path) => {
if (initialIdentity) {
initialIdentity = false;
return { fileIdentity: "c:\\apps\\fixture.exe", sha256: SHA256 };
}
throw path === TARGET.path ? targetFailure : processFailure;
},
}),
);
expect(captured).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
cleanup: { resources: [TARGET.path, processPath] },
},
});
const client = new NativeWindowsUiProvider({}, host(), "linux").createClient(
TARGET,
);
await expect(client.close()).resolves.toMatchObject({ ok: true });
expect(targetClose).toHaveBeenCalledTimes(1);
expect(processClose).toHaveBeenCalledTimes(1);
});
it("does not commit a screenshot cancelled during final identity reads", async () => {
const controller = new AbortController();
let reads = 0;
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({
identifyExecutable: async () => {
if (++reads > 1) controller.abort();
return { fileIdentity: "c:\\apps\\fixture.exe", sha256: SHA256 };
},
}),
{ signal: controller.signal },
);
expect(captured).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
it.each([undefined, ""])(
"preserves pathless native permission refusals (%s)",
async (requestedPath) => {
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({
capture: () => {
throw Object.assign(
new Error("Windows refused process/window access"),
{
win32Code: 5,
...(requestedPath === undefined ? {} : { requestedPath }),
},
);
},
}),
);
expect(captured).toMatchObject({
ok: false,
error: {
_tag: "AnalysisAccessDeniedError",
processWindow: { pid: 42, windowHandle: "0x1234" },
},
});
},
);
it("reports concurrent native capture admission as bounded capacity", async () => {
const captured = await captureWindowsWindowScreenshot(
TARGET,
{ pid: 42, window_handle: "0x1234" },
host({
capture: () => {
throw { win32Code: 170 };
},
}),
);
expect(captured).toMatchObject({
ok: false,
error: {
_tag: "AnalysisResourceConstraintError",
resource: "capacity",
reportedLimits: { maximum_concurrent_captures: 1 },
},
});
});
+761
View File
@@ -0,0 +1,761 @@
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import { z } from "zod";
import {
createAnalysisExecution,
type AnalysisClient,
type AnalysisProvider,
type CapabilityDescriptor,
type ExecutionOptions,
type ProviderIdentity,
} from "../application/AnalysisProvider.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
openRegularFile,
RegularFileAdmissionFailure,
RegularFileChangedError,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
AnalysisError,
type AnalysisCleanupObservation,
} from "../domain/analysisErrorBase.js";
import {
MAX_WINDOWS_WINDOW_PNG_BYTES,
windowsWindowPngSchema,
windowsWindowScreenshotInputSchema,
windowsWindowScreenshotResultSchema,
type WindowsWindowScreenshotResult,
} from "../domain/native/windowsWindowScreenshot.js";
import { err, ok, type Result } from "../domain/result.js";
import { requireWindowsWindowCaptureAuthority } from "./WindowsNativeLoader.js";
import {
executeWindowsCapture,
retryWindowsCaptureCleanup,
} from "./WindowsWindowCaptureExecution.js";
import {
nativeWindowsCaptureSchema,
windowsCaptureFailureSchema,
} from "./WindowsWindowCaptureProtocol.js";
import {
NATIVE_WINDOWS_UI_PROVIDER_IDENTITY as IDENTITY,
nativeWindowsUiCapabilities,
} from "./NativeWindowsUiProviderMetadata.js";
const OPERATION = "capture_windows_window_screenshot" as const;
const MAX_EXECUTABLE_BYTES = 256 * 1024 * 1024;
interface ExecutableIdentity {
readonly fileIdentity: string;
readonly sha256: string;
}
type ExecutableIdentityOutcome =
| { readonly kind: "completed"; readonly value: ExecutableIdentity }
| { readonly kind: "failed"; readonly cause: unknown };
/** Preserves both identity outcome and a descriptor whose close is unconfirmed. */
class ExecutableIdentityCleanupFailure extends Error {
constructor(
readonly path: string,
readonly owner: OwnedFileHandle,
readonly outcome: ExecutableIdentityOutcome,
readonly cleanupCause: unknown,
) {
super(`Selected executable descriptor cleanup failed: ${path}`, {
cause: outcome.kind === "failed" ? outcome.cause : cleanupCause,
});
this.name = "ExecutableIdentityCleanupFailure";
}
}
const retainedExecutableIdentityHandles = new Map<OwnedFileHandle, string>();
const retainExecutableIdentityHandle = (
owner: OwnedFileHandle,
path: string,
): void => {
retainedExecutableIdentityHandles.set(owner, path);
};
/** Retry every retained owner before admitting more target-identity work. */
const retryRetainedExecutableIdentityHandles = async (): Promise<
AnalysisCleanupObservation | undefined
> => {
let failure: unknown;
for (const [owner] of retainedExecutableIdentityHandles) {
try {
await owner.close();
retainedExecutableIdentityHandles.delete(owner);
} catch (cause: unknown) {
failure ??= cause;
}
}
if (retainedExecutableIdentityHandles.size === 0) return undefined;
return {
reason: `Selected executable descriptor cleanup remains incomplete: ${failureReason(failure)}`,
resources: [...new Set(retainedExecutableIdentityHandles.values())],
};
};
const incompleteExecutableIdentityCleanupError = (
cleanup: AnalysisCleanupObservation,
): AnalysisOutputError =>
new AnalysisOutputError(
OPERATION,
"Previous selected executable descriptor cleanup remains incomplete.",
{ cleanup },
);
/** Narrow test seam: no test needs a Windows desktop or a packaged addon. */
export interface WindowsWindowCaptureHost {
capture(
pid: number,
windowHandle: string,
maximumDimension: number,
targetIdentity: {
readonly path: string;
readonly sha256: string;
readonly fileIdentity: string;
},
signal?: AbortSignal,
): unknown;
identifyExecutable(
path: string,
signal?: AbortSignal,
): Promise<ExecutableIdentity>;
}
const systemCaptureHost = (
environment: Readonly<NodeJS.ProcessEnv>,
): WindowsWindowCaptureHost => ({
capture: async (
pid,
windowHandle,
maximumDimension,
targetIdentity,
signal,
) => {
try {
requireWindowsWindowCaptureAuthority();
} catch (cause) {
throw new AnalysisCapabilityUnavailableError(
IDENTITY.id,
OPERATION,
nativeFailureReason(cause),
{ cause },
);
}
return executeWindowsCapture(
{
pid,
window_handle: windowHandle,
max_dimension: maximumDimension,
target_path: targetIdentity.path,
target_sha256: targetIdentity.sha256,
target_file_identity: targetIdentity.fileIdentity,
},
environment,
signal,
);
},
identifyExecutable: hashExecutable,
});
/** Optional auxiliary provider for one passive, target-bound Windows PNG observation. */
export class NativeWindowsUiProvider implements AnalysisProvider {
readonly #capabilities: readonly CapabilityDescriptor[];
constructor(
environment: Readonly<NodeJS.ProcessEnv> = process.env,
private readonly captureHost: WindowsWindowCaptureHost = systemCaptureHost(
environment,
),
platform: NodeJS.Platform = process.platform,
) {
this.#capabilities = nativeWindowsUiCapabilities(platform);
}
identity(): ProviderIdentity {
return IDENTITY;
}
capabilities(): readonly CapabilityDescriptor[] {
return this.#capabilities;
}
createClient(target: BinaryTarget): AnalysisClient {
return {
execute: async (operation, parameters, options) => {
if (operation === "health")
return ok(createAnalysisExecution(null, IDENTITY));
if (operation !== OPERATION)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"Operation is not implemented by the Windows selected-window adapter.",
),
);
const result = await captureWindowsWindowScreenshot(
target,
parameters,
this.captureHost,
options,
);
return result.ok
? ok(
createAnalysisExecution(result.value, IDENTITY, {
limitations: [
"One target-bound client-area frame was requested through PrintWindow; no desktop, region, title-search, or input fallback exists.",
"GPU-composited, minimized, protected, elevated, or custom-drawn windows can refuse or incompletely render PrintWindow; a successful PNG does not prove what was visible on a physical display.",
"PrintWindow can execute the selected process's paint handler, which can change UI state or cause target filesystem and network effects. The title and PNG can contain private application data and REA retains them only in returned Evidence for this selected window.",
"The selected on-disk executable matched the active target before and after capture. That comparison does not prove every byte originally mapped into the process.",
],
}),
)
: result;
},
close: async () => {
const cleanups = await Promise.all([
retryRetainedExecutableIdentityHandles(),
retryWindowsCaptureCleanup(),
]);
const incomplete = cleanups.filter((cleanup) => cleanup !== undefined);
return incomplete.length === 0
? ok(null)
: err(
new AnalysisOutputError(
OPERATION,
"Selected-window capture resource cleanup remains incomplete.",
{
cleanup: {
reason: incomplete
.map((cleanup) => cleanup.reason)
.join("; "),
resources: incomplete.flatMap(
(cleanup) => cleanup.resources,
),
},
},
),
);
},
};
}
}
/** Validate target identity around one short native capture without broad fallback. */
export const captureWindowsWindowScreenshot = async (
target: BinaryTarget,
parameters: Readonly<Record<string, unknown>>,
host: WindowsWindowCaptureHost = systemCaptureHost(process.env),
options: ExecutionOptions = {},
): Promise<Result<WindowsWindowScreenshotResult, AnalysisError>> => {
if (options.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
const input = windowsWindowScreenshotInputSchema.safeParse(parameters);
if (!input.success)
return err(
analysisInputErrorFromIssues(OPERATION, input.error.issues, parameters, {
cause: input.error,
}),
);
if (
target.kind !== "executable" ||
target.format !== "pe" ||
target.executableRole !== "application"
)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
OPERATION,
"Selected-window capture requires an active PE application target.",
),
);
let before: ExecutableIdentity;
try {
before = await host.identifyExecutable(target.path, options.signal);
} catch (cause) {
return err(identityError(target.path, cause, options.signal));
}
if (before.sha256 !== target.sha256)
return err(
new AnalysisArtifactChangedError(
OPERATION,
target.path,
"The active target bytes no longer match its opened SHA-256 identity.",
),
);
if (options.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
let reply: unknown;
try {
reply = await host.capture(
input.data.pid,
input.data.window_handle,
input.data.max_dimension,
{
path: target.path,
sha256: target.sha256,
fileIdentity: before.fileIdentity,
},
options.signal,
);
} catch (cause) {
return err(
captureFailure(
target.path,
input.data.pid,
input.data.window_handle,
cause,
),
);
}
const parsed = nativeWindowsCaptureSchema.safeParse(reply);
if (!parsed.success)
return err(
new AnalysisOutputError(
OPERATION,
"Native capture returned malformed output.",
{ cause: parsed.error },
),
);
const raw = parsed.data;
if (options.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
if (
raw.pid !== input.data.pid ||
raw.window_handle !== input.data.window_handle
)
return err(
new AnalysisOutputError(
OPERATION,
"Native capture returned a different process or window handle.",
),
);
if (
raw.width > input.data.max_dimension ||
raw.height > input.data.max_dimension ||
raw.width > raw.source_width ||
raw.height > raw.source_height ||
raw.source_width * raw.source_height > 4 * 1024 * 1024
)
return err(
new AnalysisOutputError(
OPERATION,
"Native capture violated the declared pixel bounds.",
),
);
const [targetIdentity, processIdentity] = await Promise.allSettled([
host.identifyExecutable(target.path, options.signal),
host.identifyExecutable(raw.process_image_path, options.signal),
]);
if (
targetIdentity.status === "rejected" ||
processIdentity.status === "rejected"
) {
// Both settled errors own resources independently. Project both before
// selecting a primary result, otherwise the second admission failure can
// lose its descriptor owner and never receive a cleanup retry.
const targetIdentityError =
targetIdentity.status === "rejected"
? identityError(target.path, targetIdentity.reason, options.signal)
: undefined;
const processIdentityError =
processIdentity.status === "rejected"
? identityError(
raw.process_image_path,
processIdentity.reason,
options.signal,
)
: undefined;
if (targetIdentityError !== undefined) {
if (processIdentityError !== undefined)
targetIdentityError.retainSecondaryFailure(processIdentityError, {
process_identity_error: processIdentityError.message,
});
return err(
withCleanup(targetIdentityError, processIdentityError?.cleanup),
);
}
if (processIdentityError !== undefined) return err(processIdentityError);
return err(
new AnalysisOutputError(
OPERATION,
"Selected executable identity checks failed without a reported cause.",
),
);
}
const afterTarget = targetIdentity.value;
const processImage = processIdentity.value;
if (options.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
if (
afterTarget.sha256 !== target.sha256 ||
afterTarget.fileIdentity !== before.fileIdentity
)
return err(
new AnalysisArtifactChangedError(
OPERATION,
target.path,
"The active target path or bytes changed during capture.",
),
);
if (
processImage.sha256 !== target.sha256 ||
processImage.fileIdentity !== before.fileIdentity
)
return err(
analysisInputErrorFromIssues(
OPERATION,
[
{
path: ["pid"],
code: "custom",
message: "Selected PID does not map to the active PE target bytes.",
},
],
parameters,
),
);
try {
const screenshot = windowsWindowPngSchema.parse({
media_type: "image/png",
data_base64: raw.png.toString("base64"),
sha256: createHash("sha256").update(raw.png).digest("hex"),
bytes: raw.png.byteLength,
width: raw.width,
height: raw.height,
source_width: raw.source_width,
source_height: raw.source_height,
});
return ok(
windowsWindowScreenshotResultSchema.parse({
target_sha256: target.sha256,
window: {
pid: raw.pid,
window_handle: raw.window_handle,
executable: target.path,
process_image_path: raw.process_image_path,
process_start_time_unix_ms: raw.process_start_time_unix_ms,
title: raw.title,
},
screenshot,
gaps: [
"Accessibility-tree capture and UI actions are unavailable in this screenshot-only Windows capability.",
],
}),
);
} catch (cause) {
return err(
new AnalysisOutputError(
OPERATION,
"Native capture returned an invalid PNG result.",
{ cause },
),
);
}
};
const hashExecutable = async (
path: string,
signal?: AbortSignal,
): Promise<ExecutableIdentity> => {
const priorCleanup = await retryRetainedExecutableIdentityHandles();
if (priorCleanup !== undefined)
throw incompleteExecutableIdentityCleanupError(priorCleanup);
const handle = await openRegularFile(path, { symlinks: "follow", signal });
const owner = new OwnedFileHandle(handle);
let outcome: ExecutableIdentityOutcome;
try {
const metadata = await handle.stat({ bigint: true });
if (metadata.ino === 0n)
throw new AnalysisOutputError(
OPERATION,
"The host did not report stable executable file identity.",
);
if (metadata.size > BigInt(MAX_EXECUTABLE_BYTES))
throw new AnalysisResourceConstraintError(
OPERATION,
"file-size",
"Selected executable exceeds the 256 MiB target-identity budget.",
{ maximum_bytes: MAX_EXECUTABLE_BYTES },
);
const digest = createHash("sha256");
const buffer = Buffer.allocUnsafe(64 * 1024);
let position = 0;
const size = Number(metadata.size);
while (position < size) {
if (signal?.aborted) throw new AnalysisCancelledError(OPERATION);
const { bytesRead } = await handle.read(
buffer,
0,
buffer.byteLength,
position,
);
if (signal?.aborted) throw new AnalysisCancelledError(OPERATION);
if (bytesRead === 0) break;
digest.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
if (signal?.aborted) throw new AnalysisCancelledError(OPERATION);
if (position !== size) throw new RegularFileChangedError(path);
const [opened, currentPath] = await Promise.all([
handle.stat({ bigint: true }),
stat(path, { bigint: true }),
]);
if (
!sameRegularFileState(metadata, opened) ||
!sameRegularFileState(metadata, currentPath)
)
throw new RegularFileChangedError(path);
outcome = {
kind: "completed",
value: {
fileIdentity: `${String(metadata.dev)}:${String(metadata.ino)}`,
sha256: digest.digest("hex"),
},
};
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
try {
await owner.close();
} catch (cleanupCause: unknown) {
retainExecutableIdentityHandle(owner, path);
throw new ExecutableIdentityCleanupFailure(
path,
owner,
outcome,
cleanupCause,
);
}
if (outcome.kind === "failed") throw outcome.cause;
return outcome.value;
};
/** Narrow descriptor-cleanup test seam; production reaches this through the host. */
export const hashWindowsCaptureExecutable = hashExecutable;
const identityError = (
path: string,
cause: unknown,
signal?: AbortSignal,
): AnalysisError => {
let cleanup: AnalysisCleanupObservation | undefined;
if (cause instanceof RegularFileAdmissionFailure) {
retainExecutableIdentityHandle(cause.owner, path);
cleanup = {
reason: `Selected executable descriptor close failed: ${failureReason(cause.cleanupCause)}`,
resources: [path],
};
cause = cause.cause;
} else if (cause instanceof ExecutableIdentityCleanupFailure) {
cleanup = {
reason: `Selected executable descriptor close failed: ${failureReason(cause.cleanupCause)}`,
resources: [cause.path],
};
cause =
cause.outcome.kind === "failed"
? cause.outcome.cause
: cause.cleanupCause;
}
const options = {
cause,
...(cleanup === undefined ? {} : { cleanup }),
};
if (
signal?.aborted === true ||
(cause instanceof Error && cause.name === "AbortError")
)
return new AnalysisCancelledError(OPERATION, options);
if (cause instanceof AnalysisError) return withCleanup(cause, cleanup);
if (cause instanceof RegularFileChangedError)
return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
...options,
});
const filesystem = z.object({ code: z.string() }).safeParse(cause);
if (filesystem.success) {
if (filesystem.data.code === "EACCES" || filesystem.data.code === "EPERM")
return new AnalysisAccessDeniedError(
OPERATION,
path,
filesystem.data.code,
options,
);
if (filesystem.data.code === "ENOENT" || filesystem.data.code === "ESTALE")
return new AnalysisArtifactChangedError(
OPERATION,
path,
"Selected executable is no longer available.",
options,
);
}
return new AnalysisOutputError(
OPERATION,
`Cannot establish selected executable identity: ${cause instanceof Error ? cause.message : String(cause)}`,
options,
);
};
const failureReason = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
/** Keep the primary typed error while surfacing unconfirmed descriptor cleanup. */
const withCleanup = (
error: AnalysisError,
cleanup: AnalysisCleanupObservation | undefined,
): AnalysisError => {
if (cleanup === undefined) return error;
const merged =
error.cleanup === undefined
? cleanup
: {
reason: `${error.cleanup.reason}; ${cleanup.reason}`,
resources: [
...new Set([...error.cleanup.resources, ...cleanup.resources]),
],
};
Object.defineProperties(error, {
cleanup: { configurable: true, value: merged },
cleanupIncomplete: { configurable: true, value: true },
cleanupResources: { configurable: true, value: merged.resources },
});
return error;
};
const nativeFailureReason = (cause: unknown): string =>
cause instanceof Error && cause.message.length > 0
? cause.message
: "Selected window capture is unavailable on this host.";
/** Only the addon-loading owner produces capability errors; native failures stay request-scoped. */
const captureFailure = (
path: string,
pid: number,
windowHandle: string,
cause: unknown,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
const native = windowsCaptureFailureSchema.safeParse(cause);
if (!native.success)
return new AnalysisOutputError(OPERATION, nativeFailureReason(cause), {
cause,
});
if (native.data.failureKind === "artifact_changed")
return new AnalysisArtifactChangedError(
OPERATION,
path,
nativeFailureReason(cause),
{ cause },
);
if (native.data.failureKind === "source_pixel_limit")
return new AnalysisResourceConstraintError(
OPERATION,
"capacity",
nativeFailureReason(cause),
{ maximum_source_pixels: 4 * 1024 * 1024 },
{
cause,
remediationAction:
"Resize the selected window below maximum_source_pixels, then retry. Lowering max_dimension does not reduce the source area.",
},
);
if (
native.data.failureKind === "window_owner_mismatch" ||
native.data.failureKind === "window_state" ||
native.data.failureKind === "process_identity_mismatch"
) {
const field =
native.data.failureKind === "window_owner_mismatch"
? "window_handle"
: native.data.failureKind === "window_state"
? "window_handle"
: "pid";
return analysisInputErrorFromIssues(
OPERATION,
[{ path: [field], code: "custom", message: nativeFailureReason(cause) }],
{ pid, window_handle: windowHandle },
{ cause },
);
}
const code = native.data.win32Code;
if (code === 995) return new AnalysisCancelledError(OPERATION, { cause });
if (
code === 5 &&
native.data.requestedPath !== undefined &&
native.data.requestedPath.length > 0
)
return new AnalysisAccessDeniedError(
OPERATION,
native.data.requestedPath,
"EACCES",
{ cause },
);
if (code === 5)
return new AnalysisAccessDeniedError(OPERATION, "", "EACCES", {
cause,
processWindow: { pid, windowHandle },
});
if (code === 170)
return new AnalysisResourceConstraintError(
OPERATION,
"capacity",
nativeFailureReason(cause),
{ maximum_concurrent_captures: 1 },
{ cause },
);
if (code === 1460 || code === 258)
return new AnalysisTimeoutError(OPERATION, 5000, { cause });
// STG_E_MEDIUMFULL is the fixed retained PNG stream's capacity refusal.
if (code === 0x80030070)
return new AnalysisResourceConstraintError(
OPERATION,
"capacity",
nativeFailureReason(cause),
{ maximum_png_bytes: MAX_WINDOWS_WINDOW_PNG_BYTES },
{ cause, remediationAction: "Retry with a smaller max_dimension." },
);
// Native allocation failures and HRESULT_FROM_WIN32 allocation failures.
if ([8, 14, 1455, 0x8007000e, 0x80070008].includes(code))
return new AnalysisResourceConstraintError(
OPERATION,
"memory",
nativeFailureReason(cause),
null,
{ cause },
);
if (code === 1400 || code === 6 || code === 87)
return analysisInputErrorFromIssues(
OPERATION,
[
{
path: ["window_handle"],
code: "custom",
message: nativeFailureReason(cause),
},
],
{ pid, window_handle: windowHandle },
{ cause },
);
return new AnalysisOutputError(OPERATION, nativeFailureReason(cause), {
cause,
});
};
@@ -0,0 +1,44 @@
import { expect, it } from "vitest";
import { nativeWindowsUiCapabilities } from "./NativeWindowsUiProviderMetadata.js";
import type { WindowsWindowCaptureLoadResult } from "./WindowsNativeLoader.js";
const loaded = (): WindowsWindowCaptureLoadResult => ({
available: true,
authority: {
call: () => undefined,
},
});
it("requires affirmative inspected capture support on the shipped Windows architecture", () => {
expect(nativeWindowsUiCapabilities("win32", "arm64")[0]).toMatchObject({
available: false,
availabilityCode: "unsupported_host",
});
expect(nativeWindowsUiCapabilities("win32", "x64", loaded)[0]).toMatchObject({
available: true,
cachePolicy: "live",
effects: { launchesProcess: true },
});
expect(
nativeWindowsUiCapabilities("win32", "x64", () => ({
available: false,
reason: "artifact unavailable",
}))[0],
).toMatchObject({ available: false, reason: "artifact unavailable" });
});
it("keeps retryable admission unavailable until a fresh successful snapshot", () => {
let readiness: WindowsWindowCaptureLoadResult = {
available: false,
reason: "Private runtime admission is still unresolved",
retryable: true,
};
const first = nativeWindowsUiCapabilities("win32", "x64", () => readiness);
expect(first[0]).toMatchObject({
available: false,
reason: readiness.reason,
});
readiness = loaded();
expect(
nativeWindowsUiCapabilities("win32", "x64", () => readiness)[0],
).toMatchObject({ available: true });
expect(first[0]).toMatchObject({ available: false });
});
@@ -0,0 +1,66 @@
import {
systemWindowsWindowCaptureAuthority,
type WindowsWindowCaptureLoadResult,
} from "./WindowsNativeLoader.js";
import type {
CapabilityDescriptor,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
/** Public provenance for the optional Windows target-window capture adapter. */
export const NATIVE_WINDOWS_UI_PROVIDER_IDENTITY: ProviderIdentity =
Object.freeze({
id: "native-windows-ui",
name: "Windows selected-window capture",
version: null,
});
/** Declare the narrow Windows-only facet from its inspected native artifact. */
export const nativeWindowsUiCapabilities = (
platform: NodeJS.Platform = process.platform,
architecture: string = process.arch,
load: () => WindowsWindowCaptureLoadResult = systemWindowsWindowCaptureAuthority,
): readonly CapabilityDescriptor[] => {
const loaded =
platform === "win32" && architecture === "x64" ? load() : undefined;
const captureReady = loaded?.available === true;
const availability =
platform === "win32" && architecture === "x64"
? captureReady
? ({ available: true, reason: null } as const)
: ({
available: false,
availabilityCode: "runtime_missing",
reason:
loaded?.available === false
? loaded.reason
: "The loaded Windows addon does not support selected-window capture; rebuild or install the matching native artifact.",
} as const)
: ({
available: false,
availabilityCode: "unsupported_host",
reason:
"Selected-window capture requires a Windows x64 host and packaged native addon.",
} as const);
return Object.freeze([
Object.freeze({
provider: NATIVE_WINDOWS_UI_PROVIDER_IDENTITY,
operation: "capture_windows_window_screenshot" as const,
...availability,
cachePolicy: "live" as const,
effects: Object.freeze({
mutatesArtifact: false,
launchesProcess: true,
mayShowUi: true,
mayAccessNetwork: true,
mayWriteFilesystem: true,
changesPermissions: false,
requiresRoot: false,
}),
limitations: Object.freeze([
"Captures only one explicitly selected existing HWND of the active PE application through an owned helper; the target is not launched.",
"Does not enumerate windows, search titles, capture the desktop or region, send input, or expose accessibility automation. PrintWindow can nevertheless execute the selected target's paint handler.",
]),
}),
]);
};
+120 -11
View File
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
import {
loadWindowsNativeAuthority,
loadWindowsWindowCaptureAuthority,
loadWindowsWindowCaptureWorkerAuthority,
type WindowsNativeLoadHost,
} from "./WindowsNativeLoader.js";
@@ -12,6 +14,7 @@ const fixture = (
protocolStdin?: boolean;
jobMemoryLimit?: unknown;
privateBridgeDescriptorAccess?: unknown;
windowCapture?: unknown;
} = {},
) => {
const bytes = Buffer.alloc(128);
@@ -49,24 +52,130 @@ const fixture = (
loadArtifact: () => {
loads++;
return {
call: () => ({
abiVersion: 1,
nodeApiVersion: 8,
architecture: "x64",
filesystem: identity,
privateDacl: true,
privateBridgeDescriptorAccess: true,
atomicJobAssignment: true,
killOnOwnerClose: true,
...capabilities,
}),
call: (operation: string) =>
operation === "inspect_window_capture"
? {
abiVersion: 1,
nodeApiVersion: 8,
architecture: "x64",
...(capabilities.windowCapture === undefined
? {}
: { windowCapture: capabilities.windowCapture }),
}
: {
abiVersion: 1,
nodeApiVersion: 8,
architecture: "x64",
filesystem: identity,
privateDacl: true,
privateBridgeDescriptorAccess: true,
atomicJobAssignment: true,
killOnOwnerClose: true,
...capabilities,
},
};
},
};
return { host, manifest, bytes, loads: () => loads };
};
describe("selected-window native admission", () => {
it("admits the worker's capture facet without repeating filesystem or process admission", () => {
const value = fixture({ windowCapture: true });
const host = {
...value.host,
loadArtifact: () => ({
call: (operation: string) => {
expect(operation).toBe("inspect_window_capture");
return {
abiVersion: 1,
nodeApiVersion: 8,
architecture: "x64",
windowCapture: true,
};
},
}),
};
expect(loadWindowsWindowCaptureWorkerAuthority(host)).toMatchObject({
available: true,
});
expect(
loadWindowsWindowCaptureWorkerAuthority(fixture().host),
).toMatchObject({
available: false,
});
expect(loadWindowsWindowCaptureAuthority(value.host)).toMatchObject({
available: false,
});
});
it("refuses capture when owned helper controls fail admission", () => {
const value = fixture({ windowCapture: true });
const result = loadWindowsWindowCaptureAuthority({
...value.host,
loadArtifact: () => ({
call: (operation: string) => {
if (operation === "inspect")
throw new Error("private runtime self-test failed");
return {
abiVersion: 1,
nodeApiVersion: 8,
architecture: "x64",
windowCapture: true,
};
},
}),
});
expect(result).toMatchObject({ available: false, retryable: true });
});
it("refuses an addon that does not expose selected-window inspection", () => {
const value = fixture({ jobMemoryLimit: true, protocolStdin: true });
expect(loadWindowsWindowCaptureAuthority(value.host)).toMatchObject({
available: false,
failureStage: "inspection",
});
});
it("requires capture support and the helper's enforced memory control", () => {
expect(
loadWindowsWindowCaptureAuthority(
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"),
});
});
});
describe("packaged Windows native compatibility", () => {
it("retains capture support as an affirmative artifact observation", () => {
expect(
loadWindowsNativeAuthority(fixture({ windowCapture: true }).host),
).toMatchObject({
available: true,
authority: { inspection: { windowCapture: true } },
});
const earlier = loadWindowsNativeAuthority(fixture().host);
if (!earlier.available) throw new Error(earlier.reason);
expect(earlier.authority.inspection.windowCapture).toBeUndefined();
expect(
loadWindowsNativeAuthority(fixture({ windowCapture: false }).host)
.available,
).toBe(false);
});
it.each([true, false])(
"preserves protocol stdin capability %s alongside existing job controls",
(protocolStdin) => {
+215 -47
View File
@@ -47,6 +47,16 @@ const inspectionSchema = z.strictObject({
protocolStdin: z.boolean().optional(),
/** Absent from artifacts built before owned Job Object commit limits. */
jobMemoryLimit: z.literal(true).optional(),
/** Older compatible artifacts still support other controls, but not capture. */
windowCapture: z.literal(true).optional(),
});
/** Capture additionally requires the Job Object controls used by its helper. */
const windowCaptureInspectionSchema = z.strictObject({
abiVersion: z.literal(1),
nodeApiVersion: z.literal(8),
architecture: z.literal("x64"),
windowCapture: z.literal(true),
});
type NativeCall = (
@@ -65,16 +75,45 @@ export interface WindowsNativeAuthority {
readonly inspection: z.infer<typeof inspectionSchema>;
}
/** Native authority admitted only for target-bound selected-window capture. */
export interface WindowsWindowCaptureAuthority {
readonly call: NativeCall;
}
type WindowsNativeFailureStage =
| "host"
| "artifact"
| "self-test"
| "inspection";
type WindowsNativeLoadFailure = {
readonly available: false;
readonly reason: string;
readonly failureStage: WindowsNativeFailureStage;
/** Only a failed native invocation can recover on a later query. */
readonly retryable?: true;
};
/** Availability of the bundled native implementation, including load failures. */
export type WindowsNativeLoadResult =
| { readonly available: true; readonly authority: WindowsNativeAuthority }
| WindowsNativeLoadFailure;
/** Availability of the independent selected-window capture facet. */
type WindowsWindowCaptureLoadFailure = {
readonly available: false;
readonly reason: string;
/** Kept optional for custom narrow-capability loaders. */
readonly failureStage?: WindowsNativeFailureStage;
readonly retryable?: true;
};
export type WindowsWindowCaptureLoadResult =
| {
readonly available: false;
readonly reason: string;
readonly failureStage: "host" | "artifact" | "self-test" | "inspection";
/** Only a failed native invocation can recover on a later query. */
readonly retryable?: true;
};
readonly available: true;
readonly authority: WindowsWindowCaptureAuthority;
}
| WindowsWindowCaptureLoadFailure;
/** Trusted package reads and module loading, replaceable in loader tests. */
export interface WindowsNativeLoadHost {
@@ -105,10 +144,14 @@ const readPackagedWindowsNativeFile = <T>(file: string, read: () => T): T => {
}
};
/** Validate compatibility and artifact identity before invoking the native addon. */
export const loadWindowsNativeAuthority = (
type WindowsNativeBindingLoadResult =
| { readonly available: true; readonly call: NativeCall }
| WindowsNativeLoadFailure;
/** Admit the packaged artifact before selecting a narrow native capability. */
const loadWindowsNativeBinding = (
host: WindowsNativeLoadHost,
): WindowsNativeLoadResult => {
): WindowsNativeBindingLoadResult => {
if (host.platform !== "win32" || host.architecture !== "x64")
return {
available: false,
@@ -139,7 +182,26 @@ export const loadWindowsNativeAuthority = (
"Windows native artifact SHA-256 does not match package metadata",
);
assertWindowsX64Artifact(artifact);
const binding = bindingSchema.parse(host.loadArtifact());
return {
available: true,
call: bindingSchema.parse(host.loadArtifact()).call,
};
} catch (cause: unknown) {
return {
available: false,
failureStage: "artifact",
reason: `REA Windows native controls are unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
};
/** Validate full native readiness for controls that require private runtime support. */
export const loadWindowsNativeAuthority = (
host: WindowsNativeLoadHost,
): WindowsNativeLoadResult => {
const binding = loadWindowsNativeBinding(host);
if (!binding.available) return binding;
try {
let observed: unknown;
try {
observed = binding.call("inspect", []);
@@ -158,13 +220,84 @@ export const loadWindowsNativeAuthority = (
failureStage: "inspection",
reason: `REA Windows native inspection is invalid: ${parsed.error.message}`,
};
const inspection = parsed.data;
return { available: true, authority: { call: binding.call, inspection } };
return {
available: true,
authority: { call: binding.call, inspection: parsed.data },
};
} catch (cause: unknown) {
return {
available: false,
failureStage: "artifact",
reason: `REA Windows native controls are unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
failureStage: "inspection",
reason: `REA Windows native inspection is invalid: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
};
/** Capture requires both the selected-window API and owned helper controls. */
export const loadWindowsWindowCaptureAuthority = (
host: WindowsNativeLoadHost,
): WindowsWindowCaptureLoadResult => {
const controls = loadWindowsNativeAuthority(host);
if (!controls.available) return controls;
const binding = controls.authority;
if (binding.inspection.jobMemoryLimit !== true)
return {
available: false,
failureStage: "inspection",
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.",
};
return inspectWindowsWindowCapture(binding);
};
/** A supervised worker only uses the in-memory capture facet, not runtime files. */
export const loadWindowsWindowCaptureWorkerAuthority = (
host: WindowsNativeLoadHost,
): WindowsWindowCaptureLoadResult => {
const binding = loadWindowsNativeBinding(host);
return binding.available ? inspectWindowsWindowCapture(binding) : binding;
};
const inspectWindowsWindowCapture = (
binding: WindowsWindowCaptureAuthority,
): WindowsWindowCaptureLoadResult => {
try {
let observed: unknown;
try {
observed = binding.call("inspect_window_capture", []);
} catch (cause: unknown) {
const unsupported = z
.object({ win32Code: z.literal(87) })
.safeParse(cause).success;
return {
available: false,
failureStage: "inspection",
...(unsupported ? {} : { retryable: true as const }),
reason: unsupported
? "The loaded Windows addon does not support selected-window capture; rebuild or install the matching native artifact."
: `REA Windows selected-window capture self-test failed: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
const parsed = windowCaptureInspectionSchema.safeParse(observed);
if (!parsed.success)
return {
available: false,
failureStage: "inspection",
reason: `REA Windows selected-window capture inspection is invalid: ${parsed.error.message}`,
};
return { available: true, authority: { call: binding.call } };
} catch (cause: unknown) {
return {
available: false,
failureStage: "inspection",
reason: `REA Windows selected-window capture is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
};
}
};
@@ -182,50 +315,85 @@ const assertWindowsX64Artifact = (bytes: Buffer): void => {
throw new Error("Windows native artifact is not a Windows x64 PE32+ image");
};
let cached: WindowsNativeLoadResult | undefined;
/** Cache stable admission; let a later query retry failed owned inspection. */
export const systemWindowsNativeAuthority = (): WindowsNativeLoadResult => {
if (cached?.available === false && cached.retryable === true)
cached = undefined;
cached ??= loadWindowsNativeAuthority({
platform: process.platform,
architecture: process.arch,
nodeApiVersion: process.versions.napi,
packageVersion: PACKAGE_METADATA.version,
readManifest: () => {
const encoded = readFileSync(
new URL("../../native/windows/build/manifest.json", import.meta.url),
"utf8",
);
const value: unknown = JSON.parse(encoded);
return value;
},
readArtifact: () =>
readFileSync(
const systemWindowsNativeLoadHost = (): WindowsNativeLoadHost => ({
platform: process.platform,
architecture: process.arch,
nodeApiVersion: process.versions.napi,
packageVersion: PACKAGE_METADATA.version,
readManifest: () => {
const encoded = readFileSync(
new URL("../../native/windows/build/manifest.json", import.meta.url),
"utf8",
);
const value: unknown = JSON.parse(encoded);
return value;
},
readArtifact: () =>
readFileSync(
new URL(
"../../native/windows/build/rea-windows-x64.node",
import.meta.url,
),
),
loadArtifact: () => {
const value: unknown = createRequire(import.meta.url)(
fileURLToPath(
new URL(
"../../native/windows/build/rea-windows-x64.node",
import.meta.url,
),
),
loadArtifact: () => {
const value: unknown = createRequire(import.meta.url)(
fileURLToPath(
new URL(
"../../native/windows/build/rea-windows-x64.node",
import.meta.url,
),
),
);
return value;
},
});
);
return value;
},
});
let cached: WindowsNativeLoadResult | undefined;
let cachedWindowCapture: WindowsWindowCaptureLoadResult | undefined;
/** Cache stable admission; let a later query retry failed owned inspection. */
export const systemWindowsNativeAuthority = (): WindowsNativeLoadResult => {
if (cached?.available === false && cached.retryable === true)
cached = undefined;
cached ??= loadWindowsNativeAuthority(systemWindowsNativeLoadHost());
return cached;
};
/** Cache selected-window capture separately from unrelated native controls. */
export const systemWindowsWindowCaptureAuthority =
(): WindowsWindowCaptureLoadResult => {
if (
cachedWindowCapture?.available === false &&
cachedWindowCapture.retryable === true
)
cachedWindowCapture = undefined;
cachedWindowCapture ??= loadWindowsWindowCaptureAuthority(
systemWindowsNativeLoadHost(),
);
return cachedWindowCapture;
};
/** Require the verified backend when an operation depends on native authority. */
export const requireWindowsNativeAuthority = (): WindowsNativeAuthority => {
const loaded = systemWindowsNativeAuthority();
if (!loaded.available) throw new Error(loaded.reason);
return loaded.authority;
};
/** Require the independently verified native screenshot capability. */
export const requireWindowsWindowCaptureAuthority =
(): WindowsWindowCaptureAuthority => {
const loaded = systemWindowsWindowCaptureAuthority();
if (!loaded.available) throw new Error(loaded.reason);
return loaded.authority;
};
/** Verify the worker's artifact and capture facet without a second filesystem probe. */
export const requireWindowsWindowCaptureWorkerAuthority =
(): WindowsWindowCaptureAuthority => {
const loaded = loadWindowsWindowCaptureWorkerAuthority(
systemWindowsNativeLoadHost(),
);
if (!loaded.available) throw new Error(loaded.reason);
return loaded.authority;
};
@@ -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 });
}
});
@@ -0,0 +1,161 @@
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
import { decodeCanonicalBase64 } from "../domain/webScreenshot.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
import {
WINDOWS_CAPTURE_REPLY_BYTES,
WINDOWS_CAPTURE_TIMEOUT_MS,
windowsCaptureReplySchema,
windowsCaptureRequestSchema,
} from "./WindowsWindowCaptureProtocol.js";
import type { z } from "zod";
const operation = "capture_windows_window_screenshot";
let active = false;
const retained = new Map<ProviderProcessSupervisor, readonly string[]>();
/** Retry exact retained owners, never a PID or image-name lookup. */
export const retryWindowsCaptureCleanup = async (): Promise<
AnalysisCleanupObservation | undefined
> => {
let reason: string | undefined;
for (const [owner] of retained) {
try {
const result = await owner.stop();
if (result.status !== "incomplete") retained.delete(owner);
else reason ??= result.reason;
} catch (cause: unknown) {
reason ??= cause instanceof Error ? cause.message : String(cause);
}
}
if (retained.size === 0) return undefined;
return {
reason: reason ?? "Selected-window helper cleanup remains incomplete.",
resources: [...retained.values()].flat(),
};
};
/** 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)
throw new AnalysisResourceConstraintError(
operation,
"capacity",
"Another selected-window capture is still active.",
{ maximum_concurrent_captures: 1 },
);
active = true;
try {
const cleanup = await retryWindowsCaptureCleanup();
if (cleanup !== undefined)
throw new AnalysisOutputError(operation, cleanup.reason, { cleanup });
// 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(selectedEnvironment).filter(
([name]) => name.toUpperCase() !== "NODE_OPTIONS",
),
);
const snapshot = await runOwnedCommand(
{
command: process.execPath,
arguments: [
"--max-old-space-size=64",
fileURLToPath(
new URL("../windows-capture-runtime/worker.js", import.meta.url),
),
],
runId: `rea-window-capture-${randomUUID()}`,
expectedCommand: null,
hostEnvironment: environment,
memoryLimitBytes: 256 * 1024 * 1024,
},
{
timeoutMs: WINDOWS_CAPTURE_TIMEOUT_MS,
diagnosticBytes: WINDOWS_CAPTURE_REPLY_BYTES,
},
{
signal,
input: Buffer.from(
JSON.stringify(windowsCaptureRequestSchema.parse(request)),
"utf8",
),
},
);
const value: unknown = JSON.parse(snapshot.stdout.text);
const reply = windowsCaptureReplySchema.parse(value);
if (!reply.ok) {
if (reply.failure === null)
throw new AnalysisOutputError(operation, reply.message);
throw Object.assign(new Error(reply.message), reply.failure);
}
const { png_base64, ...capture } = reply.capture;
const png = decodeCanonicalBase64(png_base64);
if (png === undefined)
throw new AnalysisOutputError(
operation,
"Capture helper returned invalid PNG base64.",
);
return { ...capture, png };
} catch (cause: unknown) {
if (!(cause instanceof OwnedCommandFailure)) throw cause;
let cleanup: AnalysisCleanupObservation | undefined;
if (cause.cleanupOwner !== undefined) {
retained.set(cause.cleanupOwner, cause.resources);
cleanup = {
reason:
cause.cleanupFailure ??
"Selected-window helper cleanup remains incomplete.",
resources: cause.resources,
};
}
const options = { cause, ...(cleanup === undefined ? {} : { cleanup }) };
if (cause.reason === "cancelled")
throw new AnalysisCancelledError(operation, options);
if (cause.reason === "timeout")
throw new AnalysisTimeoutError(
operation,
WINDOWS_CAPTURE_TIMEOUT_MS,
options,
);
if (cause.snapshot?.memoryLimitReached === true)
throw new AnalysisResourceConstraintError(
operation,
"memory",
cause.message,
{ maximum_helper_commit_bytes: 256 * 1024 * 1024 },
options,
);
if (cause.reason === "output-limit")
throw new AnalysisResourceConstraintError(
operation,
"transport",
cause.message,
{ maximum_helper_reply_bytes: WINDOWS_CAPTURE_REPLY_BYTES },
options,
);
throw new AnalysisOutputError(
operation,
"Selected-window capture helper failed.",
options,
);
} finally {
active = false;
}
};
@@ -0,0 +1,88 @@
import { z } from "zod";
import {
MAX_WINDOWS_WINDOW_PNG_BYTES,
windowsWindowScreenshotInputSchema,
} from "../domain/native/windowsWindowScreenshot.js";
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 =
4 * Math.ceil(MAX_WINDOWS_WINDOW_PNG_BYTES / 3) +
2 * MAX_WINDOWS_TEXT_UNITS * 6 +
4096;
/** Private process protocol; identity is validated again by the native owner. */
export const windowsCaptureRequestSchema = z.strictObject({
...windowsWindowScreenshotInputSchema.shape,
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).max(32),
});
const identityFields = {
pid: windowsWindowScreenshotInputSchema.shape.pid,
window_handle: windowsWindowScreenshotInputSchema.shape.window_handle,
process_image_path: z.string().min(1).max(MAX_WINDOWS_TEXT_UNITS),
process_start_time_unix_ms: z.number().int().nonnegative(),
title: z.string().max(MAX_WINDOWS_TEXT_UNITS),
source_width: z
.number()
.int()
.positive()
.max(4 * 1024 * 1024),
source_height: z
.number()
.int()
.positive()
.max(4 * 1024 * 1024),
width: z.number().int().positive().max(4096),
height: z.number().int().positive().max(4096),
};
export const nativeWindowsCaptureSchema = z.strictObject({
...identityFields,
png: z.custom<Buffer>(
(value) =>
Buffer.isBuffer(value) &&
value.byteLength > 0 &&
value.byteLength <= MAX_WINDOWS_WINDOW_PNG_BYTES,
"Expected a native PNG buffer within the retained byte limit",
),
});
/** Typed producer reasons keep request refusals out of capability state. */
export const windowsCaptureFailureSchema = z.object({
win32Code: z.number().int().nonnegative(),
requestedPath: z.string().exactOptional(),
failureKind: z
.enum([
"artifact_changed",
"window_owner_mismatch",
"window_state",
"process_identity_mismatch",
"source_pixel_limit",
])
.exactOptional(),
});
export const windowsCaptureReplySchema = z.discriminatedUnion("ok", [
z.strictObject({
ok: z.literal(true),
capture: z.strictObject({
...identityFields,
png_base64: z
.string()
.min(4)
.max(4 * Math.ceil(MAX_WINDOWS_WINDOW_PNG_BYTES / 3)),
}),
}),
z.strictObject({
ok: z.literal(false),
message: z.string(),
failure: windowsCaptureFailureSchema.nullable(),
}),
]);
+54
View File
@@ -0,0 +1,54 @@
import { requireWindowsWindowCaptureWorkerAuthority } from "./WindowsNativeLoader.js";
import {
nativeWindowsCaptureSchema,
WINDOWS_CAPTURE_REQUEST_BYTES,
windowsCaptureFailureSchema,
windowsCaptureRequestSchema,
} from "./WindowsWindowCaptureProtocol.js";
// PrintWindow is synchronous and has no timeout API. Only this owned helper
// 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 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 = requireWindowsWindowCaptureWorkerAuthority();
const { png, ...capture } = nativeWindowsCaptureSchema.parse(
authority.call("capture_window_png", [
input.pid,
input.window_handle,
input.max_dimension,
input.target_path,
input.target_sha256,
input.target_file_identity,
]),
);
process.stdout.write(
JSON.stringify({
ok: true,
capture: { ...capture, png_base64: png.toString("base64") },
}),
);
} catch (cause: unknown) {
const failure = windowsCaptureFailureSchema.safeParse(cause);
process.stdout.write(
JSON.stringify({
ok: false,
message: cause instanceof Error ? cause.message : String(cause),
failure: failure.success ? failure.data : null,
}),
);
}
@@ -0,0 +1,32 @@
import { describe, expect } from "vitest";
import { cliTest } from "../../support/cli/cliFixture.js";
describe("Windows native-window screenshot CLI input", () => {
cliTest(
"rejects a non-canonical HWND before opening a provider",
async ({ cli }) => {
const result = await cli.run({
arguments: [
"capture-windows-window-screenshot",
"C:\\fixture.exe",
"--pid",
"1",
"--window-handle",
"0x0001",
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).not.toBe(0);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "capture_windows_window_screenshot",
issues: [{ path: ["window_handle"], reason: "invalid_format" }],
},
});
},
);
});
@@ -0,0 +1,186 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pe } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { z } from "zod";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "../../../src/application/AnalysisProvider.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { ok } from "../../../src/domain/result.js";
import { createServer } from "../../../src/server/createServer.js";
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAF/gJ+Qv1aRwAAAABJRU5ErkJggg==",
"base64",
);
const provider = {
id: "native-windows-ui",
name: "Windows selected-window capture",
version: null,
} as const;
const resources: Array<{ close(): Promise<void> }> = [];
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
});
const result = {
target_sha256: "a".repeat(64),
window: {
pid: 42,
window_handle: "0x1234",
executable: "C:\\Apps\\fixture.exe",
process_image_path: "C:\\Apps\\fixture.exe",
process_start_time_unix_ms: 1_700_000_000_000,
title: "Fixture",
},
screenshot: {
media_type: "image/png",
data_base64: PNG.toString("base64"),
sha256: createHash("sha256").update(PNG).digest("hex"),
bytes: PNG.byteLength,
width: 1,
height: 1,
source_width: 1,
source_height: 1,
},
gaps: ["Accessibility-tree capture is unavailable."],
};
it("delivers target-bound Windows screenshot Evidence through MCP", async () => {
const calls: Array<{
readonly operation: string;
readonly parameters: Readonly<Record<string, unknown>>;
}> = [];
const execute: AnalysisOperationPort["execute"] = (operation, parameters) => {
calls.push({ operation, parameters });
return Promise.resolve(ok(createAnalysisExecution(result, provider)));
};
const server = createServer({ kind: "fixed", analysis: { execute } });
const client = new Client({ name: "windows-window-mcp-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(client, server);
await server.connect(serverTransport);
await client.connect(clientTransport);
const observed = await client.callTool({
name: "capture_windows_window_screenshot",
arguments: { pid: 42, window_handle: "0x1234", detail: "complete" },
});
expect(observed.isError).not.toBe(true);
expect(calls).toEqual([
{
operation: "capture_windows_window_screenshot",
parameters: {
pid: 42,
window_handle: "0x1234",
max_dimension: 2048,
},
},
]);
expect(
toolContract("capture_windows_window_screenshot").outputSchema.parse(
observed.structuredContent,
),
).toMatchObject({
normalized_result: { window: { pid: 42, window_handle: "0x1234" } },
});
});
it("defaults to a retained summary and reads the same image without recapturing", async () => {
const directory = await createTestTempDirectory("rea-window-summary-mcp-");
const path = join(directory, "fixture.exe");
await writeFile(path, pe(0x8664));
let captures = 0;
const session = createTestBinarySession((target) => ({
execute: async (operation) => {
if (operation === "health")
return ok(createAnalysisExecution(null, provider));
expect(operation).toBe("capture_windows_window_screenshot");
captures++;
return ok(
createAnalysisExecution(
{
...result,
target_sha256: target.sha256,
window: {
...result.window,
executable: target.path,
process_image_path: target.path,
},
},
provider,
),
);
},
close: async () => ok(null),
}));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "windows-window-summary", version: "1" });
resources.push(client, server, {
close: async () => {
await session.close();
},
});
const [a, b] = InMemoryTransport.createLinkedPair();
await server.connect(b);
await client.connect(a);
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError).not.toBe(true);
const summary = await client.callTool({
name: "capture_windows_window_screenshot",
arguments: { pid: 42, window_handle: "0x1234" },
});
expect(summary.isError, JSON.stringify(summary.content)).not.toBe(true);
const parsed = toolContract(
"capture_windows_window_screenshot",
).outputSchema.parse(summary.structuredContent);
const frame = z
.object({
normalized_result: z.object({
kind: z.literal("summary"),
parent_evidence_id: z.string(),
summary: z.object({
screenshot: z.object({ sha256: z.string(), width: z.number() }),
}),
}),
})
.parse(parsed).normalized_result;
expect(frame.summary.screenshot).toMatchObject({
width: 1,
sha256: result.screenshot.sha256,
});
expect(JSON.stringify(summary)).not.toContain(PNG.toString("base64"));
expect(Buffer.byteLength(JSON.stringify(summary))).toBeLessThan(8000);
const image = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: frame.parent_evidence_id,
},
view: { kind: "windows-window", facet: "screenshot" },
},
});
expect(image.isError, JSON.stringify(image.content)).not.toBe(true);
expect(
toolContract("inspect_analysis_view").outputSchema.parse(
image.structuredContent,
),
).toMatchObject({
normalized_result: { kind: "windows-window", item: result.screenshot },
});
expect(captures).toBe(1);
});
@@ -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();
+74 -39
View File
@@ -1,4 +1,4 @@
import { readFile, readdir } from "node:fs/promises";
import { readFile, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
@@ -7,30 +7,31 @@ import { describe, expect } from "vitest";
import { bundleRuntime } from "../../../scripts/bundle-runtime.mjs";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
describe("CLI and MCP runtime bundles", () => {
workspaceCliTest(
"preserves module locations, lazy side effects, package resolution and worker entrypoints",
async ({ processes, workspace }) => {
await workspace.mkdir("dist/provider");
await workspace.mkdir("dist/composition");
await workspace.mkdir("dist/cli");
await workspace.mkdir("bridge");
await workspace.mkdir("node_modules/external-probe");
const files = {
"package.json": JSON.stringify({
type: "module",
imports: {
"#javascript-analysis-worker":
"./dist/composition/javascriptAnalysisWorkerProcess.js",
},
}),
"bridge/resource.txt": "bridge resource\n",
"node_modules/external-probe/package.json": JSON.stringify({
name: "external-probe",
main: "index.cjs",
}),
"node_modules/external-probe/index.cjs": 'module.exports = "external";',
"dist/provider/probe λ.js": `
const runtimeFixtureFiles = {
"node_modules/zod/package.json": JSON.stringify({
type: "module",
exports: "./index.js",
}),
"node_modules/zod/index.js":
"export const z = {string: () => ({parse: value => value})};",
"dist/windows/WindowsWindowCaptureWorker.js": `
import { z } from 'zod';
import { generatedPackageMetadata } from '../generatedPackageMetadata.js';
process.stdout.write(z.string().parse('capture-worker:' + generatedPackageMetadata.version));`,
"package.json": JSON.stringify({
type: "module",
imports: {
"#javascript-analysis-worker":
"./dist/composition/javascriptAnalysisWorkerProcess.js",
},
}),
"bridge/resource.txt": "bridge resource\n",
"node_modules/external-probe/package.json": JSON.stringify({
name: "external-probe",
main: "index.cjs",
}),
"node_modules/external-probe/index.cjs": 'module.exports = "external";',
"dist/provider/probe λ.js": `
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
globalThis.effects ??= [];
@@ -41,19 +42,19 @@ export const facts = {
external: createRequire(import.meta.url)('external-probe'),
worker: import.meta.resolve('#javascript-analysis-worker')
};`,
"dist/lazy.js": `
"dist/lazy.js": `
globalThis.effects.push('lazy');
export const lazyUrl = import.meta.url;`,
"dist/composition/javascriptAnalysisWorkerProcess.js":
'process.stdout.write("worker-entry");',
"dist/generatedPackageMetadata.js":
'export const generatedPackageMetadata = { version: "1.0.0" };',
"dist/cli.js": 'export { facts, run } from "./main.js";',
"dist/cliOutput.js": 'export { facts } from "./provider/probe λ.js";',
"dist/cli/cliResultOutput.js":
'export { facts } from "../provider/probe λ.js";',
"dist/mcpDoctor.js": 'export { run } from "./main.js";',
"dist/main.js": `
"dist/composition/javascriptAnalysisWorkerProcess.js":
'process.stdout.write("worker-entry");',
"dist/generatedPackageMetadata.js":
'export const generatedPackageMetadata = { version: "1.0.0" };',
"dist/cli.js": 'export { facts, run } from "./main.js";',
"dist/cliOutput.js": 'export { facts } from "./provider/probe λ.js";',
"dist/cli/cliResultOutput.js":
'export { facts } from "../provider/probe λ.js";',
"dist/mcpDoctor.js": 'export { run } from "./main.js";',
"dist/main.js": `
import { facts } from './provider/probe λ.js';
import { generatedPackageMetadata } from './generatedPackageMetadata.js';
export { facts };
@@ -68,8 +69,20 @@ export const run = async () => {
workerOutput: execFileSync(process.execPath, [fileURLToPath(facts.worker)], { encoding: 'utf8' })
};
};`,
};
for (const [path, contents] of Object.entries(files))
};
describe("CLI and MCP runtime bundles", () => {
workspaceCliTest(
"preserves module locations, lazy side effects, package resolution and worker entrypoints",
async ({ processes, workspace }) => {
await workspace.mkdir("dist/provider");
await workspace.mkdir("dist/composition");
await workspace.mkdir("dist/cli");
await workspace.mkdir("dist/windows");
await workspace.mkdir("bridge");
await workspace.mkdir("node_modules/external-probe");
await workspace.mkdir("node_modules/zod");
for (const [path, contents] of Object.entries(runtimeFixtureFiles))
await workspace.write(path, contents);
const invoke = async (entry: string) => {
const moduleUrl = pathToFileURL(join(workspace.root, entry)).href;
@@ -110,10 +123,23 @@ export const run = async () => {
afterLazy: ["provider", "main", "lazy"],
workerOutput: "worker-entry",
});
for (const [path, contents] of Object.entries(files))
for (const [path, contents] of Object.entries(runtimeFixtureFiles))
expect(await readFile(join(workspace.root, path), "utf8")).toBe(
contents,
);
// The real capture deadline includes cold startup. This worker bundles
// its schema dependency instead of reopening its module graph per frame.
await rm(join(workspace.root, "node_modules/zod"), { recursive: true });
const captureWorker = () =>
processes.run(
process.execPath,
[join(workspace.root, "dist/windows-capture-runtime/worker.js")],
{ cwd: workspace.root },
);
expect(await captureWorker()).toMatchObject({
exitCode: 0,
stdout: "capture-worker:1.0.0",
});
expect(
(await readdir(join(workspace.root, "dist/mcp"))).length,
).toBeGreaterThan(1);
@@ -125,6 +151,10 @@ export const run = async () => {
expect(previousRelease).toMatchObject({ version: "0.0.0" });
expect(await invoke("dist/mcp/main.js")).toEqual(previousRelease);
expect(await invoke("dist/cli-runtime/cli.js")).toEqual(previousRelease);
expect(await captureWorker()).toMatchObject({
exitCode: 0,
stdout: "capture-worker:0.0.0",
});
},
);
@@ -133,6 +163,11 @@ export const run = async () => {
async ({ workspace }) => {
await workspace.mkdir("dist");
await workspace.mkdir("dist/cli");
await workspace.mkdir("dist/windows");
await workspace.write(
"dist/windows/WindowsWindowCaptureWorker.js",
"export {};\n",
);
await workspace.write("package.json", '{"type":"module"}');
for (const path of [
"dist/cliOutput.js",
+126
View File
@@ -0,0 +1,126 @@
// Source-owned GUI for real selected-window capture acceptance. No user data.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
#include <thread>
#include <vector>
static bool changed = false;
static std::string mode;
static DWORD mainThread;
static void paint(HWND window, HDC dc) {
RECT area{};
GetClientRect(window, &area);
if (mode == "noise") {
const int width = area.right, height = area.bottom;
std::vector<DWORD> pixels(static_cast<size_t>(width) * height);
DWORD state = 42;
for (auto& pixel : pixels) {
state ^= state << 13; state ^= state >> 17; state ^= state << 5;
pixel = state | 0xff000000;
}
BITMAPINFO info{};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width; info.bmiHeader.biHeight = -height;
info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32;
SetDIBitsToDevice(dc, 0, 0, width, height, 0, 0, 0, height,
pixels.data(), &info, DIB_RGB_COLORS);
return;
}
const auto brush = CreateSolidBrush(changed ? RGB(80, 180, 40) : RGB(40, 120, 200));
FillRect(dc, &area, brush); DeleteObject(brush);
SetBkMode(dc, TRANSPARENT); SetTextColor(dc, RGB(255, 255, 255));
const wchar_t text[] = L"REA source-owned window: 42";
TextOutW(dc, 16, 16, text, sizeof(text) / sizeof(wchar_t) - 1);
}
static LRESULT CALLBACK procedure(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == WM_GETMINMAXINFO) {
auto* bounds = reinterpret_cast<MINMAXINFO*>(lparam);
bounds->ptMaxTrackSize = {8192, 8192};
return 0;
}
if (message == WM_PAINT) {
PAINTSTRUCT observation{};
const auto dc = BeginPaint(window, &observation);
paint(window, dc); EndPaint(window, &observation);
return 0;
}
if (message == WM_PRINT || message == WM_PRINTCLIENT) {
paint(window, reinterpret_cast<HDC>(wparam));
GdiFlush();
return 0;
}
if (message == WM_APP + 1) {
changed = true;
InvalidateRect(window, nullptr, FALSE); UpdateWindow(window);
std::puts("{\"event\":\"changed\"}"); std::fflush(stdout);
return 0;
}
if (message == WM_APP + 2) {
std::puts("{\"event\":\"blocking\"}"); std::fflush(stdout);
Sleep(12000);
return 0;
}
if (message == WM_CLOSE) { DestroyWindow(window); return 0; }
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
return IsWindowUnicode(window) ? DefWindowProcW(window, message, wparam, lparam)
: DefWindowProcA(window, message, wparam, lparam);
}
int main(int count, char** arguments) {
mode = count > 1 ? arguments[1] : "normal";
SetProcessDPIAware();
mainThread = GetCurrentThreadId();
const auto instance = GetModuleHandleW(nullptr);
const bool ansi = mode == "ansi";
WNDCLASSW wide{}; wide.lpfnWndProc = procedure;
wide.hInstance = instance; wide.lpszClassName = L"ReaWindowCaptureFixture";
WNDCLASSA narrow{}; narrow.lpfnWndProc = procedure;
narrow.hInstance = instance; narrow.lpszClassName = "ReaWindowCaptureFixture";
if (!(ansi ? RegisterClassA(&narrow) : RegisterClassW(&wide))) return 2;
RECT bounds{0, 0, mode == "noise" ? 1600 : 640, mode == "noise" ? 1600 : 360};
if (mode == "large") bounds = {0, 0, 2500, 2000};
AdjustWindowRect(&bounds, WS_OVERLAPPEDWINDOW, FALSE);
const wchar_t* title = L"REA window fixture \u4e2d\u6587";
char ansiTitle[128]{};
if (!WideCharToMultiByte(CP_ACP, 0, title, -1, ansiTitle, sizeof(ansiTitle), nullptr, nullptr)) return 3;
HWND window = ansi
? CreateWindowA(narrow.lpszClassName, ansiTitle, WS_OVERLAPPEDWINDOW,
100, 100, bounds.right - bounds.left, bounds.bottom - bounds.top,
nullptr, nullptr, instance, nullptr)
: CreateWindowW(wide.lpszClassName, title, WS_OVERLAPPEDWINDOW,
100, 100, bounds.right - bounds.left, bounds.bottom - bounds.top,
nullptr, nullptr, instance, nullptr);
if (!window) return 4;
ShowWindow(window, SW_SHOWNOACTIVATE); UpdateWindow(window);
RECT client{}; GetClientRect(window, &client);
wchar_t observedTitle[128]{};
const int titleCount = GetWindowTextW(window, observedTitle, 128);
std::printf("{\"pid\":%lu,\"window_handle\":\"0x%llx\",\"width\":%ld,\"height\":%ld,"
"\"title_length_estimate\":%d,\"title_copied\":%d}\n", GetCurrentProcessId(),
static_cast<unsigned long long>(reinterpret_cast<ULONG_PTR>(window)),
client.right, client.bottom, GetWindowTextLengthW(window), titleCount);
std::fflush(stdout);
std::thread([window] {
std::string command;
while (std::getline(std::cin, command)) {
if (command == "change") PostMessageW(window, WM_APP + 1, 0, 0);
else if (command == "block") PostMessageW(window, WM_APP + 2, 0, 0);
else if (command == "quit") break;
}
PostMessageW(window, WM_CLOSE, 0, 0);
// Also settle if the selected window was already destroyed.
PostThreadMessageW(mainThread, WM_QUIT, 0, 0);
}).detach();
MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
TranslateMessage(&message); DispatchMessageW(&message);
}
if (IsWindow(window)) DestroyWindow(window);
return 0;
}