Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
166 changes: 166 additions & 0 deletions src/shell-path.js
Original file line number Diff line number Diff line change
@@ -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,
};
158 changes: 158 additions & 0 deletions test/shell-path.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
Loading