From 6ba02ec50d9a68ea0f286acb70a3f71ffa237e47 Mon Sep 17 00:00:00 2001 From: psigho Date: Sun, 5 Jul 2026 17:39:53 +0500 Subject: [PATCH] fix(local-agents): Windows CLI detection + Hermes auth path Detection reported installed npm-global agents (Claude Code, Codex) as 'binary not found on PATH' on Windows, and an authed Hermes desktop as 'NOT AUTHED'. Two root causes: 1. resolvePath() used POSIX-only 'command -v'/'which', and execFile/spawn('claude', ...) cannot resolve npm's .cmd shims via PATHEXT -> ENOENT -> installed:false. Add resolveBin() (where.exe on Windows, preferring .exe > .cmd > .bat) and route detection + both spawn call sites through the resolved path. 2. Hermes desktop on Windows stores its login under %LOCALAPPDATA%\hermes not ~/.hermes/. Add localAppData() and check both (presence-only). Windows .cmd shims are launched via a new spawnAgent() helper that invokes cmd.exe with an argv ARRAY (shell:false) instead of shell:true, so the untrusted prompt is never re-parsed by cmd.exe -> no command-injection surface. No new deps; typecheck clean. --- .gitignore | 2 + src/agent/local-agents.ts | 94 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index a4c44ab..5b6e236 100755 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ bench/nyu/ # sensitive export leak-gate denylist (operator handle + embargoed disclosure # targets + personal vault) loaded by the exporter — never commit scripts/.export-denylist.local +server.pid +server.log diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index f6de5ec..21d2eec 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -53,6 +53,16 @@ export function agentHome(): string { } const expand = (p: string): string => (p.startsWith('~') ? agentHome() + p.slice(1) : p); +/** + * Resolve a path under the OS-native per-user local app-data root, cross-platform: + * - Windows: %LOCALAPPDATA% (e.g. C:\Users\\AppData\Local), where the Hermes desktop app + * stores its runtime — its .env / auth lives at %LOCALAPPDATA%\hermes\.env, NOT ~/.hermes/.env. + * - POSIX: /AppData/Local fallback (harmless: the artifact simply won't exist there). + * PRESENCE-ONLY: callers pass the result to existsSync(); contents are never read. + */ +const localAppData = (rel: string): string => + join(process.env.LOCALAPPDATA || join(agentHome(), 'AppData', 'Local'), ...rel.split('/')); + /** * Env for a spawned agent CLI. Two adjustments to our own env: * - strip the injected provider keys so the CLI falls back to its OWN native login (not t3mp3st's). @@ -137,7 +147,10 @@ const SPECS: AgentSpec[] = [ invokeHint: 'hermes -z "" (--yolo only if T3MP3ST_HERMES_YOLO=1)', versionArgs: ['--version'], parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0], - authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json"], + // Hermes desktop on Windows stores its login under %LOCALAPPDATA%\hermes\ (NOT ~/.hermes/), so + // checking only ~/.hermes/ made an authed Windows install read as NOT AUTHED. Presence-only check; + // ~/.hermes/auth.json is upstream's POSIX/mac auth artifact, kept alongside the Windows paths. + authArtifacts: ['~/.hermes/.env', '~/.hermes/auth.json', localAppData('hermes/.env'), localAppData('hermes/auth.json')], oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])], }, ]; @@ -172,7 +185,34 @@ export interface AgentDetection { ready: boolean; // installed && authed } -function resolvePath(bin: string): string | undefined { +const isWindows = process.platform === 'win32'; +// Kept as a constructed RegExp (not a literal) so the source never embeds a raw CRLF inside a +// regex literal — that repeatedly corrupted this file when edited by line-based patch tooling. +const NEWLINE_RE = new RegExp('\\r?\\n'); + +/** + * Windows npm-global CLIs install as shell shims (e.g. `claude.cmd`), NOT bare `.exe`. Node's + * execFile/spawn only auto-resolve `.exe` via PATHEXT, so `execFile('claude', …)` throws ENOENT + * even though `claude` runs fine in a shell — which made every npm-installed agent read as + * "binary not found on PATH". resolveBin() resolves the REAL absolute file path cross-platform: + * - Windows: `where.exe` (honors PATHEXT), preferring .exe > .cmd > .bat > first hit. + * - POSIX: `command -v` then `which`. + * Detection and every spawn then use the resolved path, so a `.cmd` shim launches correctly + * (see needsShell). Returns undefined only when the bin is genuinely not on PATH. + */ +function resolveBin(bin: string): string | undefined { + if (isWindows) { + try { + const out = execFileSync('where.exe', [bin], { encoding: 'utf8', timeout: 5000 }); + const hits = out.split(NEWLINE_RE).map((h) => h.trim()).filter(Boolean); + return ( + hits.find((h) => /\.exe$/i.test(h)) ?? + hits.find((h) => /\.cmd$/i.test(h)) ?? + hits.find((h) => /\.bat$/i.test(h)) ?? + hits[0] + ); + } catch { return undefined; } + } try { return execFileSync('command', ['-v', bin], { shell: '/bin/bash', encoding: 'utf8' }).trim() || undefined; } catch { @@ -180,6 +220,34 @@ function resolvePath(bin: string): string | undefined { } } +/** + * Windows-safe launcher for a resolved agent binary. + * + * A `.cmd`/`.bat` shim can't be spawned directly (shell:false throws EINVAL/ENOEXEC). The common + * "fix" — `spawn(bin, args, { shell: true })` — is UNSAFE here: runLocalAgent puts the (untrusted) + * prompt straight into argv (claude `-p `, codex `exec … `), and shell:true would + * route that prompt through cmd.exe where `&`, `|`, `>`, `%VAR%` become metacharacters → command + * injection in a security tool. Instead we invoke cmd.exe explicitly with an ARGV ARRAY + * (`cmd.exe /d /s /c …`) and shell:false. Node hands each element to the child + * as a distinct argv entry with no shell re-parsing, so cmd.exe resolves the `.cmd` association while + * the prompt is never interpreted. Real `.exe`/POSIX binaries spawn directly (no wrapper). + */ +function spawnAgent(resolvedBin: string, args: string[], options: import('child_process').SpawnOptions): import('child_process').ChildProcess { + if (needsShell(resolvedBin)) { + return spawn('cmd.exe', ['/d', '/s', '/c', resolvedBin, ...args], { ...options, shell: false }); + } + return spawn(resolvedBin, args, { ...options, shell: false }); +} + +/** + * True when the resolved binary is a Windows `.cmd`/`.bat` shim (npm global installs land as these). + * Such a shim can't be spawned directly — it must go through cmd.exe (see spawnAgent, which does so + * SAFELY via an argv array rather than shell:true). `.exe` and POSIX binaries return false. + */ +function needsShell(resolvedBin: string): boolean { + return isWindows && /\.(cmd|bat)$/i.test(resolvedBin); +} + function authState(spec: AgentSpec): { authed: boolean; method?: string } { for (const a of spec.authArtifacts) { if (existsSync(expand(a))) return { authed: true, method: 'file' }; @@ -198,8 +266,15 @@ function detectOne(spec: AgentSpec): Promise { id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin, blurb: spec.blurb, invokeHint: spec.invokeHint, }; + const resolved = resolveBin(spec.bin); return new Promise((resolve) => { - execFile(spec.bin, spec.versionArgs, { timeout: 8000 }, (err, stdout) => { + // Not on PATH at all → genuinely not installed. + if (!resolved) { + resolve({ ...base, installed: false, authed: false, ready: false }); + return; + } + // Launch the RESOLVED path (a .cmd shim needs a shell; a real .exe does not). + execFile(resolved, spec.versionArgs, { timeout: 8000, shell: needsShell(resolved) }, (err, stdout) => { if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') { resolve({ ...base, installed: false, authed: false, ready: false }); return; @@ -210,7 +285,7 @@ function detectOne(spec: AgentSpec): Promise { resolve({ ...base, installed: true, - path: resolvePath(spec.bin), + path: resolved, version, authed: auth.authed, authMethod: auth.method, @@ -265,9 +340,12 @@ export function runLocalAgent( const maxChars = opts.maxChars ?? 4000; // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). const env = childEnv(); + // Resolve the real binary path so a Windows .cmd shim launches (fallback to bare name on POSIX). + const resolvedBin = resolveBin(spec.bin) || spec.bin; return new Promise((resolve) => { // stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait). - const child = spawn(spec.bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); + // spawnAgent() launches a Windows .cmd shim SAFELY (cmd.exe argv array, no shell re-parsing of the prompt). + const child = spawnAgent(resolvedBin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; let errOut = ''; let done = false; @@ -329,8 +407,12 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin } const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } }; + // Resolve the real binary path (Windows .cmd shim vs real .exe). hermes resolves to hermes.exe, + // so needsShell is false there — its prompt arg never traverses a shell. See needsShell note. + const resolvedBin = resolveBin(spec.bin) || spec.bin; return new Promise((resolve, reject) => { - const child = spawn(spec.bin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); + // spawnAgent() launches a Windows .cmd shim SAFELY (cmd.exe argv array, no shell re-parsing). + const child = spawnAgent(resolvedBin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); let out = ''; let errOut = ''; let done = false;