fix(win32): resolve EPERM on userData writes, batch-file spawn failures, and native dep rebuild (#1152)

* chore: update .gitignore to include stackdump and .serena, enhance pre-commit script

* fix(win32): resolve EPERM on userData writes and batch-file spawn failures

Three Windows-specific issues prevented Orca from running correctly on
machines where Chromium resets the userData DACL during startup:

1. **EPERM on userData writes** — Chromium's BrowserWindow constructor calls
   SetNamedSecurityInfo on the userData folder with a Protected DACL.  When
   propagated to child directories the ACEs carry the Inherit-Only flag,
   meaning they apply to children-of-children but NOT to the directories
   themselves.  Any file write inside codex-runtime-home, agent-hooks, or
   similar subdirectories fails with EPERM.

   Fix: grant an explicit Full Control ACE (OI)(CI)(F) on userData and all
   existing children before BrowserWindow is created (icacls /T /C).
   Explicit ACEs survive future DACL propagation from the parent.  Per-write
   EPERM retries in fs-utils and installer-utils serve as the backstop for
   directories created after startup.

2. **Batch-file spawn failures** — resolveCodexCommand() can return a .cmd
   or .bat path (e.g. codex.cmd installed via npm).  Node's spawn() cannot
   execute batch scripts directly without shell:true, but shell:true with an
   args array triggers DEP0190 because args are concatenated rather than
   escaped.  Both service.ts and codex-fetcher.ts were affected.

   Fix: detect .cmd/.bat paths and route through cmd.exe /c explicitly,
   which is equivalent to what shell:true does internally but avoids the
   deprecation warning and arg-escaping hazard.

3. **Native dep rebuild failure** — electron-builder install-app-deps does
   not expose the ignoreModules option.  On Windows dev machines without the
   full VC++ / Python toolchain, cpu-features (an optional dep of ssh2) fails
   to build with node-gyp, aborting the entire postinstall step.

   Fix: replace electron-builder install-app-deps with a thin wrapper script
   (scripts/rebuild-native-deps.mjs) that calls @electron/rebuild's JS API
   directly with ignoreModules: ['cpu-features'] on Windows.  ssh2 detects
   the missing native module and falls back to pure-JS automatically.

Refactoring: extract shared win32-utils.ts with getIcaclsExePath(),
getCmdExePath(), isWindowsBatchScript(), isPermissionError(), grantDirAcl(),
and getSpawnArgsForWindows() to eliminate five instances of duplicated
SystemRoot path construction and two near-identical EPERM retry blocks.
Reduce startup icacls calls from three sequential blocking /T invocations
to one, removing up to 20 s of potential startup delay.

* fix(win32): address review feedback on ACL and spawn helpers

- Fall back to SID via `whoami /user` when `USERNAME` is unset so
  `grantDirAcl` works under services, CI, and hardened envs instead of
  silently no-op'ing.
- Use a 60s timeout for recursive `icacls /T` walks; the 10s cap could
  starve on large userData trees and silently fail the startup grant.
- Pass `windowsHide: true` to `icacls` and the cmd.exe-routed Codex
  spawns so no console window flashes in the packaged GUI app.
- Add `/d` to `cmd.exe /c` invocations to disable AutoRun registry
  commands — safer default for background spawns.
- Drop unused `createRequire`/`require` from rebuild-native-deps.mjs.
- Add `@electron/rebuild` as an explicit devDependency; relying on the
  electron-builder transitive was brittle under pnpm.
- Fix two misleading "Re-enable inheritance" comments that describe
  behavior opposite to what the code actually does (explicit ACL grant).
- Add unit tests for `isWindowsBatchScript`, `getSpawnArgsForWindows`,
  and `isPermissionError` to lock in Windows batch detection + cmd.exe
  routing.

Co-authored-by: Orca <help@stably.ai>

* fix(win32): unify PTY spawn through /d and document cmd.exe safety

- fetchViaPty now uses getCmdExePath() and /d /c, matching the rest of
  the codebase instead of hand-rolling 'cmd.exe' + ['/c', ...].
- getSpawnArgsForWindows gains a SAFETY note: when the .cmd/.bat branch
  is taken, cmd.exe re-parses the combined command line, so callers
  must only pass trusted/literal args.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SSWSer
2026-04-26 22:29:58 -07:00
committed by GitHub
co-authored by Orca Neil
parent 3b82e5a3cb
commit cfc444242c
12 changed files with 359 additions and 16 deletions
+2
View File
@@ -36,11 +36,13 @@ package-lock.json
# IDE
.vscode/
.idea/
.serena/
*.swp
*.swo
*~
# OS
*.stackdump
.DS_Store
Thumbs.db
+1
View File
@@ -1 +1,2 @@
#!/usr/bin/env sh
pnpm exec lint-staged
+2 -1
View File
@@ -28,7 +28,7 @@
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli",
"build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli",
"postinstall": "pnpm rebuild electron && electron-builder install-app-deps",
"postinstall": "pnpm rebuild electron && node scripts/rebuild-native-deps.mjs",
"build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win",
"build:icons": "bash resources/icon-source/generate.sh",
@@ -110,6 +110,7 @@
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@electron/rebuild": "^4.0.3",
"@playwright/test": "^1.59.1",
"@stablyai/playwright-test": "^2.1.13",
"@tailwindcss/vite": "^4.2.2",
+3
View File
@@ -224,6 +224,9 @@ importers:
'@electron-toolkit/tsconfig':
specifier: ^2.0.0
version: 2.0.0(@types/node@25.5.0)
'@electron/rebuild':
specifier: ^4.0.3
version: 4.0.3
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* Why this script exists:
*
* The standard `electron-builder install-app-deps` uses @electron/rebuild
* internally but does not expose the `ignoreModules` option (as of
* electron-builder 26.x). On Windows dev machines that lack the full
* Visual C++ / Python build toolchain, `cpu-features@0.0.10` (an optional
* performance dependency of `ssh2`) fails to build with node-gyp because
* `buildcheck.gypi` is missing from the tarball. This causes the entire
* postinstall step to fail and prevents `pnpm install` from completing.
*
* This script replaces `electron-builder install-app-deps` in the postinstall
* lifecycle. It calls @electron/rebuild's JS API directly so that we can pass
* `ignoreModules: ['cpu-features']` on Windows. Skipping cpu-features is
* safe: ssh2 detects the missing native module and falls back to pure-JS CPU
* feature detection automatically.
*
* On macOS and Linux the full rebuild (including cpu-features) runs as usual.
*/
import { rebuild } from '@electron/rebuild'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const projectDir = process.cwd()
const electronVersion = JSON.parse(
readFileSync(resolve(projectDir, 'node_modules/electron/package.json'), 'utf8')
).version
const ignoreModules = process.platform === 'win32' ? ['cpu-features'] : []
if (ignoreModules.length > 0) {
console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`)
}
try {
await rebuild({
buildPath: projectDir,
electronVersion,
ignoreModules,
})
} catch (/** @type {any} */ err) {
console.error('[rebuild] Native module rebuild failed:', err?.message ?? err)
process.exit(1)
}
+23 -1
View File
@@ -10,6 +10,7 @@ import {
} from 'fs'
import { dirname, join } from 'path'
import { randomUUID } from 'crypto'
import { grantDirAcl, isPermissionError } from '../win32-utils'
export type HookCommandConfig = {
type: 'command'
@@ -84,12 +85,33 @@ export function removeManagedCommands(
export function writeManagedScript(scriptPath: string, content: string): void {
mkdirSync(dirname(scriptPath), { recursive: true })
writeFileSync(scriptPath, content, 'utf-8')
writeScriptWithAclRetry(scriptPath, content)
if (process.platform !== 'win32') {
chmodSync(scriptPath, 0o755)
}
}
// Why: on Windows, Chromium's renderer initialization can reset the DACL on
// the userData directory (Protected DACL without OI+CI propagation), leaving
// child directories like agent-hooks with an empty DACL. Grant an explicit
// directory ACL on EPERM and retry once.
function writeScriptWithAclRetry(scriptPath: string, content: string): void {
try {
writeFileSync(scriptPath, content, 'utf-8')
} catch (error) {
if (isPermissionError(error) && process.platform === 'win32') {
try {
grantDirAcl(dirname(scriptPath))
writeFileSync(scriptPath, content, 'utf-8')
return
} catch {
// icacls failure is not actionable; re-throw the original EPERM
}
}
throw error
}
}
export function writeHooksJson(configPath: string, config: HooksConfig): void {
const dir = dirname(configPath)
mkdirSync(dir, { recursive: true })
+23
View File
@@ -1,5 +1,7 @@
import { randomUUID } from 'node:crypto'
import { renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { grantDirAcl, isPermissionError } from '../win32-utils'
export function writeFileAtomically(
targetPath: string,
@@ -12,6 +14,27 @@ export function writeFileAtomically(
renameWithRetry(tmpPath, targetPath)
} catch (error) {
rmSync(tmpPath, { force: true })
// Why: on Windows, Chromium's renderer initialization calls
// SetNamedSecurityInfo on the userData folder with a Protected DACL
// that propagates empty inherited ACEs to child directories, causing
// EPERM on all writes. Grant an explicit ACL on the parent directory
// and retry once so the write succeeds even if Chromium reset the DACL
// after our startup fix ran.
if (isPermissionError(error) && process.platform === 'win32') {
try {
grantDirAcl(dirname(targetPath))
const retryTmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`
try {
writeFileSync(retryTmpPath, contents, { encoding: 'utf-8', mode: options?.mode })
renameWithRetry(retryTmpPath, targetPath)
return
} catch {
rmSync(retryTmpPath, { force: true })
}
} catch {
// icacls failure is not actionable; re-throw the original EPERM
}
}
throw error
}
}
+11 -6
View File
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn
import { join, relative, resolve, sep } from 'node:path'
import { homedir } from 'node:os'
import { app } from 'electron'
import { getSpawnArgsForWindows } from '../win32-utils'
import type {
CodexManagedAccount,
CodexManagedAccountSummary,
@@ -390,13 +391,17 @@ export class CodexAccountService {
private async runCodexLogin(managedHomePath: string): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const codexCommand = resolveCodexCommand()
const child = spawn(codexCommand, ['login'], {
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file
// (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute
// batch scripts directly without shell:true, but shell:true with an args
// array causes DEP0190 because args are concatenated, not escaped.
// Fix: detect batch scripts and invoke cmd.exe /c explicitly.
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login'])
const child = spawn(spawnCmd, spawnArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file
// (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute
// batch scripts directly — it needs cmd.exe as an intermediary. Setting
// shell: true on win32 avoids the EINVAL error this would otherwise cause.
shell: process.platform === 'win32',
// Why: route through cmd.exe for .cmd/.bat entrypoints would otherwise
// flash a console window in the packaged GUI app on Windows.
windowsHide: true,
env: {
...process.env,
CODEX_HOME: managedHomePath
+15
View File
@@ -2,6 +2,7 @@
it owns app lifecycle, service wiring, window creation, and hook/daemon
startup. Splitting by line count would fragment tightly coupled startup
logic across files without a cleaner ownership seam. */
import { grantDirAcl } from './win32-utils'
import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
import devIcon from '../../resources/icon-dev.png?asset'
@@ -138,12 +139,26 @@ function openMainWindow(): BrowserWindow {
)
}
// Why: Chromium's BrowserWindow constructor resets the userData DACL to a
// Protected DACL. Grant explicit Full Control ACEs on all existing children
// before the constructor runs so they survive the upcoming DACL reset.
// Per-write EPERM retries in fs-utils/installer-utils serve as the backstop
// for any directories created after startup.
if (process.platform === 'win32') {
try {
grantDirAcl(app.getPath('userData'), { recursive: true })
} catch {
// Non-fatal; per-call retries are the backstop.
}
}
const window = createMainWindow(store, {
getIsQuitting: () => isQuitting,
onQuitAborted: () => {
isQuitting = false
}
})
registerCoreHandlers(
store,
runtime,
+22 -8
View File
@@ -4,6 +4,7 @@ differences and ensure account-scoped env handling stays identical. */
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import { spawn } from 'node:child_process'
import { resolveCodexCommand } from '../codex-cli/command'
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
const RPC_TIMEOUT_MS = 10_000
const PTY_TIMEOUT_MS = 15_000
@@ -86,16 +87,26 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
let rpcId = 0
const codexCommand = resolveCodexCommand()
const child = spawn(codexCommand, ['-s', 'read-only', '-a', 'untrusted', 'app-server'], {
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file.
// spawn() cannot execute batch scripts directly without shell:true, but
// shell:true with an args array triggers DEP0190 (args are concatenated,
// not escaped). Fix: detect batch scripts and route through cmd.exe /c.
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, [
'-s',
'read-only',
'-a',
'untrusted',
'app-server'
])
const child = spawn(spawnCmd, spawnArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
// Why: on Windows, resolveCodexCommand() may return a .cmd/.bat file
// (e.g. codex.cmd from npm). Node's child_process.spawn cannot execute
// batch scripts directly — it needs cmd.exe as an intermediary. Setting
// shell: true on win32 avoids the EINVAL error this would otherwise cause.
shell: process.platform === 'win32',
// Why: the selected Codex rate-limit account must only affect this fetch
// subprocess. Never mutate process.env globally or other Codex features
// would inherit the managed account unintentionally.
// Why windowsHide: this fetch runs periodically in the background;
// without the flag, cmd.exe /c would flash a console window for each
// poll on Windows.
windowsHide: true,
env: {
...process.env,
...(options?.codexHomePath ? { CODEX_HOME: options.codexHomePath } : {})
@@ -296,9 +307,12 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
// those need cmd.exe as an interpreter. resolveCodexCommand() may also fall
// back to bare 'codex' when it can't locate the binary on disk, yet cmd.exe
// can still find codex.cmd via PATHEXT. Always route through cmd.exe on win32.
// Why not getSpawnArgsForWindows: the PTY path must route through cmd.exe
// even for bare 'codex' (not just .cmd/.bat) to let PATHEXT resolution
// succeed under a minimal Electron PATH. /d matches the rest of the codebase.
const isWin32 = process.platform === 'win32'
const spawnFile = isWin32 ? 'cmd.exe' : codexCommand
const spawnArgs = isWin32 ? ['/c', codexCommand] : []
const spawnFile = isWin32 ? getCmdExePath() : codexCommand
const spawnArgs = isWin32 ? ['/d', '/c', codexCommand] : []
return new Promise<ProviderRateLimits>((resolve) => {
let output = ''
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { getSpawnArgsForWindows, isPermissionError, isWindowsBatchScript } from './win32-utils'
function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return fn()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
describe('isWindowsBatchScript', () => {
it('detects .cmd and .bat on win32', () => {
withPlatform('win32', () => {
expect(isWindowsBatchScript('C:\\tools\\codex.cmd')).toBe(true)
expect(isWindowsBatchScript('C:\\tools\\codex.BAT')).toBe(true)
})
})
it('returns false for non-batch extensions', () => {
withPlatform('win32', () => {
expect(isWindowsBatchScript('C:\\tools\\codex.exe')).toBe(false)
expect(isWindowsBatchScript('C:\\tools\\codex')).toBe(false)
})
})
it('returns false on non-win32 regardless of extension', () => {
withPlatform('linux', () => {
expect(isWindowsBatchScript('/usr/bin/foo.cmd')).toBe(false)
})
})
})
describe('getSpawnArgsForWindows', () => {
it('routes .cmd through cmd.exe with /d /c on win32', () => {
const originalComSpec = process.env.ComSpec
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
try {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\codex.cmd', [
'login',
'--foo'
])
expect(spawnCmd).toBe('C:\\Windows\\System32\\cmd.exe')
// Why: /d disables AutoRun; /c runs the given command and exits. Args
// follow the batch path literally without re-escaping.
expect(spawnArgs).toEqual(['/d', '/c', 'C:\\tools\\codex.cmd', 'login', '--foo'])
})
} finally {
if (originalComSpec === undefined) {
delete process.env.ComSpec
} else {
process.env.ComSpec = originalComSpec
}
}
})
it('passes .exe through unchanged on win32', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\codex.exe', ['login'])
expect(spawnCmd).toBe('C:\\tools\\codex.exe')
expect(spawnArgs).toEqual(['login'])
})
})
it('passes posix paths through unchanged on non-win32', () => {
withPlatform('darwin', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('/usr/local/bin/codex', ['login'])
expect(spawnCmd).toBe('/usr/local/bin/codex')
expect(spawnArgs).toEqual(['login'])
})
})
})
describe('isPermissionError', () => {
it('returns true for EPERM and EACCES Node errors', () => {
const eperm = Object.assign(new Error('denied'), { code: 'EPERM' })
const eacces = Object.assign(new Error('denied'), { code: 'EACCES' })
expect(isPermissionError(eperm)).toBe(true)
expect(isPermissionError(eacces)).toBe(true)
})
it('returns false for unrelated errors and non-error values', () => {
expect(isPermissionError(Object.assign(new Error('nope'), { code: 'ENOENT' }))).toBe(false)
expect(isPermissionError(new Error('plain'))).toBe(false)
expect(isPermissionError(null)).toBe(false)
expect(isPermissionError('EPERM')).toBe(false)
})
})
+120
View File
@@ -0,0 +1,120 @@
import { execFileSync } from 'node:child_process'
/**
* Full path to icacls.exe. Electron's main process may have a stripped PATH
* that excludes System32, causing bare `icacls` to throw ENOENT.
*/
export function getIcaclsExePath(): string {
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe`
}
/**
* Full path to cmd.exe, respecting the ComSpec convention used elsewhere in
* the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts).
* Falls back to SystemRoot-based path if ComSpec is unset.
*/
export function getCmdExePath(): string {
return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe`
}
/** Whether a resolved command path points to a Windows batch script (.cmd/.bat). */
export function isWindowsBatchScript(commandPath: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
}
/** Check whether an error is a Windows permission error (EACCES or EPERM). */
export function isPermissionError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EACCES' ||
(error as NodeJS.ErrnoException).code === 'EPERM')
)
}
// Why: USERNAME-only identity resolution silently no-ops under services, CI,
// and hardened envs where USERNAME is unset. Fall back to the SID via
// `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative
// and always available on Windows. Cached because it never changes in-process.
let cachedIdentity: string | null | undefined
function resolveCurrentIdentity(): string | null {
if (cachedIdentity !== undefined) {
return cachedIdentity
}
if (process.env.USERNAME) {
cachedIdentity = process.env.USERNAME
return cachedIdentity
}
try {
const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
timeout: 5000
}).trim()
// CSV columns: "DOMAIN\\user","S-1-5-21-..."
const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output)
cachedIdentity = sidMatch ? `*${sidMatch[1]}` : null
} catch {
cachedIdentity = null
}
return cachedIdentity
}
/**
* Grant Full Control (OI)(CI)(F) on a directory for the current user.
* Used to fix Chromium's Protected DACL propagation which leaves child
* directories with Inherit-Only ACEs that deny direct file creation.
*
* Why /grant:r not /inheritance:e: Chromium's ACEs carry the Inherit-Only
* flag when propagated to children, so restoring inheritance does not grant
* the directory itself any effective permissions. An explicit ACE survives
* future DACL propagation and grants create-file rights.
*/
export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }): void {
const identity = resolveCurrentIdentity()
if (!identity) {
return
}
const args = [dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`]
if (options?.recursive) {
args.push('/T', '/C')
}
// Why: /T walks the entire subtree; a 10s cap can starve on large userData
// dirs (tens of thousands of cached chromium files), making the startup
// grant silently fail. Give recursive calls a generous budget.
const timeout = options?.recursive ? 60_000 : 10_000
execFileSync(getIcaclsExePath(), args, {
stdio: 'ignore',
windowsHide: true,
timeout
})
}
/**
* Resolve spawn parameters for a command that may be a Windows batch script.
*
* Why: Node's spawn() cannot execute .cmd/.bat files directly without
* shell:true, but shell:true with an args array triggers DEP0190 because
* args are concatenated, not escaped. Routing through cmd.exe /c explicitly
* avoids the deprecation warning while passing args correctly.
*
* Why /d: disables per-machine/user AutoRun registry commands so a background
* spawn cannot inherit surprising side effects from the user's shell config.
*
* SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the combined
* command line, so callers MUST only pass trusted/literal args. An arg
* containing `&`, `|`, `^`, `<`, `>`, or unbalanced `"` can escape the
* intended command and execute arbitrary cmd.exe syntax. Do not feed
* user-supplied strings through this helper without first validating them.
*/
export function getSpawnArgsForWindows(
command: string,
args: string[]
): { spawnCmd: string; spawnArgs: string[] } {
if (isWindowsBatchScript(command)) {
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
}
return { spawnCmd: command, spawnArgs: args }
}