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:
@@ -36,11 +36,13 @@ package-lock.json
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.serena/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
*.stackdump
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
pnpm exec lint-staged
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user