mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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.
222 lines
7.0 KiB
TypeScript
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
|
|
}
|
|
)
|
|
}
|