diff --git a/.github/actions/prepare-native-runtime/action.yml b/.github/actions/prepare-native-runtime/action.yml index e487d7beef5..62763c66c75 100644 --- a/.github/actions/prepare-native-runtime/action.yml +++ b/.github/actions/prepare-native-runtime/action.yml @@ -54,7 +54,7 @@ runs: env: NATIVE_RUNTIME: ${{ inputs.native-runtime }} NODE_VERSION: ${{ inputs.node-version }} - NATIVE_SOURCE_HASH: ${{ hashFiles('.npmrc', 'config/pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/scripts/shared-tree-publish.mjs', 'config/scripts/native-build-entry.cjs', 'config/scripts/native-build-entry-probe.cjs', 'config/scripts/shared-native-build.mjs', 'config/scripts/orcad-prebuild-node-gyp.mjs', 'config/scripts/msbuild-file-tracking.mjs', 'config/scripts/native-dependency-lock-digest.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }} + NATIVE_SOURCE_HASH: ${{ hashFiles('.npmrc', 'config/pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/scripts/shared-tree-publish.mjs', 'config/scripts/native-build-entry.cjs', 'config/scripts/native-build-entry-probe.cjs', 'config/scripts/shared-native-build.mjs', 'config/scripts/orcad-prebuild-node-gyp.mjs', 'config/scripts/msbuild-file-tracking.mjs', 'config/scripts/native-dependency-lock-digest.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/src/isolated-spawn.cc', 'native/windows-security/src/isolated-spawn.h', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }} run: | scope="${ImageOS:-$RUNNER_OS}" if [ -r /etc/os-release ]; then diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index d77dcf1f707..b03bd244952 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -295,8 +295,11 @@ function loadNativeModule(moduleName) { return } if (moduleName === '@orca/windows-security') { - // Why: the addon loads on first call; the newest export also rejects a stale build that loads. - require(moduleName).clearStdHandleInheritance() + const security = require(moduleName) + // currentUserSid loads the addon; a stale build that still loads lacks the newest export. + if (!security.currentUserSid() || !security.hasNativeExport('spawnDetachedIsolated')) { + throw new Error('stale build without the newest export; rebuild it') + } return } if (moduleName === 'node-pty') { diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 20ebbfab40f..6f2ef63f1e3 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -425,7 +425,7 @@ function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) { mkdirSync(securityDir, { recursive: true }) writeFileSync( join(securityDir, 'index.js'), - "exports.currentUserSid = () => 'S-1-5-18'; exports.clearStdHandleInheritance = () => []\n" + "exports.currentUserSid = () => 'S-1-5-18'; exports.hasNativeExport = () => true\n" ) } diff --git a/config/scripts/pr-package-test-paths.mjs b/config/scripts/pr-package-test-paths.mjs index 1e6261e4cb4..e578bc0ffa8 100644 --- a/config/scripts/pr-package-test-paths.mjs +++ b/config/scripts/pr-package-test-paths.mjs @@ -96,6 +96,7 @@ export const WINDOWS_BOUNDARY_TESTS = [ 'src/main/startup/windows-install-dir-acl-repair.win32.test.ts', 'src/main/startup/windows-app-data-path.test.ts', 'src/main/startup/windows-std-handle-inheritance.win32.test.ts', + 'src/main/startup/windows-isolated-spawner.win32.test.ts', 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts', 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts', 'src/shared/secure-file-fsync-flags.test.ts', diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 4066d0c62ac..85fb2ca3e88 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -402,7 +402,7 @@ export function writeFakeWindowsSecurity(projectDir) { mkdirSync(securityDir, { recursive: true }) writeFileSync( join(securityDir, 'index.js'), - "exports.currentUserSid = () => 'S-1-5-18'; exports.clearStdHandleInheritance = () => []\n" + "exports.currentUserSid = () => 'S-1-5-18'; exports.hasNativeExport = () => true\n" ) } diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 36c23e9ef98..3c4a0752bf9 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -577,8 +577,8 @@ function loadNativeModule(moduleName) { return } if (moduleName === '@orca/windows-security') { - // Why: the addon loads on first call; the newest export also rejects a stale build that loads. - projectRequire(moduleName).clearStdHandleInheritance() + const security = projectRequire(moduleName) + if (!security.currentUserSid() || !security.hasNativeExport('spawnDetachedIsolated')) throw new Error('stale build without the newest export; rebuild it') return } if (moduleName === 'node-pty') { diff --git a/native/windows-security/binding.gyp b/native/windows-security/binding.gyp index 7379d0a81ff..d9fdbd3d81a 100644 --- a/native/windows-security/binding.gyp +++ b/native/windows-security/binding.gyp @@ -2,7 +2,7 @@ "targets": [ { "target_name": "orca_windows_security", - "sources": ["src/addon.cc"], + "sources": ["src/addon.cc", "src/isolated-spawn.cc"], "libraries": ["advapi32.lib"], "include_dirs": [" void +): IsolatedSpawnResult + +/** Whether the resolved binary exports `name`; loads it, but calls nothing else. */ +export declare function hasNativeExport(name: string): boolean diff --git a/native/windows-security/index.js b/native/windows-security/index.js index b7cc64f82bb..0500a4294f3 100644 --- a/native/windows-security/index.js +++ b/native/windows-security/index.js @@ -18,5 +18,9 @@ module.exports = { grantAccess: (path, sid, mask, options) => getAddon().grantAccess(path, sid, mask, options), grantAccessAsync: (path, sid, mask, options) => getAddon().grantAccessAsync(path, sid, mask, options), - clearStdHandleInheritance: () => getAddon().clearStdHandleInheritance() + clearStdHandleInheritance: () => getAddon().clearStdHandleInheritance(), + spawnDetachedIsolated: (file, commandLine, cwd, environmentBlock, onExit) => + getAddon().spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit), + // Reads the binding this package resolved, so a build probe tests the binary the app loads. + hasNativeExport: (name) => typeof getAddon()[name] === 'function' } diff --git a/native/windows-security/package.json b/native/windows-security/package.json index 1e92a1e3406..f9e824828e2 100644 --- a/native/windows-security/package.json +++ b/native/windows-security/package.json @@ -2,7 +2,7 @@ "name": "@orca/windows-security", "version": "1.0.0", "private": true, - "description": "In-process Windows file DACL reads and writes for Orca", + "description": "In-process Windows file DACLs and child handle inheritance for Orca", "license": "MIT", "files": [ "index.js", diff --git a/native/windows-security/src/addon.cc b/native/windows-security/src/addon.cc index 649d29628c1..7f06ac7a769 100644 --- a/native/windows-security/src/addon.cc +++ b/native/windows-security/src/addon.cc @@ -6,6 +6,8 @@ #include #include +#include "isolated-spawn.h" + #include #include #include @@ -423,6 +425,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("grantAccess", Napi::Function::New(env, GrantAccessJs)); exports.Set("grantAccessAsync", Napi::Function::New(env, GrantAccessAsync)); exports.Set("clearStdHandleInheritance", Napi::Function::New(env, ClearStdHandleInheritance)); + exports.Set("spawnDetachedIsolated", Napi::Function::New(env, SpawnDetachedIsolated)); return exports; } diff --git a/native/windows-security/src/isolated-spawn.cc b/native/windows-security/src/isolated-spawn.cc new file mode 100644 index 00000000000..787f5ec23c7 --- /dev/null +++ b/native/windows-security/src/isolated-spawn.cc @@ -0,0 +1,126 @@ +// libuv spawns with bInheritHandles=TRUE and no handle list, so every inheritable handle in Orca +// reaches a long-lived child. This spawn lists exactly one NUL handle, used for all three stdio. +// Flags mirror libuv's detached spawn; no PROC_THREAD_ATTRIBUTE_PARENT_PROCESS, no breakaway. + +#include "isolated-spawn.h" + +#include + +#include +#include + +namespace { + +struct ExitWatch { + HANDLE process = nullptr; + HANDLE wait = nullptr; + Napi::ThreadSafeFunction onExit; +}; + +// Runs on a wait thread once the process is signaled; hands the code to the JS thread. +void CALLBACK OnProcessExited(PVOID context, BOOLEAN /*timedOut*/) { + auto* watch = static_cast(context); + DWORD code = 0; + if (!GetExitCodeProcess(watch->process, &code)) { + code = static_cast(-1); + } + watch->onExit.NonBlockingCall([watch, code](Napi::Env env, Napi::Function callback) { + // Non-blocking unregister: the callback already ran once (WT_EXECUTEONLYONCE). + UnregisterWait(watch->wait); + CloseHandle(watch->process); + if (env != nullptr && !callback.IsEmpty()) { + callback.Call({Napi::Number::New(env, static_cast(code))}); + } + watch->onExit.Release(); + delete watch; + }); +} + +Napi::Value ThrowWin32(Napi::Env env, const char* syscall, DWORD code) { + Napi::Error::New(env, std::string(syscall) + " failed with Win32 error " + std::to_string(code)) + .ThrowAsJavaScriptException(); + return env.Undefined(); +} + +} // namespace + +// spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit(code)) -> { pid, exitWatchError } +// The exit callback never keeps the event loop alive. +Napi::Value SpawnDetachedIsolated(const Napi::CallbackInfo& info) { + auto env = info.Env(); + if (info.Length() < 5 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString() || + !info[3].IsString() || !info[4].IsFunction()) { + Napi::TypeError::New( + env, "spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit) expected") + .ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::u16string file = info[0].As().Utf16Value(); + std::u16string commandLine = info[1].As().Utf16Value(); + std::u16string cwd = info[2].As().Utf16Value(); + std::u16string environment = info[3].As().Utf16Value(); + + SECURITY_ATTRIBUTES inheritable{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE}; + HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &inheritable, OPEN_EXISTING, 0, nullptr); + if (nul == INVALID_HANDLE_VALUE) { + return ThrowWin32(env, "CreateFileW", GetLastError()); + } + SIZE_T size = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &size); + std::vector storage(size); + auto* attributes = reinterpret_cast(storage.data()); + if (!InitializeProcThreadAttributeList(attributes, 1, 0, &size)) { + DWORD error = GetLastError(); + CloseHandle(nul); + return ThrowWin32(env, "InitializeProcThreadAttributeList", error); + } + if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &nul, sizeof(nul), + nullptr, nullptr)) { + DWORD error = GetLastError(); + DeleteProcThreadAttributeList(attributes); + CloseHandle(nul); + return ThrowWin32(env, "UpdateProcThreadAttribute", error); + } + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + startup.StartupInfo.wShowWindow = SW_HIDE; + startup.StartupInfo.hStdInput = nul; + startup.StartupInfo.hStdOutput = nul; + startup.StartupInfo.hStdError = nul; + startup.lpAttributeList = attributes; + PROCESS_INFORMATION created{}; + const DWORD flags = CREATE_UNICODE_ENVIRONMENT | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | + EXTENDED_STARTUPINFO_PRESENT; + BOOL ok = CreateProcessW(reinterpret_cast(file.c_str()), + reinterpret_cast(commandLine.data()), nullptr, nullptr, TRUE, + flags, environment.data(), + cwd.empty() ? nullptr : reinterpret_cast(cwd.c_str()), + &startup.StartupInfo, &created); + DWORD error = GetLastError(); + DeleteProcThreadAttributeList(attributes); + CloseHandle(nul); + if (!ok) { + return ThrowWin32(env, "CreateProcessW", error); + } + CloseHandle(created.hThread); + + auto result = Napi::Object::New(env); + result.Set("pid", static_cast(created.dwProcessId)); + auto* watch = new ExitWatch{created.hProcess}; + watch->onExit = Napi::ThreadSafeFunction::New(env, info[4].As(), + "orcaIsolatedSpawnExit", 0, 1); + watch->onExit.Unref(env); + if (!RegisterWaitForSingleObject(&watch->wait, created.hProcess, OnProcessExited, watch, INFINITE, + WT_EXECUTEONLYONCE)) { + // The child runs on; only its exit goes unobserved. + result.Set("exitWatchError", static_cast(GetLastError())); + watch->onExit.Release(); + CloseHandle(created.hProcess); + delete watch; + return result; + } + result.Set("exitWatchError", env.Null()); + return result; +} diff --git a/native/windows-security/src/isolated-spawn.h b/native/windows-security/src/isolated-spawn.h new file mode 100644 index 00000000000..a3a3ef05357 --- /dev/null +++ b/native/windows-security/src/isolated-spawn.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +Napi::Value SpawnDetachedIsolated(const Napi::CallbackInfo& info); diff --git a/src/main/index.ts b/src/main/index.ts index 324d7562979..94eb10e13ef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -41,6 +41,7 @@ import { configureWslHostsGateFromDisk } from './wsl/wsl-host-gate-startup' import { ignoreInheritedWslHostsGateState } from '../shared/wsl-host-internal-gate' import { registerOrcaProtocolClient } from './startup/orca-protocol-registration' import { clearWindowsStdHandleInheritance } from './startup/windows-std-handle-inheritance' +import { installWindowsIsolatedSpawner } from './startup/windows-isolated-spawner' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -110,6 +111,7 @@ const handleMacAppActivation = createMacAppActivationHandler({ // Before anything spawns: no child may inherit Orca's std handles. clearWindowsStdHandleInheritance() +installWindowsIsolatedSpawner() // A relaunch or installer inherits the parent's published WSL gate state; this process recomputes. ignoreInheritedWslHostsGateState() // The uninstaller's holder release and hook removal run instead of the app, never alongside it. diff --git a/src/main/startup/windows-install-dir-package-acl-grant.test.ts b/src/main/startup/windows-install-dir-package-acl-grant.test.ts index d170d08cf5a..2d0ee63a872 100644 --- a/src/main/startup/windows-install-dir-package-acl-grant.test.ts +++ b/src/main/startup/windows-install-dir-package-acl-grant.test.ts @@ -43,6 +43,8 @@ function installFakeAddon( createFileWithDacl: unexpected, grantAccess: unexpected, clearStdHandleInheritance: unexpected, + spawnDetachedIsolated: unexpected, + hasNativeExport: unexpected, grantAccessAsync: async (path, sid, accessMask, options) => { calls.push({ path: plain(path), sid, accessMask, options }) if (fail(plain(path))) { @@ -191,6 +193,8 @@ describe('install-dir package grant passes', () => { createFileWithDacl: unexpected, grantAccess: unexpected, clearStdHandleInheritance: unexpected, + spawnDetachedIsolated: unexpected, + hasNativeExport: unexpected, grantAccessAsync: async (path) => { seen.push(path) } diff --git a/src/main/startup/windows-isolated-spawn-electron-fixture.ts b/src/main/startup/windows-isolated-spawn-electron-fixture.ts new file mode 100644 index 00000000000..c85c3eab29d --- /dev/null +++ b/src/main/startup/windows-isolated-spawn-electron-fixture.ts @@ -0,0 +1,29 @@ +// Electron main entry for windows-isolated-spawner.win32.test.ts: Electron keeps its std handles +// inheritable, so a holder spawned through libuv pins the test's pipe and an isolated one must not. +import { app } from 'electron' +import { writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import type * as WindowsSecurity from '@orca/windows-security' +import { + setIsolatedSpawner, + spawnDetachedWithoutInheritance +} from '@orca/process-host/isolated-detached-spawn' + +const [addonPath, mode, holderMs, resultFile] = process.argv.slice(-4) +const addon: typeof WindowsSecurity = createRequire(__filename)(addonPath) +if (mode === 'isolated') { + setIsolatedSpawner(addon.spawnDetachedIsolated) +} +const holder = spawnDetachedWithoutInheritance({ + program: process.execPath, + args: ['-e', `setTimeout(() => {}, ${holderMs})`], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } +}) +holder.once('error', (error) => { + writeFileSync(resultFile, `error ${String(error)}`) + app.exit(1) +}) +holder.once('spawn', () => { + writeFileSync(resultFile, String(holder.pid)) + app.exit(0) +}) diff --git a/src/main/startup/windows-isolated-spawner.test.ts b/src/main/startup/windows-isolated-spawner.test.ts new file mode 100644 index 00000000000..3b8b8b9f995 --- /dev/null +++ b/src/main/startup/windows-isolated-spawner.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { loadWindowsSecurity, setIsolatedSpawner } = vi.hoisted(() => ({ + loadWindowsSecurity: vi.fn(), + setIsolatedSpawner: vi.fn() +})) +vi.mock('../../shared/windows-security-addon', () => ({ loadWindowsSecurity })) +vi.mock('@orca/process-host/isolated-detached-spawn', () => ({ setIsolatedSpawner })) + +import { installWindowsIsolatedSpawner } from './windows-isolated-spawner' + +function addonWith(hasNativeExport: (name: string) => boolean) { + const spawnDetachedIsolated = vi.fn() + return { + spawnDetachedIsolated, + security: { userSid: 'S-1-5-18', addon: { hasNativeExport, spawnDetachedIsolated } } + } +} + +describe('installWindowsIsolatedSpawner', () => { + afterEach(() => { + vi.restoreAllMocks() + loadWindowsSecurity.mockReset() + setIsolatedSpawner.mockReset() + }) + + it('is a no-op off Windows', () => { + installWindowsIsolatedSpawner('darwin') + + expect(loadWindowsSecurity).not.toHaveBeenCalled() + expect(setIsolatedSpawner).not.toHaveBeenCalled() + }) + + it('installs the native spawner when the binary exports it', () => { + const { security, spawnDetachedIsolated } = addonWith( + (name) => name === 'spawnDetachedIsolated' + ) + loadWindowsSecurity.mockReturnValue(security) + + installWindowsIsolatedSpawner('win32') + + expect(setIsolatedSpawner).toHaveBeenCalledWith(spawnDetachedIsolated) + }) + + it('keeps libuv and logs when the binary is stale', () => { + loadWindowsSecurity.mockReturnValue(addonWith(() => false).security) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + installWindowsIsolatedSpawner('win32') + + expect(setIsolatedSpawner).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[startup] detached spawns inherit handles; the addon build is stale' + ) + }) + + it('keeps libuv and logs when the addon is unavailable or throws', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + loadWindowsSecurity.mockReturnValueOnce({ error: 'missing binary' }) + loadWindowsSecurity.mockImplementationOnce(() => { + throw new Error('boom') + }) + + expect(() => installWindowsIsolatedSpawner('win32')).not.toThrow() + expect(() => installWindowsIsolatedSpawner('win32')).not.toThrow() + expect(setIsolatedSpawner).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/startup/windows-isolated-spawner.ts b/src/main/startup/windows-isolated-spawner.ts new file mode 100644 index 00000000000..ef9f189361e --- /dev/null +++ b/src/main/startup/windows-isolated-spawner.ts @@ -0,0 +1,26 @@ +import { setIsolatedSpawner } from '@orca/process-host/isolated-detached-spawn' +import { loadWindowsSecurity } from '../../shared/windows-security-addon' + +/** Long-lived detached children (the WSL holder) then inherit none of main's handles, e.g. a + * DevTools socket. A missing or stale addon keeps libuv's spawn, which inherits them. */ +export function installWindowsIsolatedSpawner(platform: NodeJS.Platform = process.platform): void { + if (platform !== 'win32') { + return + } + try { + const security = loadWindowsSecurity() + if ('error' in security) { + console.warn( + `[startup] detached spawns inherit handles; addon unavailable: ${security.error}` + ) + return + } + if (!security.addon.hasNativeExport('spawnDetachedIsolated')) { + console.warn('[startup] detached spawns inherit handles; the addon build is stale') + return + } + setIsolatedSpawner(security.addon.spawnDetachedIsolated) + } catch (error) { + console.warn('[startup] detached spawns inherit handles:', error) + } +} diff --git a/src/main/startup/windows-isolated-spawner.win32.test.ts b/src/main/startup/windows-isolated-spawner.win32.test.ts new file mode 100644 index 00000000000..9cd2f2a9cf0 --- /dev/null +++ b/src/main/startup/windows-isolated-spawner.win32.test.ts @@ -0,0 +1,156 @@ +import { build } from 'esbuild' +import { once } from 'node:events' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { spawnProcess } from '@orca/process-host' +import { + setIsolatedSpawner, + spawnDetachedWithoutInheritance +} from '@orca/process-host/isolated-detached-spawn' +import { loadWindowsSecurity } from '../../shared/windows-security-addon' + +const describeOnWindows = process.platform === 'win32' ? describe : describe.skip +const ADDON = resolve(__dirname, '..', '..', '..', 'native', 'windows-security', 'index.js') +const root = mkdtempSync(join(tmpdir(), 'orca-isolated-spawn-')) +const entry = join(root, 'main.cjs') + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +/** Runs the Electron fixture; reports whether its holder outlived the fixture's stdout pipe. */ +async function runElectron(mode: 'isolated' | 'libuv', holderMs: number) { + const electron: unknown = createRequire(import.meta.url)('electron') + if (typeof electron !== 'string') { + throw new Error('Electron executable is unavailable') + } + const env: NodeJS.ProcessEnv = { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' } + delete env.ELECTRON_RUN_AS_NODE + const resultFile = join(root, `${mode}.result`) + const child = spawnProcess({ + program: electron, + args: [entry, ADDON, mode, String(holderMs), resultFile], + env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let output = '' + child.stdout?.on('data', (chunk) => (output += chunk)) + child.stderr?.on('data', (chunk) => (output += chunk)) + const [code] = await once(child, 'close', { signal: AbortSignal.timeout(holderMs + 30_000) }) + const result = readFileSync(resultFile, 'utf8') + const holderPid = Number(result) + const holderAliveAtClose = isAlive(holderPid) + if (holderAliveAtClose) { + process.kill(holderPid) + } + return { code, output, result, holderAliveAtClose } +} + +describeOnWindows('isolated detached spawn against real Windows processes', () => { + beforeAll(async () => { + await build({ + entryPoints: [resolve(__dirname, 'windows-isolated-spawn-electron-fixture.ts')], + outfile: entry, + bundle: true, + platform: 'node', + format: 'cjs', + conditions: ['orca-source'], + external: ['electron'], + logLevel: 'silent' + }) + const security = loadWindowsSecurity() + if ('error' in security) { + throw new Error(`the windows-security addon did not load: ${security.error}`) + } + setIsolatedSpawner(security.addon.spawnDetachedIsolated) + }) + + afterAll(() => { + setIsolatedSpawner(null) + rmSync(root, { recursive: true, force: true }) + }) + + // A cold Node start on a loaded Windows runner can exceed the 5 s default. + it('passes argv through unchanged and reports the exit code', async () => { + const argvFile = join(root, 'argv.json') + const tricky = ['plain', 'with space', 'quote"inside', 'back\\slash', 'trail\\', 'x \\"y', ''] + const child = spawnDetachedWithoutInheritance({ + program: process.execPath, + args: [ + '-e', + 'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2))); process.exit(7)', + argvFile, + ...tricky + ], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } + }) + const [code] = await once(child, 'exit', { signal: AbortSignal.timeout(20_000) }) + + expect(code).toBe(7) + expect(JSON.parse(readFileSync(argvFile, 'utf8'))).toEqual(tricky) + }, 30_000) + + // Holder liveness reads these: exit 1 means its distro stopped (or someone else killed it). + it.each(['isolated', 'libuv'] as const)( + '%s spawn: own kill reports SIGTERM, an outside kill reports exit 1', + async (mode) => { + const start = () => { + const spec = { + program: process.execPath, + args: ['-e', 'setTimeout(() => {}, 60000)'], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } + } + return mode === 'isolated' + ? spawnDetachedWithoutInheritance(spec) + : spawnProcess({ ...spec, detached: true, stdio: 'ignore' }) + } + const own = start() + const outside = start() + await Promise.all([once(own, 'spawn'), once(outside, 'spawn')]) + const ownExit = once(own, 'exit', { signal: AbortSignal.timeout(10_000) }) + const outsideExit = once(outside, 'exit', { signal: AbortSignal.timeout(10_000) }) + + expect(own.kill()).toBe(true) + process.kill(Number(outside.pid)) + + expect(await ownExit).toEqual([null, 'SIGTERM']) + expect(await outsideExit).toEqual([1, null]) + }, + // Two cold Node starts on a loaded Windows runner can exceed the 5 s default. + 20_000 + ) + + it('reports a missing program as an error event', async () => { + const child = spawnDetachedWithoutInheritance({ + program: join(root, 'missing.exe') + }) + const [error] = await once(child, 'error', { signal: AbortSignal.timeout(5_000) }) + + expect(String(error)).toContain('CreateProcessW failed with Win32 error 2') + }) + + // Cold Electron start on a CI runner; a regression pins the pipe for the 60 s holder. + it('keeps an Electron main pipe away from a detached holder', async () => { + const run = await runElectron('isolated', 60_000) + + expect(run.code, run.output).toBe(0) + expect(run.result).toMatch(/^\d+$/) + expect(run.holderAliveAtClose).toBe(true) + }, 90_000) + + // Cold Electron start on a CI runner plus the 3 s holder exceeds the 5 s default. + it('without it, the libuv holder pins the pipe until it exits (control)', async () => { + const run = await runElectron('libuv', 3_000) + + expect(run.code, run.output).toBe(0) + expect(run.holderAliveAtClose).toBe(false) + }, 60_000) +}) diff --git a/src/main/wsl/wsl-distro-holder.ts b/src/main/wsl/wsl-distro-holder.ts index ae391853f2c..41bc443500b 100644 --- a/src/main/wsl/wsl-distro-holder.ts +++ b/src/main/wsl/wsl-distro-holder.ts @@ -1,5 +1,5 @@ import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' -import { spawnProcess } from '@orca/process-host' +import { spawnDetachedWithoutInheritance } from '@orca/process-host/isolated-detached-spawn' import { queryWindowsProcess } from '../daemon/daemon-process-inspection' import { listConfirmedRunningWslDistros, @@ -42,14 +42,13 @@ export const WSL_HOLDER_UNVERIFIABLE_REPLACE_AFTER_MS = 5 * 60_000 function spawnDetachedHolder(distro: string): WslDistroHolderChild { // Detached holders outlive Orca's libuv kill-on-close job; explicit taskkill /T still reaches descendants. - return spawnProcess({ + // Inheriting none of Orca's handles keeps a launcher's pipe or a DevTools socket from outliving Orca. + return spawnDetachedWithoutInheritance({ program: resolveWslExecutablePath(), args: buildWslDistroHolderArgs(distro), // A host-wide holder must never pin a worktree as its Windows cwd. cwd: resolveWslInteropSpawnCwd(), - env: { ...process.env, WSL_UTF8: '1' }, - detached: true, - stdio: 'ignore' + env: { ...process.env, WSL_UTF8: '1' } }) } diff --git a/src/main/wsl/wsl-orcad-real-distro-idle-release-lane.test.ts b/src/main/wsl/wsl-orcad-real-distro-idle-release-lane.test.ts index 8435e1fe335..004df9fd76e 100644 --- a/src/main/wsl/wsl-orcad-real-distro-idle-release-lane.test.ts +++ b/src/main/wsl/wsl-orcad-real-distro-idle-release-lane.test.ts @@ -483,9 +483,10 @@ async function restartThroughBridgeConpty(): Promise { } ) let output = '' - let firstPaintMs: number | null = null + // ConPTY's own startup repaint, before the distro has produced anything. + let conptyStartupOutputMs: number | null = null terminal.onData((data) => { - firstPaintMs ??= Math.round(performance.now() - started) + conptyStartupOutputMs ??= Math.round(performance.now() - started) output += data }) try { @@ -496,7 +497,7 @@ async function restartThroughBridgeConpty(): Promise { 120_000, 50 ) - return { firstPaintMs, firstOutputMs: Math.round(performance.now() - started) } + return { conptyStartupOutputMs, firstOutputMs: Math.round(performance.now() - started) } } finally { terminal.kill() } @@ -616,7 +617,7 @@ async function distroRestartBaseline(): Promise { medianHostPhases: medianWslOrcadEnsurePhases(host.map((run) => run.phases)), medianBridgeExecLowerBoundMs: median(bridge, 'firstOutputMs'), bridgeConpty: conpty, - medianBridgeConptyFirstPaintMs: median(conpty, 'firstPaintMs'), + medianBridgeConptyStartupOutputMs: median(conpty, 'conptyStartupOutputMs'), medianBridgeConptyFirstOutputMs: median(conpty, 'firstOutputMs') } // One unrecorded connect boots the distro and orcad the warm runs reconnect to. diff --git a/src/packages/process-host/package.json b/src/packages/process-host/package.json index 31af0ed7103..d231d88afe1 100644 --- a/src/packages/process-host/package.json +++ b/src/packages/process-host/package.json @@ -20,6 +20,11 @@ "types": "./dist/process-spec.d.ts", "default": "./dist/process-spec.js" }, + "./isolated-detached-spawn": { + "orca-source": "./src/isolated-detached-spawn.ts", + "types": "./dist/isolated-detached-spawn.d.ts", + "default": "./dist/isolated-detached-spawn.js" + }, "./fork-process": { "orca-source": "./src/fork-process.ts", "types": "./dist/fork-process.d.ts", diff --git a/src/packages/process-host/src/isolated-detached-spawn.test.ts b/src/packages/process-host/src/isolated-detached-spawn.test.ts new file mode 100644 index 00000000000..acee016744f --- /dev/null +++ b/src/packages/process-host/src/isolated-detached-spawn.test.ts @@ -0,0 +1,190 @@ +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { spawnProcess } = vi.hoisted(() => ({ spawnProcess: vi.fn() })) +vi.mock('./run-process', () => ({ spawnProcess })) + +import { + buildWindowsEnvironmentBlock, + quoteWindowsArgumentLikeLibuv, + setIsolatedSpawner, + spawnDetachedWithoutInheritance, + type IsolatedSpawner +} from './isolated-detached-spawn' + +const originalPlatform = process.platform + +function onPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }) +} + +describe('quoteWindowsArgumentLikeLibuv', () => { + // The expected outputs are the ones libuv's quote_cmd_arg documents. + it.each([ + ['', '""'], + ['hello', 'hello'], + ['hello\\world', 'hello\\world'], + ['hello world', '"hello world"'], + ['hello"world', '"hello\\"world"'], + ['hello""world', '"hello\\"\\"world"'], + ['hello\\"world', '"hello\\\\\\"world"'], + ['hello\\\\"world', '"hello\\\\\\\\\\"world"'], + ['hello world\\', '"hello world\\\\"'], + ['C:\\Program Files\\x', '"C:\\Program Files\\x"'] + ])('quotes %j as %s', (value, expected) => { + expect(quoteWindowsArgumentLikeLibuv(value)).toBe(expected) + }) +}) + +describe('buildWindowsEnvironmentBlock', () => { + it('sorts case-insensitively, keeps the first of a duplicate, and ends with two NULs', () => { + const block = buildWindowsEnvironmentBlock( + { b: '2', A: '1', B: 'dup', SYSTEMROOT: 'C:\\Windows', skip: undefined }, + {} + ) + + expect(block).toBe('A=1\0b=2\0SYSTEMROOT=C:\\Windows\0\0') + }) + + it('fills required variables from the parent the way libuv does', () => { + const block = buildWindowsEnvironmentBlock({ X: '1' }, { SystemRoot: 'C:\\W', Other: 'no' }) + + expect(block).toBe('SystemRoot=C:\\W\0X=1\0\0') + }) + + it('keeps an empty environment a valid block', () => { + expect(buildWindowsEnvironmentBlock({}, {})).toBe('\0') + }) +}) + +describe('spawnDetachedWithoutInheritance', () => { + afterEach(() => { + setIsolatedSpawner(null) + onPlatform(originalPlatform) + vi.unstubAllEnvs() + spawnProcess.mockReset() + }) + + it('uses a detached, ignoring libuv spawn off Windows', () => { + onPlatform('linux') + const spawner = vi.fn() + setIsolatedSpawner(spawner) + spawnProcess.mockReturnValue({ pid: 1 }) + + spawnDetachedWithoutInheritance({ program: '/bin/true', args: ['x'], cwd: '/' }) + + expect(spawner).not.toHaveBeenCalled() + expect(spawnProcess).toHaveBeenCalledWith({ + program: '/bin/true', + args: ['x'], + cwd: '/', + detached: true, + stdio: 'ignore' + }) + }) + + it('falls back to libuv on Windows when no spawner is installed', () => { + onPlatform('win32') + spawnProcess.mockReturnValue({ pid: 1 }) + + spawnDetachedWithoutInheritance({ program: 'C:\\Windows\\System32\\wsl.exe' }) + + expect(spawnProcess).toHaveBeenCalledOnce() + }) + + it.each(['C:\\tools\\run.cmd', 'wsl.exe'])('falls back to libuv for %s', (program) => { + onPlatform('win32') + const spawner = vi.fn() + setIsolatedSpawner(spawner) + spawnProcess.mockReturnValue({ pid: 1 }) + + spawnDetachedWithoutInheritance({ program, env: { ComSpec: 'cmd.exe' } }) + + expect(spawner).not.toHaveBeenCalled() + expect(spawnProcess).toHaveBeenCalledOnce() + }) + + it('hands the native spawner a libuv-shaped command line and environment block', async () => { + onPlatform('win32') + let exit: (code: number) => void = () => {} + const spawner = vi.fn((_file, _line, _cwd, _env, onExit) => { + exit = onExit + return { pid: 42, exitWatchError: null } + }) + setIsolatedSpawner(spawner) + + const child = spawnDetachedWithoutInheritance({ + program: 'C:\\Windows\\System32\\wsl.exe', + args: ['--exec', '/bin/sh', '-c', 'echo "hi"'], + cwd: 'C:\\Users\\me', + env: { WSL_UTF8: '1' } + }) + await once(child, 'spawn') + const exited = once(child, 'exit') + exit(3) + + expect(child.pid).toBe(42) + expect(await exited).toEqual([3, null]) + expect(spawner).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\wsl.exe', + 'C:\\Windows\\System32\\wsl.exe --exec /bin/sh -c "echo \\"hi\\""', + 'C:\\Users\\me', + expect.stringMatching(/WSL_UTF8=1\0/), + expect.any(Function) + ) + expect(spawnProcess).not.toHaveBeenCalled() + }) + + it('reports exits like libuv: its own kill as a signal, any other exit as the code', async () => { + onPlatform('win32') + const exits: ((code: number) => void)[] = [] + let nextPid = 100 + setIsolatedSpawner((_file, _line, _cwd, _env, onExit) => { + exits.push(onExit) + return { pid: nextPid++, exitWatchError: null } + }) + const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true) + const stopped = spawnDetachedWithoutInheritance({ program: 'C:\\wsl.exe' }) + const killed = spawnDetachedWithoutInheritance({ program: 'C:\\wsl.exe' }) + + expect(killed.kill()).toBe(true) + expect(processKill).toHaveBeenCalledWith(101, 'SIGTERM') + const stoppedExit = once(stopped, 'exit') + const killedExit = once(killed, 'exit') + // The holder exits 1 when its distro stops; TerminateProcess also leaves 1. + exits[0](1) + exits[1](1) + + expect(await stoppedExit).toEqual([1, null]) + expect(await killedExit).toEqual([null, 'SIGTERM']) + expect(killed.kill()).toBe(false) + }) + + it('reports a native spawn failure as an error event, like libuv', async () => { + onPlatform('win32') + setIsolatedSpawner(() => { + throw new Error('CreateProcessW failed with Win32 error 2') + }) + + const child = spawnDetachedWithoutInheritance({ program: 'C:\\missing.exe' }) + const [error] = await once(child, 'error') + + expect(child.pid).toBeUndefined() + expect(String(error)).toContain('Win32 error 2') + }) + + it('rejects a WSL distro outside the allowlist before spawning', () => { + onPlatform('win32') + vi.stubEnv('ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST', 'allowed-distro') + const spawner = vi.fn() + setIsolatedSpawner(spawner) + + expect(() => + spawnDetachedWithoutInheritance({ + program: 'C:\\Windows\\System32\\wsl.exe', + args: ['-d', 'not-allowed-distro', '--exec', 'true'] + }) + ).toThrow() + expect(spawner).not.toHaveBeenCalled() + }) +}) diff --git a/src/packages/process-host/src/isolated-detached-spawn.ts b/src/packages/process-host/src/isolated-detached-spawn.ts new file mode 100644 index 00000000000..df3ad6843a8 --- /dev/null +++ b/src/packages/process-host/src/isolated-detached-spawn.ts @@ -0,0 +1,157 @@ +import { EventEmitter } from 'node:events' +import { win32 } from 'node:path' +import type { ProcessSpec } from './process-spec' +import { spawnProcess } from './run-process' +import { resolveSpawn } from './spawn-resolution' +import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer' + +/** Main installs the native spawner; daemon, relay, and CLI fall back to libuv. Empty cwd: ours. */ +export type IsolatedSpawner = ( + file: string, + commandLine: string, + cwd: string, + environmentBlock: string, + onExit: (code: number) => void +) => { pid: number; exitWatchError: number | null } + +export type DetachedChild = EventEmitter & { + readonly pid?: number + unref(): void + kill(signal?: NodeJS.Signals): boolean +} + +export type DetachedSpec = Omit + +let isolatedSpawner: IsolatedSpawner | null = null + +export function setIsolatedSpawner(next: IsolatedSpawner | null): void { + isolatedSpawner = next +} + +// libuv's quote_cmd_arg, so a holder's command line matches what uv_spawn produced before. +export function quoteWindowsArgumentLikeLibuv(value: string): string { + if (value === '') { + return '""' + } + if (!/[ \t"]/.test(value)) { + return value + } + if (!/["\\]/.test(value)) { + return `"${value}"` + } + let quoted = '"' + let backslashes = 0 + for (const char of value) { + if (char === '\\') { + backslashes += 1 + continue + } + quoted += + char === '"' ? `${'\\'.repeat(backslashes * 2 + 1)}"` : `${'\\'.repeat(backslashes)}${char}` + backslashes = 0 + } + return `${quoted}${'\\'.repeat(backslashes * 2)}"` +} + +// Variables libuv copies from the parent when the given env lacks them; Windows breaks without some. +const REQUIRED_WINDOWS_ENV = [ + 'HOMEDRIVE', + 'HOMEPATH', + 'LOGONSERVER', + 'PATH', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'USERDOMAIN', + 'USERNAME', + 'USERPROFILE', + 'WINDIR' +] + +/** libuv's make_program_env: required variables filled in, sorted case-insensitively, `\0\0`-ended. */ +export function buildWindowsEnvironmentBlock( + env: NodeJS.ProcessEnv, + parentEnv: NodeJS.ProcessEnv = process.env +): string { + const entries = new Map() + for (const [key, value] of Object.entries(env)) { + if (value !== undefined && !entries.has(key.toUpperCase())) { + entries.set(key.toUpperCase(), `${key}=${value}`) + } + } + for (const required of REQUIRED_WINDOWS_ENV) { + if (entries.has(required)) { + continue + } + const inherited = Object.entries(parentEnv).find(([key]) => key.toUpperCase() === required) + if (inherited?.[1] !== undefined) { + entries.set(required, `${inherited[0]}=${inherited[1]}`) + } + } + const sorted = [...entries.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return `${sorted.map(([, entry]) => `${entry}\0`).join('')}\0` +} + +/** + * Detached, stdio-ignoring spawn whose child inherits none of Orca's handles on Windows. + * Falls back to spawnProcess off Windows, without an installed spawner, or for programs that need + * cmd.exe or a relative-path lookup. + */ +export function spawnDetachedWithoutInheritance(spec: DetachedSpec): DetachedChild { + const fallback = (): DetachedChild => spawnProcess({ ...spec, detached: true, stdio: 'ignore' }) + const spawner = isolatedSpawner + if (process.platform !== 'win32' || !spawner) { + return fallback() + } + const resolved = resolveSpawn({ ...spec, detached: true, stdio: 'ignore' }, 'win32') + if (resolved.options.windowsVerbatimArguments || !win32.isAbsolute(resolved.file)) { + return fallback() + } + const child = new EventEmitter() + const commandLine = [resolved.file, ...resolved.args].map(quoteWindowsArgumentLikeLibuv).join(' ') + const startedAt = hasSpawnObserver() ? performance.now() : null + let pid: number | undefined + let exited = false + let killedWith: NodeJS.Signals | null = null + // Like libuv: an exit after our own kill() reports (null, signal); any other exit, its code. + const onExit = (code: number): void => { + exited = true + child.emit('exit', killedWith ? null : code, killedWith) + child.emit('close', killedWith ? null : code, killedWith) + } + const kill = (signal: NodeJS.Signals = 'SIGTERM'): boolean => { + if (pid === undefined || exited) { + return false + } + try { + process.kill(pid, signal) + } catch { + return false + } + killedWith = signal + return true + } + try { + const spawned = spawner( + resolved.file, + commandLine, + spec.cwd ?? '', + buildWindowsEnvironmentBlock(resolved.options.env ?? process.env), + onExit + ) + pid = spawned.pid + if (startedAt !== null) { + notifySpawnObserver(resolved.file, resolved.args, performance.now() - startedAt) + } + if (spawned.exitWatchError !== null) { + console.warn( + `[process-host] pid ${pid} exit is unobservable; Win32 error ${spawned.exitWatchError}` + ) + } + process.nextTick(() => child.emit('spawn')) + } catch (error) { + process.nextTick(() => child.emit('error', error)) + } + // The native exit watch never holds the event loop, so unref has nothing to release. + return Object.assign(child, { pid, unref: () => {}, kill }) +} diff --git a/src/shared/windows-security-addon.test-fixture.ts b/src/shared/windows-security-addon.test-fixture.ts index 3f4ee1978e5..57399a03434 100644 --- a/src/shared/windows-security-addon.test-fixture.ts +++ b/src/shared/windows-security-addon.test-fixture.ts @@ -107,6 +107,10 @@ export function createFakeWindowsSecurity(): FakeWindowsSecurity { fake.calls.filter((call) => call.op === op && (path === undefined || call.path === path)), currentUserSid: () => FAKE_USER_SID, clearStdHandleInheritance: () => [], + spawnDetachedIsolated: () => { + throw new Error('spawnDetachedIsolated is not faked') + }, + hasNativeExport: () => true, getDaclAces: (addonPath) => { const path = record('get', addonPath) return daclFromSddl(fake.dacls.get(path) ?? INHERITED_SDDL) diff --git a/src/shared/windows-security-addon.ts b/src/shared/windows-security-addon.ts index 4d41faedfb3..8da109152a2 100644 --- a/src/shared/windows-security-addon.ts +++ b/src/shared/windows-security-addon.ts @@ -111,7 +111,10 @@ function prepare(raw: WindowsSecurityAddon): WindowsSecurity { raw.grantAccess(toNamespacedPath(path), sid, mask, options), grantAccessAsync: (path, sid, mask, options) => inPathOrder(path, (namespaced) => raw.grantAccessAsync(namespaced, sid, mask, options)), - clearStdHandleInheritance: () => raw.clearStdHandleInheritance() + clearStdHandleInheritance: () => raw.clearStdHandleInheritance(), + spawnDetachedIsolated: (file, commandLine, cwd, environmentBlock, onExit) => + raw.spawnDetachedIsolated(file, commandLine, cwd, environmentBlock, onExit), + hasNativeExport: (name) => raw.hasNativeExport(name) } } }