From 22fbf40152993d9cebd04b1f8d86005e8fecf123 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Tue, 21 Jul 2026 12:02:38 +0300 Subject: [PATCH 1/2] fix: detect agents when launched from GUI with stripped PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS/Linux GUI launches (Finder/Dock/Spotlight) inherit a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) that omits user bin dirs such as ~/.local/bin and ~/.npm-global/bin where CLIs like claude and codex are installed. agents-detect.js resolves those agents purely via which() on process.env.PATH, so a GUI-launched app detected only agents shipping as a .app bundle (Cursor) and missed Claude Code / Codex — even though their history is present and the terminal-launched CLI detects them fine. Add src/shell-path.js: on startup, when PATH lacks any user-home dir, probe the login shell (SHELL -ilc) for its PATH and merge in the missing dirs. No-op (no shell spawn) when already launched from a terminal. Fixes both detection and in-process agent launching, generically for all agents. --- bin/cli.js | 12 ++++++ src/shell-path.js | 99 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/shell-path.js diff --git a/bin/cli.js b/bin/cli.js index fdfdf49..3267749 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -45,6 +45,18 @@ const { exportArchive, importArchive } = require('../src/migrate'); const { convertSession } = require('../src/convert'); const { generateHandoff, quickHandoff } = require('../src/handoff'); const { cloudCLI } = require('../src/cloud'); +const { augmentPathFromLoginShell } = require('../src/shell-path'); + +// GUI launches (Finder/Dock/Spotlight) inherit a stripped PATH that omits user +// bin dirs where `claude`/`codex` live, which breaks PATH-based agent detection. +// Repair it from the login shell before any command runs. No-op (no shell +// spawn) when launched from a terminal that already has the full PATH. +try { + const added = augmentPathFromLoginShell(); + if (added.length) { + console.error(`[codbash] PATH augmented from login shell (+${added.length} dir${added.length === 1 ? '' : 's'})`); + } +} catch (_) { /* keep existing PATH */ } const DEFAULT_PORT = 3847; const DEFAULT_HOST = 'localhost'; diff --git a/src/shell-path.js b/src/shell-path.js new file mode 100644 index 0000000..e6d7763 --- /dev/null +++ b/src/shell-path.js @@ -0,0 +1,99 @@ +// Repair PATH for GUI launches on macOS/Linux. +// +// When codbash.app is launched from Finder/Dock/Spotlight, macOS gives the +// process a minimal PATH (typically `/usr/bin:/bin:/usr/sbin:/sbin`) that does +// NOT include user bin directories such as `~/.local/bin`, `~/.npm-global/bin`, +// nvm/fnm shims, or Homebrew. CLIs like `claude` and `codex` are usually +// installed there, so PATH-based agent detection (see agents-detect.js) misses +// them — even though they are installed and their history is present. Agents +// that ship as a `.app` bundle (Cursor) still detect, which is why a GUI launch +// often shows only Cursor while the terminal-launched CLI shows everything. +// +// We repair `process.env.PATH` once at startup by asking the user's login shell +// for its PATH — mirroring exactly what a terminal-launched process would see. +// When already launched from a terminal (PATH already contains user dirs) this +// is a cheap no-op: we never spawn a shell. + +const { execFileSync } = require('child_process'); +const os = require('os'); +const path = require('path'); + +let _done = false; + +// A login-shell PATH almost always contains at least one directory under the +// user's home (nvm, ~/.local/bin, ~/.npm-global/bin, ...). The Finder-stripped +// GUI PATH never does. This is a more robust signal than matching the exact +// four-entry minimal PATH, which can vary slightly between macOS versions. +function hasUserPaths(p, home) { + const h = home || os.homedir(); + return (p || '') + .split(path.delimiter) + .some(d => d && d.startsWith(h + path.sep)); +} + +// Control characters we refuse to accept as part of a PATH entry. rc files on +// an interactive login shell can emit banners / shell-integration escape +// sequences; the sentinels below isolate the PATH, and this guard drops any +// stray entry that still carries control bytes. +const CONTROL_CHARS = /[\r\n\x00-\x08\x0b\x0c\x0e-\x1f]/; + +function captureLoginShellPath() { + const shell = process.env.SHELL || '/bin/zsh'; + // Sentinels (SOH-delimited) let us extract PATH cleanly even if rc files + // print output on startup (e.g. iTerm/VS Code shell integration). + const script = 'printf "\\1CBP\\1%s\\1CBE\\1" "$PATH"'; + // -i -l -c loads the user's login + interactive profile so PATH matches a + // real terminal session. Time-boxed so a slow/hanging rc file cannot wedge + // startup; stderr is discarded so banners never reach us. + const raw = execFileSync(shell, ['-ilc', script], { + encoding: 'utf8', + timeout: 4000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const m = /\x01CBP\x01([\s\S]*?)\x01CBE\x01/.exec(raw); + return m ? m[1] : ''; +} + +// Merge the login-shell PATH into process.env.PATH. Existing entries keep +// priority; only previously-absent, control-char-free directories are appended. +// Idempotent and safe to call unconditionally at startup. Returns the list of +// directories that were added (empty when skipped or nothing new). +function augmentPathFromLoginShell(opts) { + const o = opts || {}; + if (_done && !o.force) return []; + _done = true; + + const platform = o.platform || process.platform; + if (platform === 'win32') return []; // Windows GUI PATH is not stripped this way + + const home = o.home || os.homedir(); + const currentPath = o.path != null ? o.path : (process.env.PATH || ''); + + // Already a terminal-style PATH → nothing to repair, and no shell spawn. + if (hasUserPaths(currentPath, home)) return []; + + let captured = ''; + try { + captured = o.capture ? o.capture() : captureLoginShellPath(); + } catch (_) { + return []; // keep the existing PATH if the shell probe fails + } + + const have = new Set(currentPath.split(path.delimiter).filter(Boolean)); + const added = captured + .split(path.delimiter) + .map(d => d.trim()) + .filter(d => d && !have.has(d) && !CONTROL_CHARS.test(d)); + + if (added.length) { + const next = [...have, ...added].join(path.delimiter); + if (o.path == null) process.env.PATH = next; + } + return added; +} + +module.exports = { + augmentPathFromLoginShell, + hasUserPaths, + captureLoginShellPath, +}; From 77b9ce20fe506c61d28375a227e406f18988b5b0 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Tue, 21 Jul 2026 12:47:20 +0300 Subject: [PATCH 2/2] fix: address review findings for GUI PATH repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review + security review of the shell-path module: - HIGH: add test/shell-path.test.js (15 cases) — gate logic, merge/dedup, control-char filtering, idempotency, win32 skip, opt-out, capture failure. - MEDIUM: pass killSignal:SIGKILL so a shell ignoring SIGTERM can't outlive the 4s timeout and hang startup. - MEDIUM: add CODBASH_NO_PATH_REPAIR=1 opt-out (login shell / rc files no longer run unconditionally with no escape hatch). - MEDIUM: log login-shell probe failures (was silently swallowed) and the bin/cli.js catch now logs, matching the repo-refresh convention. - MEDIUM: replace the startsWith($HOME) gate with hasUserBinPaths — a lone unrelated ~/Library entry no longer false-negatives and suppresses repair. - LOW: block TAB (\x09) and DEL (\x7f) too (full \x00-\x1f,\x7f), keeping non-ASCII/Unicode dirs valid. - LOW: unbundle -ilc into -i -l -c for shell-getopt portability. - LOW: validate $SHELL is absolute before exec; document the zsh fallback. - LOW: trim existing PATH entries before dedup to avoid whitespace dupes. - LOW: document the intentional append-after-system precedence + assert it in a test; note case-insensitive-FS dupes are an accepted no-op. - CODBASH_DEBUG=1 logs skip/augment decisions for field diagnosis. --- bin/cli.js | 4 +- src/shell-path.js | 133 ++++++++++++++++++++++++--------- test/shell-path.test.js | 158 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 34 deletions(-) create mode 100644 test/shell-path.test.js diff --git a/bin/cli.js b/bin/cli.js index 3267749..99c8971 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -56,7 +56,9 @@ try { if (added.length) { console.error(`[codbash] PATH augmented from login shell (+${added.length} dir${added.length === 1 ? '' : 's'})`); } -} catch (_) { /* keep existing PATH */ } +} catch (err) { + console.error('[codbash] PATH augmentation skipped:', err && err.message ? err.message : err); +} const DEFAULT_PORT = 3847; const DEFAULT_HOST = 'localhost'; diff --git a/src/shell-path.js b/src/shell-path.js index e6d7763..1875704 100644 --- a/src/shell-path.js +++ b/src/shell-path.js @@ -11,8 +11,11 @@ // // We repair `process.env.PATH` once at startup by asking the user's login shell // for its PATH — mirroring exactly what a terminal-launched process would see. -// When already launched from a terminal (PATH already contains user dirs) this -// is a cheap no-op: we never spawn a shell. +// When already launched from a terminal (PATH already contains user bin dirs) +// this is a cheap no-op: we never spawn a shell. +// +// Opt out entirely with `CODBASH_NO_PATH_REPAIR=1`. Set `CODBASH_DEBUG=1` to +// log the skip/augment decisions to stderr. const { execFileSync } = require('child_process'); const os = require('os'); @@ -20,49 +23,100 @@ const path = require('path'); let _done = false; -// A login-shell PATH almost always contains at least one directory under the -// user's home (nvm, ~/.local/bin, ~/.npm-global/bin, ...). The Finder-stripped -// GUI PATH never does. This is a more robust signal than matching the exact -// four-entry minimal PATH, which can vary slightly between macOS versions. -function hasUserPaths(p, home) { - const h = home || os.homedir(); - return (p || '') - .split(path.delimiter) - .some(d => d && d.startsWith(h + path.sep)); +function debugEnabled() { + return process.env.CODBASH_DEBUG === '1' || process.env.CODBASH_DEBUG === 'true'; +} +function debug(msg) { + if (debugEnabled()) { + try { console.error('[codbash] ' + msg); } catch (_) { /* stderr closed */ } + } } -// Control characters we refuse to accept as part of a PATH entry. rc files on -// an interactive login shell can emit banners / shell-integration escape -// sequences; the sentinels below isolate the PATH, and this guard drops any -// stray entry that still carries control bytes. -const CONTROL_CHARS = /[\r\n\x00-\x08\x0b\x0c\x0e-\x1f]/; +// Reject any PATH entry containing a C0/C1 control character, TAB, or DEL. A +// blocklist (rather than a printable-ASCII allowlist) is used deliberately so +// legitimate non-ASCII directory names — e.g. a Unicode home directory — are +// preserved instead of being silently dropped. +const CONTROL_CHARS = /[\x00-\x1f\x7f]/; + +// Does PATH already look like a real terminal session? We treat it as "good" +// only when it contains a bin/shims directory *under the user's home* — the +// kind of entry (`~/.local/bin`, `~/.npm-global/bin`, nvm/fnm shims) that agent +// detection depends on. A lone unrelated home path (e.g. a LaunchServices +// `~/Library/...` entry that some GUI launchers prepend) must NOT suppress the +// repair, so a bare `startsWith($HOME)` test is deliberately avoided — it would +// false-negative and leave detection broken with no shell probe ever running. +function hasUserBinPaths(p, home) { + const h = home || os.homedir(); + const prefix = h + path.sep; + return (p || '').split(path.delimiter).some(d => { + if (!d || !d.startsWith(prefix)) return false; + return d.endsWith(path.sep + 'bin') + || d.endsWith(path.sep + 'sbin') + || d.includes(path.sep + 'shims' + path.sep) || d.endsWith(path.sep + 'shims') + || d.includes(path.sep + '.nvm' + path.sep) + || d.includes(path.sep + '.fnm' + path.sep); + }); +} function captureLoginShellPath() { - const shell = process.env.SHELL || '/bin/zsh'; - // Sentinels (SOH-delimited) let us extract PATH cleanly even if rc files - // print output on startup (e.g. iTerm/VS Code shell integration). + let shell = process.env.SHELL || ''; + // $SHELL is attacker-controllable only by someone who already has env-setting + // (= code-execution) capability in this process, so this is a robustness + // guard, not a trust boundary: reject an empty/relative value and fall back + // to the macOS default login shell (zsh since Catalina; this feature is + // macOS-focused — cf. terminal.js which defaults to bash for its own path). + if (!shell || !path.isAbsolute(shell)) shell = '/bin/zsh'; + + // Sentinels (SOH-delimited) let us pull PATH out of stdout even when rc files + // print banners / shell-integration escape sequences on startup. NOTE: this + // guards against *accidental* banner noise only — it is NOT a trust boundary + // against a malicious rc file, which already controls $PATH directly. const script = 'printf "\\1CBP\\1%s\\1CBE\\1" "$PATH"'; - // -i -l -c loads the user's login + interactive profile so PATH matches a - // real terminal session. Time-boxed so a slow/hanging rc file cannot wedge - // startup; stderr is discarded so banners never reach us. - const raw = execFileSync(shell, ['-ilc', script], { - encoding: 'utf8', - timeout: 4000, - stdio: ['ignore', 'pipe', 'ignore'], - }); + + // Separate flags (not a bundled `-ilc`) for portability across shells with + // stricter getopt parsing. `-i -l` sources both login (.zprofile/.zlogin) and + // interactive (.zshrc/.bashrc) config, so PATH matches a real terminal no + // matter which file the user's exports live in. Cost: this runs the user's rc + // chain once per app launch — an accepted tradeoff for correct detection. + // killSignal is SIGKILL because a shell/rc that traps or ignores SIGTERM + // could otherwise outlive the timeout; SIGKILL is still best-effort (a + // process blocked in an uninterruptible syscall cannot be reaped until it + // unblocks), so the 4s bound is a strong guideline, not a hard guarantee. + let raw; + try { + raw = execFileSync(shell, ['-i', '-l', '-c', script], { + encoding: 'utf8', + timeout: 4000, + killSignal: 'SIGKILL', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch (err) { + // Surface the failure (rare: bad $SHELL, hung rc file, sentinel timeout) so + // a field report of "detection still broken" is diagnosable, matching the + // repo-refresh startup logging convention in bin/cli.js. + console.error('[codbash] PATH repair: login-shell probe failed: ' + (err && err.message ? err.message : String(err))); + return ''; + } const m = /\x01CBP\x01([\s\S]*?)\x01CBE\x01/.exec(raw); return m ? m[1] : ''; } // Merge the login-shell PATH into process.env.PATH. Existing entries keep // priority; only previously-absent, control-char-free directories are appended. -// Idempotent and safe to call unconditionally at startup. Returns the list of -// directories that were added (empty when skipped or nothing new). +// Idempotent (guarded by `_done`; pass `{ force: true }` to re-run). Safe to +// call unconditionally at startup. Returns the list of directories that were +// added (empty when skipped or nothing new). function augmentPathFromLoginShell(opts) { const o = opts || {}; if (_done && !o.force) return []; _done = true; + // Explicit user opt-out — skips the login-shell spawn entirely. + if (process.env.CODBASH_NO_PATH_REPAIR === '1') { + debug('PATH repair disabled via CODBASH_NO_PATH_REPAIR'); + return []; + } + const platform = o.platform || process.platform; if (platform === 'win32') return []; // Windows GUI PATH is not stripped this way @@ -70,30 +124,43 @@ function augmentPathFromLoginShell(opts) { const currentPath = o.path != null ? o.path : (process.env.PATH || ''); // Already a terminal-style PATH → nothing to repair, and no shell spawn. - if (hasUserPaths(currentPath, home)) return []; + if (hasUserBinPaths(currentPath, home)) { + debug('PATH already contains user bin dirs — skipping login-shell probe'); + return []; + } let captured = ''; try { captured = o.capture ? o.capture() : captureLoginShellPath(); } catch (_) { - return []; // keep the existing PATH if the shell probe fails + return []; // fail-safe: keep the existing PATH if the probe throws } - const have = new Set(currentPath.split(path.delimiter).filter(Boolean)); + // Trim BOTH sides so a stray-whitespace entry already present in PATH is not + // re-added as a spurious "new" duplicate. + const have = new Set(currentPath.split(path.delimiter).map(d => d.trim()).filter(Boolean)); const added = captured .split(path.delimiter) .map(d => d.trim()) .filter(d => d && !have.has(d) && !CONTROL_CHARS.test(d)); + // Precedence is intentional: existing (system) entries stay FIRST and the + // login-shell dirs are appended AFTER — the opposite of a normal terminal + // (where user dirs usually win). Chosen so a directory discovered from the + // login shell can never shadow a base system binary; detection only needs the + // dir to be *present* on PATH, not first. (Duplicate casings on a + // case-insensitive filesystem are not normalized — accepted: unlikely and + // harmless, at worst a redundant PATH entry.) if (added.length) { const next = [...have, ...added].join(path.delimiter); if (o.path == null) process.env.PATH = next; + debug('PATH augmented with: ' + added.join(', ')); } return added; } module.exports = { augmentPathFromLoginShell, - hasUserPaths, + hasUserBinPaths, captureLoginShellPath, }; diff --git a/test/shell-path.test.js b/test/shell-path.test.js new file mode 100644 index 0000000..3755777 --- /dev/null +++ b/test/shell-path.test.js @@ -0,0 +1,158 @@ +// Tests for src/shell-path.js — login-shell PATH repair for GUI launches. +// +// Strategy: augmentPathFromLoginShell() takes an injected context +// ({ platform, home, path, capture, force }) so we can drive every branch +// without spawning a real shell or touching process.env — mirroring the DI +// approach used in agents-detect.test.js. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +function loadFresh() { + delete require.cache[require.resolve('../src/shell-path')]; + return require('../src/shell-path'); +} + +const HOME = '/Users/test'; +const STRIPPED = '/usr/bin:/bin:/usr/sbin:/sbin'; +const LOGIN = '/Users/test/.local/bin:/Users/test/.npm-global/bin:/opt/homebrew/bin:/usr/bin:/bin'; + +// Base options for a GUI-stripped darwin launch. Individual tests override. +function base(over) { + return Object.assign({ force: true, platform: 'darwin', home: HOME, path: STRIPPED }, over); +} + +test('hasUserBinPaths: false for a stripped GUI PATH', () => { + const { hasUserBinPaths } = loadFresh(); + assert.equal(hasUserBinPaths(STRIPPED, HOME), false); +}); + +test('hasUserBinPaths: true when a bin dir under $HOME is present', () => { + const { hasUserBinPaths } = loadFresh(); + assert.equal(hasUserBinPaths(LOGIN, HOME), true); +}); + +test('hasUserBinPaths: a lone non-bin home dir does NOT count (avoids false-negative skip)', () => { + const { hasUserBinPaths } = loadFresh(); + const p = '/Users/test/Library/Caches/foo:/usr/bin:/bin'; + assert.equal(hasUserBinPaths(p, HOME), false); +}); + +test('hasUserBinPaths: nvm/fnm shim dirs count', () => { + const { hasUserBinPaths } = loadFresh(); + assert.equal(hasUserBinPaths('/Users/test/.nvm/versions/node/v20/bin:/usr/bin', HOME), true); + assert.equal(hasUserBinPaths('/Users/test/.fnm/aliases/default/bin:/usr/bin', HOME), true); +}); + +test('augment: stripped PATH gets login dirs appended, existing entries kept first', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const added = augmentPathFromLoginShell(base({ capture: () => LOGIN })); + assert.deepEqual(added, [ + '/Users/test/.local/bin', + '/Users/test/.npm-global/bin', + '/opt/homebrew/bin', + ]); +}); + +test('augment: precedence — system dirs stay first, new dirs appended after (not prepended)', () => { + const { augmentPathFromLoginShell } = loadFresh(); + let written = null; + // Use the process.env path branch by omitting `path`, but capture the result + // via the return value + reconstruct expected ordering explicitly instead. + const added = augmentPathFromLoginShell(base({ capture: () => LOGIN })); + // The merged order is: existing (trimmed) entries, then added. + const expectedMerged = STRIPPED.split(':').concat(added).join(':'); + assert.equal( + expectedMerged, + '/usr/bin:/bin:/usr/sbin:/sbin:/Users/test/.local/bin:/Users/test/.npm-global/bin:/opt/homebrew/bin' + ); +}); + +test('augment: no-op (capture never called) when PATH already has user bin dirs', () => { + const { augmentPathFromLoginShell } = loadFresh(); + let called = false; + const added = augmentPathFromLoginShell(base({ + path: LOGIN, + capture: () => { called = true; return LOGIN; }, + })); + assert.deepEqual(added, []); + assert.equal(called, false, 'capture must not be invoked when PATH is already good'); +}); + +test('augment: control-char entries (incl. TAB and DEL) are filtered out', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const added = augmentPathFromLoginShell(base({ + capture: () => '/Users/test/.local/bin:/ta\tb/dir:/del\x7fdir:/ctrl\x01dir', + })); + assert.deepEqual(added, ['/Users/test/.local/bin']); +}); + +test('augment: does not re-add a dir already present, even with surrounding whitespace', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const added = augmentPathFromLoginShell(base({ + path: ' /usr/bin : /bin ', + capture: () => '/usr/bin:/Users/test/.local/bin', + })); + assert.deepEqual(added, ['/Users/test/.local/bin'], 'trimmed existing entry should dedupe'); +}); + +test('augment: win32 short-circuits without calling capture', () => { + const { augmentPathFromLoginShell } = loadFresh(); + let called = false; + const added = augmentPathFromLoginShell(base({ + platform: 'win32', + capture: () => { called = true; return LOGIN; }, + })); + assert.deepEqual(added, []); + assert.equal(called, false); +}); + +test('augment: CODBASH_NO_PATH_REPAIR=1 opts out without calling capture', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const prev = process.env.CODBASH_NO_PATH_REPAIR; + process.env.CODBASH_NO_PATH_REPAIR = '1'; + try { + let called = false; + const added = augmentPathFromLoginShell(base({ capture: () => { called = true; return LOGIN; } })); + assert.deepEqual(added, []); + assert.equal(called, false); + } finally { + if (prev === undefined) delete process.env.CODBASH_NO_PATH_REPAIR; + else process.env.CODBASH_NO_PATH_REPAIR = prev; + } +}); + +test('augment: a throwing capture is swallowed and returns []', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const added = augmentPathFromLoginShell(base({ + capture: () => { throw new Error('boom'); }, + })); + assert.deepEqual(added, []); +}); + +test('augment: empty capture (no sentinel match) returns []', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const added = augmentPathFromLoginShell(base({ capture: () => '' })); + assert.deepEqual(added, []); +}); + +test('augment: idempotent — second call without force is a no-op even after state change', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const first = augmentPathFromLoginShell({ platform: 'darwin', home: HOME, path: STRIPPED, capture: () => LOGIN }); + assert.ok(first.length > 0, 'first call augments'); + const second = augmentPathFromLoginShell({ platform: 'darwin', home: HOME, path: STRIPPED, capture: () => LOGIN }); + assert.deepEqual(second, [], 'second call without force is guarded by _done'); +}); + +test('augment: mutates process.env.PATH when no explicit path is injected', () => { + const { augmentPathFromLoginShell } = loadFresh(); + const prev = process.env.PATH; + process.env.PATH = STRIPPED; + try { + const added = augmentPathFromLoginShell({ force: true, platform: 'darwin', home: HOME, capture: () => LOGIN }); + assert.ok(added.includes('/Users/test/.local/bin')); + assert.ok(process.env.PATH.includes('/Users/test/.local/bin')); + assert.ok(process.env.PATH.startsWith('/usr/bin:/bin'), 'system dirs remain first'); + } finally { + process.env.PATH = prev; + } +});