diff --git a/bin/cli.js b/bin/cli.js index fdfdf49..99c8971 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -45,6 +45,20 @@ 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 (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 new file mode 100644 index 0000000..1875704 --- /dev/null +++ b/src/shell-path.js @@ -0,0 +1,166 @@ +// 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 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'); +const path = require('path'); + +let _done = false; + +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 */ } + } +} + +// 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() { + 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"'; + + // 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 (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 + + 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 (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 []; // fail-safe: keep the existing PATH if the probe throws + } + + // 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, + 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; + } +});