Files
orca/src/main/win32-utils.ts
T
Brennan Benson 31562c5b27 fix(windows): attach interactive login children to console input
Verified on native Windows awin at the exact PR head with Electron CDP/Playwright: the Claude sign-in console is visible, cancellation after console launch restores Add Account state, and the login process/PID/temp cleanup completes.
2026-08-24 18:12:42 -07:00

222 lines
7.0 KiB
TypeScript

import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process'
import { delimiter, join, win32 } from 'node:path'
import { existsSync } from 'node:fs'
export {
getCmdExePath,
getSpawnArgsForWindows,
wrapWindowsStartWait,
isWindowsBatchScript,
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL,
UnsafeWindowsBatchArgumentsError,
type GetSpawnArgsForWindowsOptions
} from '../shared/windows-batch-spawn'
function execFileWithoutBlocking(
command: string,
args: string[],
options: ExecFileOptionsWithStringEncoding
): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout) => {
if (error) {
reject(error)
return
}
resolve(stdout)
})
})
}
/**
* 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`
}
/** Absolute path because service-launched Electron can omit System32 from PATH. */
export function getWhoamiExePath(): string {
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe`
}
/** Absolute path because service-launched Electron can omit System32 from PATH. */
export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string {
const systemRoot = env.SystemRoot?.trim()
const root = systemRoot && /^[a-z]:[\\/]/i.test(systemRoot) ? systemRoot : 'C:\\Windows'
return win32.join(root, 'System32', 'reg.exe')
}
export function resolveWindowsCommand(
command: string,
env: NodeJS.ProcessEnv = process.env
): string {
if (process.platform !== 'win32') {
return command
}
if (/[\\/]/.test(command) || /\.[a-z0-9]+$/i.test(command)) {
return command
}
const pathEnv = env.PATH ?? env.Path
if (!pathEnv) {
return command
}
for (const directory of pathEnv.split(delimiter).filter(Boolean)) {
for (const name of [`${command}.cmd`, `${command}.exe`, `${command}.bat`, command]) {
const candidate = join(directory, name)
if (existsSync(candidate)) {
return candidate
}
}
}
return command
}
/** 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 | undefined
let pendingIdentityResolution: Promise<string | null> | null = null
function cachedOrEnvironmentIdentity(): string | undefined {
if (cachedIdentity !== undefined) {
return cachedIdentity
}
if (process.env.USERNAME) {
cachedIdentity = process.env.USERNAME
return cachedIdentity
}
return undefined
}
function identityFromWhoamiOutput(output: string): string | null {
// CSV columns: "DOMAIN\\user","S-1-5-21-..."
const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output.trim())
return sidMatch ? `*${sidMatch[1]}` : null
}
export function resolveCurrentWindowsIdentity(): string | null {
return resolveCurrentIdentity()
}
function resolveCurrentIdentity(): string | null {
const knownIdentity = cachedOrEnvironmentIdentity()
if (knownIdentity !== undefined) {
return knownIdentity
}
try {
const output = execFileSync(getWhoamiExePath(), ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
timeout: 5000
})
const resolvedIdentity = identityFromWhoamiOutput(output)
if (resolvedIdentity) {
cachedIdentity = resolvedIdentity
}
return resolvedIdentity
} catch {
return null
}
}
async function resolveCurrentIdentityAsync(): Promise<string | null> {
const knownIdentity = cachedOrEnvironmentIdentity()
if (knownIdentity !== undefined) {
return knownIdentity
}
if (!pendingIdentityResolution) {
pendingIdentityResolution = (async () => {
try {
const stdout = await execFileWithoutBlocking(
getWhoamiExePath(),
['/user', '/fo', 'csv', '/nh'],
{
encoding: 'utf-8',
windowsHide: true,
timeout: 5000
}
)
// Why: a synchronous caller may resolve identity while async whoami
// is in flight; its authoritative cached result must win the race.
const resolvedIdentity = identityFromWhoamiOutput(stdout)
if (cachedIdentity === undefined && resolvedIdentity) {
cachedIdentity = resolvedIdentity
}
return cachedIdentity ?? resolvedIdentity
} catch {
// Why: transient service/PATH failures must not permanently disable
// ACL repair for every later crash attempt in this process.
return cachedIdentity ?? null
}
})().finally(() => {
pendingIdentityResolution = null
})
}
return pendingIdentityResolution
}
/**
* 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
})
}
export async function grantDirAclAsync(dirPath: string): Promise<void> {
const identity = await resolveCurrentIdentityAsync()
if (!identity) {
return
}
// Why: crash recovery runs on Electron's main thread; an asynchronous
// icacls child keeps its worst-case timeout from freezing every window.
await execFileWithoutBlocking(
getIcaclsExePath(),
[dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`],
{
encoding: 'utf-8',
windowsHide: true,
timeout: 10_000
}
)
}