feat: Windows WHP support, deterministic VM dispose, npm audit fixes#15
feat: Windows WHP support, deterministic VM dispose, npm audit fixes#15simongdavies wants to merge 1 commit intohyperlight-dev:mainfrom
Conversation
Windows platform support: - Justfile: [windows] recipes for build-hyperlight, resolve-hyperlight-dir, start-debug - Justfile: runtime-cflags forward-slash fix for clang cross-compilation - build-binary.js: .cmd launcher, platform-aware post-build output - plugins: O_NOFOLLOW fallback (Windows lacks O_NOFOLLOW, relies on lstat pre-check) - agent/index.ts: pathToFileURL for ESM plugin imports on Windows - build.rs: forward-slash CFLAGS for clang on Windows - code-validator/guest: win32-x64-msvc NAPI target - .gitattributes: enforce LF line endings across platforms VM resource management: - sandbox/tool.js: invalidateSandbox() now calls dispose() on LoadedJSSandbox and JSSandbox for deterministic VM cleanup instead of relying on V8 GC - Updated hyperlight-js dep to include dispose() API Error handling: - agent/event-handler.ts: suppress duplicate 'Tool execution failed' messages - sandbox/tool.js: MMIO error detection in compilation and runtime paths - agent/index.ts: surrogate pool env vars (HYPERLIGHT_INITIAL/MAX_SURROGATES) Test fixes (Windows compatibility): - tests: symlink EPERM skip for Windows (path-jail, fs-read, fs-write) - tests/dts-sync: rmSync instead of shell rm -rf - tests/pattern-loader: unique tmpdir per test to avoid Windows EBUSY locks CI: - pr-validate.yml: Windows WHP matrix - publish.yml: Windows build support Security: - npm audit fix across all workspaces (picomatch, brace-expansion) - plugin-system/manager.ts: simplified ternary Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds Windows (WHP) support and improves cross-platform reliability across build tooling, sandbox lifecycle management, plugins, tests, and CI, plus applies dependency security updates.
Changes:
- Add Windows WHP build/test support across Just recipes, CI matrices, binary packaging, and ESM plugin loading.
- Make sandbox VM cleanup deterministic via
dispose()and improve MMIO/out-of-memory error detection/messaging. - Improve Windows compatibility in tests and housekeeping scripts; apply npm audit upgrades (e.g., picomatch, brace-expansion).
Reviewed changes
Copilot reviewed 31 out of 33 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/pattern-loader.test.ts | Use per-test temp directories to avoid Windows file locking issues. |
| tests/path-jail.test.ts | Skip symlink creation-dependent tests when Windows lacks symlink privileges. |
| tests/fs-write.test.ts | Skip symlink tests on Windows EPERM; cleanup for baseDir symlink case. |
| tests/fs-read.test.ts | Skip symlink tests on Windows EPERM; cleanup for baseDir symlink case. |
| tests/dts-sync.test.ts | Replace rm -rf shell usage with rmSync for Windows compatibility. |
| src/sandbox/tool.js | Deterministic VM disposal + MMIO/unmapped-address error detection improvements. |
| src/plugin-system/manager.ts | Simplify plugin entrypoint selection logic. |
| src/code-validator/guest/package.json | Add Windows N-API build target. |
| src/code-validator/guest/package-lock.json | Update vulnerable dependency versions (e.g., picomatch). |
| src/code-validator/guest/index.js | Formatting + Windows triple mapping support. |
| src/code-validator/guest/host/build.rs | Normalize include path slashes for clang on Windows. |
| src/agent/index.ts | Windows surrogate pool env defaults + pathToFileURL for ESM plugin imports. |
| src/agent/event-handler.ts | Suppress duplicate tool failure errors when handler already displayed a user-facing error. |
| scripts/update-module-hashes.ts | Formatting-only adjustments. |
| scripts/generate-native-dts.ts | Formatting-only adjustments. |
| scripts/generate-host-modules-dts.ts | Formatting-only adjustments. |
| scripts/generate-ha-modules-dts.ts | Formatting-only adjustments. |
| scripts/check-native-runtime.js | Formatting-only adjustments. |
| scripts/build-modules.js | Formatting-only adjustments. |
| scripts/build-binary.js | Platform-aware binary packaging + add .cmd launcher and improved output messaging. |
| plugins/fs-write/index.ts | Add Windows fallback for missing O_NOFOLLOW. |
| plugins/fs-read/index.ts | Add Windows fallback for missing O_NOFOLLOW. |
| package.json | Make prepare/postinstall scripts cross-platform (no POSIX shell conditionals). |
| package-lock.json | Update vulnerable dependency versions (e.g., brace-expansion, picomatch). |
| builtin-modules/pptx.json | Update module source hash metadata. |
| builtin-modules/pptx-tables.json | Update module source hash metadata and formatting. |
| builtin-modules/pptx-charts.json | Update module source hash metadata. |
| builtin-modules/ooxml-core.json | Update module source hash metadata. |
| README.md | Update prerequisites to include Windows WHP. |
| Justfile | Add Windows recipes + fix runtime CFLAGS path separators for clang. |
| .github/workflows/publish.yml | Rename matrix label used in job name. |
| .github/workflows/pr-validate.yml | Add Windows WHP build/test matrix entries. |
| .gitattributes | Enforce LF line endings and mark binary file patterns. |
Files not reviewed (1)
- src/code-validator/guest/package-lock.json: Language not supported
Comments suppressed due to low confidence (2)
plugins/fs-read/index.ts:267
- This introduces
O_NOFOLLOW = FS_CONSTANTS.O_NOFOLLOW ?? 0, but the read paths still useFS_CONSTANTS.O_NOFOLLOWin theopenSyncflags (so the new constant is unused) and there is no post-open lstat/fstat comparison despite the comment claiming one. On Windows this leaves a real TOCTOU window where a path can be swapped to a symlink betweenvalidatePath()andopenSync(). Either implement the post-open verification (before reading) and use theO_NOFOLLOWconstant in open flags, or adjust the comment/behavior accordingly.
// O_NOFOLLOW atomically rejects symlinks at open() on POSIX.
// On Windows it doesn't exist — we rely on the lstatSync pre-check
// in validatePath() plus a post-open fstatSync/lstatSync comparison.
// The residual TOCTOU window is narrow and requires symlink creation
// privileges (SeCreateSymbolicLinkPrivilege or Developer Mode).
const O_NOFOLLOW = FS_CONSTANTS.O_NOFOLLOW ?? 0;
// ── Host function implementations ────────────────────────────
/**
* readFile(path, encoding?) — Read a file's contents.
* Uses O_NOFOLLOW to atomically reject symlinks at open time.
*/
function readFile(filePath: string, encoding?: string): ReadFileResult {
const enc =
typeof encoding === "string" && ALLOWED_ENCODINGS.has(encoding)
? encoding
: "utf8";
const check = validatePath(filePath, resolvedBase);
if (!check.valid) {
return { error: check.error };
}
let fd: number | undefined;
try {
fd = openSync(
check.realPath!,
FS_CONSTANTS.O_RDONLY | FS_CONSTANTS.O_NOFOLLOW,
);
plugins/fs-write/index.ts:277
O_NOFOLLOWis introduced with a Windows fallback (FS_CONSTANTS.O_NOFOLLOW ?? 0), but the write paths still OR inFS_CONSTANTS.O_NOFOLLOWdirectly (leaving the new constant unused) and there is no post-open lstat/fstat verification even though the comment says there is. For Windows, that meansvalidatePath()can be bypassed via TOCTOU (swap to a symlink between validation andopenSync()), potentially allowing writes outside the jail. Use theO_NOFOLLOWconstant in open flags and add a post-open check (before writing) to ensure the opened fd still corresponds to the non-symlink path.
// O_NOFOLLOW atomically rejects symlinks at open() on POSIX.
// On Windows it doesn't exist — we rely on the lstatSync pre-check
// in validatePath() plus a post-open fstatSync/lstatSync comparison.
// The residual TOCTOU window is narrow and requires symlink creation
// privileges (SeCreateSymbolicLinkPrivilege or Developer Mode).
const O_NOFOLLOW = FS_CONSTANTS.O_NOFOLLOW ?? 0;
const maxEntries = Math.floor(
safeNumericConfig(cfg.maxEntries, 500, MAX_ENTRIES_LIMIT),
);
let entriesCreated = 0;
// ── Host function implementations ────────────────────────────
function writeFile(
filePath: string,
content: string,
encoding?: string,
): WriteResult {
const enc =
typeof encoding === "string" && ALLOWED_ENCODINGS.has(encoding)
? encoding
: "utf8";
const check = validatePath(filePath, resolvedBase);
if (!check.valid) {
return { error: check.error };
}
if (typeof content !== "string") {
return {
error:
"writeFile expects a string. For binary data (Uint8Array from " +
"createZip, etc.), use writeFileBinary(path, data) instead.",
};
}
const contentBytes =
enc === "base64"
? Math.ceil((content.length * 3) / 4)
: Buffer.byteLength(content, "utf8");
if (contentBytes > maxWriteChunkBytes) {
return {
error: `Content too large for single write: ${contentBytes} bytes exceeds per-call limit of ${MAX_WRITE_CHUNK_KB}KB. Split into multiple appendFile calls.`,
};
}
if (contentBytes > maxWriteBytes) {
return {
error: `Content too large: exceeds cumulative file write limit of ${maxWriteBytes / 1024}KB`,
};
}
let fd: number | undefined;
let isNew = false;
try {
const parentDir = dirname(check.realPath!);
if (!existsSync(parentDir)) {
return { error: "Parent directory does not exist" };
}
isNew = !existsSync(check.realPath!);
if (isNew) {
if (entriesCreated >= maxEntries) {
return {
error: `Entry limit reached: cannot create more than ${maxEntries} files/directories`,
};
}
entriesCreated++;
}
fd = openSync(
check.realPath!,
FS_CONSTANTS.O_WRONLY |
FS_CONSTANTS.O_CREAT |
FS_CONSTANTS.O_TRUNC |
FS_CONSTANTS.O_NOFOLLOW,
FILE_MODE,
);
| import { fileURLToPath } from 'node:url'; | ||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| const cjs = join(__dirname, '..', 'lib', 'hyperagent-launcher.cjs'); | ||
| await import('file://' + cjs.replace(/\\\\/g, '/')); |
There was a problem hiding this comment.
The generated Node.js launcher builds a file URL via string concatenation ('file://' + cjs.replace(...)), which produces invalid URLs on both Windows (file://C:/... instead of file:///C:/...) and POSIX (file:////... for absolute paths). This will break import() of the CJS launcher in the built binary. Use pathToFileURL(cjs).href (or equivalent URL construction) instead of manual concatenation.
| import { fileURLToPath } from 'node:url'; | |
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| const cjs = join(__dirname, '..', 'lib', 'hyperagent-launcher.cjs'); | |
| await import('file://' + cjs.replace(/\\\\/g, '/')); | |
| import { fileURLToPath, pathToFileURL } from 'node:url'; | |
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| const cjs = join(__dirname, '..', 'lib', 'hyperagent-launcher.cjs'); | |
| await import(pathToFileURL(cjs).href); |
| @@ -417,7 +499,17 @@ To run (option 2 - add to PATH): | |||
|
|
|||
| To run (option 3 - symlink): | |||
| sudo ln -sf ${launcherPath} /usr/local/bin/hyperagent | |||
| hyperagent | |||
| hyperagent`; | |||
There was a problem hiding this comment.
The printed Unix run instructions escape $PATH (export PATH="${BIN_DIR}:\$PATH"), which will set PATH to a literal $PATH instead of expanding the existing value. Remove the backslash so the instructions actually prepend BIN_DIR.
| afterEach(() => { | ||
| rmSync(TMP_DIR, { recursive: true, force: true }); | ||
| try { | ||
| rmSync(TMP_DIR, { recursive: true, force: true }); | ||
| } catch { | ||
| // Windows: Defender/indexer may hold a lock — not worth failing the test | ||
| } |
There was a problem hiding this comment.
The afterEach cleanup swallows all rmSync failures unconditionally. That can hide real test isolation issues on non-Windows platforms (e.g., permission problems) and leave temp dirs behind. Consider only suppressing expected Windows lock errors (e.g., when process.platform === "win32" and error code is EBUSY/EPERM), and rethrow otherwise.
|
Reopening from windows-support branch |
Windows platform support:
VM resource management:
Error handling:
Test fixes (Windows compatibility):
CI:
Security: