fix(agents): detect local CLIs via well-known install dirs (#83)

A T3MP3ST server launched from Finder, the desktop app, or any non-interactive
shell inherits launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). The
local-agent detector resolved `claude`/`codex`/`hermes` against that PATH via
execFile, so an installed, authed CLI in ~/.local/bin (Claude Code's native
installer), Homebrew, npm-global, or a version-manager shim (mise/nvm/fnm) threw
ENOENT and was reported installed:false — the Settings "Connect Local Agents"
checkboxes went dead and it read like a UI bug.

Add resolveBin(): pure-fs (no shell) resolution over PATH plus a curated set of
well-known install dirs under the real agent home (agentHome(), so a HOME
redirect can't hide them), with an executable-file access check and fail-open to
the bare name — an unresolved name yields the same ENOENT → installed:false as
before, so true-absence detection is unchanged. Route the detector and both spawn
sites (runLocalAgent, localAgentChat) through it, so a CLI detected under a
minimal PATH also actually spawns. Bound the keychain auth-probe with a 2s
timeout so a locked/headless keychain (the same non-interactive launch context)
can't hang detection.

POSIX/macOS-scoped: resolveBin short-circuits on win32 — Windows resolution
(where.exe / .cmd shims) is handled by the complementary open PR #18.

Verified on macOS: RED->GREEN unit + integration tests (resolver, detector
wiring, and both spawn sites), and manual QA driving the real Web UI under a
reproduced minimal PATH (Claude Code flips from "Not installed" to detected).

Refs #78 — fixes the macOS/POSIX variant. The original report was a Windows
Sandbox environment (PR #18's domain), so this intentionally does not auto-close
that issue.
This commit is contained in:
Lyubomir Bozhinov authored and GitHub committed 2026-07-15 19:39:59 -04:00
1 parent db2d270af5
commit 5b4ac700ad
2 files changed
+292 -11

No files matched your search

@@ -0,0 +1,213 @@
/**
* REGRESSION (issue #78 — macOS/POSIX): the local-agent detector resolved the CLI binary
* against the SERVER process PATH. When T3MP3ST is launched from Finder, the desktop app, or
* any non-interactive shell, launchd hands it a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) —
* missing ~/.local/bin (Claude Code's native install), Homebrew, npm-global, and version-manager
* shims (mise/nvm/fnm). `execFile('claude', …)` then throws ENOENT and an installed, working CLI
* is reported `installed:false` (the Settings checkboxes go dead — reads like a UI bug).
*
* The fix is `resolveBin()`: pure-fs resolution over PATH + a curated set of well-known install
* dirs under the real agent home, with an executable-file check, fail-open to the bare name.
* These tests pin the resolver in isolation, the detector wiring, AND the two spawn call-sites
* (runLocalAgent / localAgentChat) the fix rewired — all through the public API.
*
* POSIX-only by design: `resolveBin` short-circuits on win32 (Windows resolution — where.exe /
* .cmd shims — is PR #18's domain, deliberately not duplicated here). The win32 case is asserted
* via a platform stub so the boundary is documented and locked.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolveBin, detectLocalAgents, runLocalAgent, localAgentChat } from '../agent/local-agents.js';
const FAKE_CLI = '#!/bin/sh\necho "1.2.3"\n';
// For the chat path, which writes the prompt to the child's stdin: drain it before replying so the
// parent's write()/end() completes cleanly (no EPIPE races), then emit a clean, non-error reply.
const FAKE_CLI_STDIN = '#!/bin/sh\ncat >/dev/null 2>&1\necho "1.2.3"\n';
const tmpDirs: string[] = [];
const savedEnv = { PATH: process.env.PATH, AGENT_HOME: process.env.T3MP3ST_AGENT_HOME };
/** A throwaway dir; tracked for afterEach cleanup. */
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), 't3mp3st-pathres-'));
tmpDirs.push(d);
return d;
}
/** Drop an executable (0o755) fake binary at `dir/name`; returns its absolute path. */
function putExe(dir: string, name: string, content: string = FAKE_CLI): string {
mkdirSync(dir, { recursive: true });
const p = join(dir, name);
writeFileSync(p, content, { mode: 0o755 });
return p;
}
afterEach(() => {
process.env.PATH = savedEnv.PATH;
if (savedEnv.AGENT_HOME === undefined) delete process.env.T3MP3ST_AGENT_HOME; else process.env.T3MP3ST_AGENT_HOME = savedEnv.AGENT_HOME;
for (const d of tmpDirs.splice(0)) { try { rmSync(d, { recursive: true, force: true }); } catch { /* noop */ } }
});
describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', () => {
it('finds a CLI in ~/.local/bin even when PATH omits it (the reported bug)', () => {
const home = scratch();
const bin = putExe(join(home, '.local', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home; // real agent home for this scan
process.env.PATH = '/usr/bin:/bin'; // minimal launchd-style PATH — omits ~/.local/bin
expect(resolveBin('faketool')).toBe(bin);
});
// A representative spread of the curated dirs (not just index-0 ~/.local/bin) so a typo or
// reorder in any entry is caught. Paths are relative to the agent home.
it.each([
['.asdf/shims'],
['.bun/bin'],
['.local/share/pnpm'],
])('resolves a CLI installed under the well-known dir %s', (rel) => {
const home = scratch();
const bin = putExe(join(home, ...rel.split('/')), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('finds a CLI under an nvm per-version bin dir (dynamic enumeration)', () => {
const home = scratch();
const bin = putExe(join(home, '.nvm', 'versions', 'node', 'v20.11.0', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('finds a CLI under an fnm per-version bin dir (dynamic enumeration)', () => {
const home = scratch();
const bin = putExe(join(home, '.local', 'share', 'fnm', 'node-versions', 'v20.11.0', 'installation', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(bin);
});
it('resolves via PATH when the CLI is on it (rich-shell launch still works)', () => {
const dir = scratch();
const bin = putExe(dir, 'faketool');
process.env.T3MP3ST_AGENT_HOME = scratch(); // empty home — force the hit to come from PATH
process.env.PATH = `/usr/bin:${dir}`;
expect(resolveBin('faketool')).toBe(bin);
});
it('prefers a PATH match over a well-known-dir match when both exist (shell precedence)', () => {
const home = scratch();
const wellKnown = putExe(join(home, '.local', 'bin'), 'faketool');
const pathDir = scratch();
const onPath = putExe(pathDir, 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = `/usr/bin:${pathDir}`; // PATH scanned before well-known dirs
expect(resolveBin('faketool')).toBe(onPath);
expect(resolveBin('faketool')).not.toBe(wellKnown);
});
it('follows a symlink to an executable (asdf/nvm shims & Claude Code\'s own install are symlinks)', () => {
const home = scratch();
const real = putExe(join(home, 'tools'), 'realtool');
const linkDir = join(home, '.local', 'bin');
mkdirSync(linkDir, { recursive: true });
const link = join(linkDir, 'faketool');
symlinkSync(real, link);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe(link); // the resolved candidate path, symlink followed for the file-check
});
it('skips a non-executable match (parity with execFile X_OK) and fails open to the bare name', () => {
const home = scratch();
const p = join(home, '.local', 'bin', 'faketool');
mkdirSync(join(home, '.local', 'bin'), { recursive: true });
writeFileSync(p, 'not executable', { mode: 0o644 }); // present but not +x
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe('faketool'); // not the non-exec path
});
it('does NOT match a directory named like the CLI (dirs carry the search bit; execvp would skip it)', () => {
// Regression guard: a bare `accessSync(X_OK)` passes for a directory, so resolveBin would return
// the dir → execFile fails EACCES (not ENOENT) → a false installed:true. Require a regular file.
const home = scratch();
mkdirSync(join(home, '.local', 'bin', 'faketool'), { recursive: true }); // a DIRECTORY, not a binary
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('faketool')).toBe('faketool'); // bare name, not the dir path
});
it('returns an already-qualified path unchanged (the bin.includes("/") guard — no re-resolution)', () => {
expect(resolveBin('/opt/custom/claude')).toBe('/opt/custom/claude');
expect(resolveBin('./rel/claude')).toBe('./rel/claude');
});
it('fails open to the bare name when nothing matches anywhere', () => {
process.env.T3MP3ST_AGENT_HOME = scratch();
process.env.PATH = '/usr/bin:/bin';
expect(resolveBin('definitely-not-a-real-cli-xyz')).toBe('definitely-not-a-real-cli-xyz');
});
it('does NOT scan on win32 — returns the bare name (Windows resolution is PR #18\'s domain)', () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'faketool');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const orig = Object.getOwnPropertyDescriptor(process, 'platform') || { value: process.platform, configurable: true };
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
expect(resolveBin('faketool')).toBe('faketool');
} finally {
Object.defineProperty(process, 'platform', orig);
}
});
});
describe('detectLocalAgents — wiring: a well-known-dir CLI is reported installed (issue #78)', () => {
it('reports Claude Code installed + versioned when `claude` lives in ~/.local/bin and PATH omits it', async () => {
const home = scratch();
const claudePath = putExe(join(home, '.local', 'bin'), 'claude');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin'; // pre-fix: execFile('claude') → ENOENT → installed:false
delete process.env.T3MP3ST_DISABLE_LOCAL_AGENTS; // insurance: a leaked disable flag would short-circuit to []
const agents = await detectLocalAgents();
const claude = agents.find((a) => a.id === 'claude');
expect(claude?.installed).toBe(true);
expect(claude?.path).toBe(claudePath);
expect(claude?.version).toBe('1.2.3'); // parsed from the resolved binary's --version output
// codex/hermes are absent from this temp home → still not installed (no false positives).
expect(agents.find((a) => a.id === 'codex')?.installed).toBe(false);
});
});
describe('spawn call-sites use the resolved path (issue #78 — detected-but-unspawnable would be worse)', () => {
// Behavioral, not a spawn spy: if the rewiring regressed to the bare `spec.bin`, spawn('claude')
// under PATH=/usr/bin:/bin throws ENOENT → ok:false / reject. Success proves the resolved path ran.
it('runLocalAgent launches the well-known-dir CLI (not the bare name)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const res = await runLocalAgent('claude', 'ping', { timeoutMs: 4000 });
expect(res.ok).toBe(true);
expect(res.output).toContain('1.2.3');
});
it('localAgentChat launches the well-known-dir CLI (not the bare name)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_STDIN);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).resolves.toContain('1.2.3');
});
});
+79 -11
View File
@@ -13,7 +13,7 @@
// =============================================================================
import { execFile, execFileSync, spawn } from 'child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { accessSync, constants, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'fs';
import { homedir, tmpdir, userInfo } from 'os';
import { join } from 'path';
@@ -182,12 +182,70 @@ export interface AgentDetection {
ready: boolean; // installed && authed
}
function resolvePath(bin: string): string | undefined {
try {
return execFileSync('command', ['-v', bin], { shell: '/bin/bash', encoding: 'utf8' }).trim() || undefined;
} catch {
try { return execFileSync('which', [bin], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; }
/**
* Curated POSIX install dirs an agent CLI commonly lands in, under the REAL agent home (issue #78).
*
* The bug: a T3MP3ST server launched from Finder / the desktop app / any non-interactive shell
* inherits launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). That omits every place a CLI
* actually installs — so `execFile('claude', …)` throws ENOENT and a working, authed CLI reads as
* `installed:false`. We recover it by ALSO scanning these dirs (they are where Claude Code's native
* installer, Homebrew, npm-global, and the version managers put their bins). Anchored to agentHome()
* — NOT os.homedir() — so a HOME redirect can't hide them (same rationale as `agentHome`).
*/
function wellKnownBinDirs(home: string): string[] {
const dirs = [
join(home, '.local', 'bin'), // Claude Code native installer, pipx, mise
join(home, '.local', 'share', 'mise', 'shims'), // mise
join(home, '.asdf', 'shims'), // asdf
'/opt/homebrew/bin', // Homebrew (Apple Silicon)
'/usr/local/bin', // Homebrew (Intel) / manual installs
join(home, '.bun', 'bin'), // bun
join(home, '.deno', 'bin'), // deno
join(home, '.volta', 'bin'), // volta
join(home, '.npm-global', 'bin'), // npm prefix override
join(home, '.yarn', 'bin'), // yarn global
join(home, 'Library', 'pnpm'), // pnpm (macOS default)
join(home, '.local', 'share', 'pnpm'), // pnpm (XDG)
'/opt/local/bin', // MacPorts
];
// Version managers keep a per-version bin dir — enumerate the installed versions so an
// npm-global CLI (e.g. codex) under the active node still resolves under a minimal PATH.
for (const nodeVersions of [join(home, '.nvm', 'versions', 'node')]) {
try { for (const v of readdirSync(nodeVersions)) dirs.push(join(nodeVersions, v, 'bin')); } catch { /* not present */ }
}
for (const fnmDir of [join(home, '.local', 'share', 'fnm', 'node-versions'),
join(home, 'Library', 'Application Support', 'fnm', 'node-versions')]) {
try { for (const v of readdirSync(fnmDir)) dirs.push(join(fnmDir, v, 'installation', 'bin')); } catch { /* not present */ }
}
return dirs;
}
/**
* Absolute path to `bin`, resolved WITHOUT a shell against PATH plus the well-known install dirs
* (issue #78). Fail-open: an unresolved name is returned unchanged so `execFile`/`spawn` still throw
* the same ENOENT (→ `installed:false`) they did before — no behavior change when a CLI is truly
* absent. Pure-fs (no shell) so it can't hang or be injected, and it uses an X_OK access check to
* match `execFile`'s own executable-file semantics (a non-executable name-collision is skipped, not
* returned). win32 is intentionally NOT handled here (where.exe / .cmd-shim resolution is PR #18's
* domain) — the bare name is returned so Node's own %PATH%/PATHEXT lookup stays in effect.
*/
export function resolveBin(bin: string): string {
if (process.platform === 'win32' || bin.includes('/')) return bin;
const path = process.env.PATH || '';
for (const dir of [...path.split(':'), ...wellKnownBinDirs(agentHome())]) {
// Absolute dirs only: skips blank PATH segments (whose POSIX "= cwd" meaning is a known
// CWD-execution vector) and relative segments (which would leak a non-absolute `path`).
if (!dir.startsWith('/')) continue;
const cand = join(dir, bin);
try {
// X_OK alone also passes for a DIRECTORY named `bin` (dirs carry the search bit); the OS's own
// execvp skips such a directory and searches on, so require a regular file to match that and
// avoid returning a dir that execFile would then reject with EACCES (a false installed:true).
accessSync(cand, constants.X_OK);
if (statSync(cand).isFile()) return cand;
} catch { /* missing, not executable, or not a regular file — keep scanning */ }
}
return bin;
}
function authState(spec: AgentSpec): { authed: boolean; method?: string } {
@@ -196,7 +254,10 @@ function authState(spec: AgentSpec): { authed: boolean; method?: string } {
}
if (spec.keychainService) {
try {
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore' });
// timeout: a headless/SSH/locked-keychain session (the same non-interactive launch context
// as #78) can make `security` block on an ACL prompt; bound it so detection can't hang the
// event loop. A timeout throws → treated as "not in keychain" (fail-open, same as absence).
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore', timeout: 2000 });
return { authed: true, method: 'keychain' };
} catch { /* not in keychain */ }
}
@@ -208,8 +269,12 @@ function detectOne(spec: AgentSpec): Promise<AgentDetection> {
id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin,
blurb: spec.blurb, invokeHint: spec.invokeHint,
};
// Resolve against PATH + well-known install dirs so a Finder/desktop launch's minimal PATH
// doesn't hide an installed CLI (issue #78). Absolute when found; the bare name otherwise
// (→ execFile throws the same ENOENT → installed:false, preserving the true-absence path).
const exe = resolveBin(spec.bin);
return new Promise((resolve) => {
execFile(spec.bin, spec.versionArgs, { timeout: 8000 }, (err, stdout) => {
execFile(exe, spec.versionArgs, { timeout: 8000 }, (err, stdout) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve({ ...base, installed: false, authed: false, ready: false });
return;
@@ -220,7 +285,7 @@ function detectOne(spec: AgentSpec): Promise<AgentDetection> {
resolve({
...base,
installed: true,
path: resolvePath(spec.bin),
path: exe !== spec.bin ? exe : undefined,
version,
authed: auth.authed,
authMethod: auth.method,
@@ -277,7 +342,9 @@ export function runLocalAgent(
const env = childEnv();
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'] });
// resolveBin: same PATH-robust resolution the detector uses, so a CLI detected under a minimal
// launchd PATH also SPAWNS (detected-but-unspawnable would be worse than undetected) — issue #78.
const child = spawn(resolveBin(spec.bin), args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
let errOut = '';
let done = false;
@@ -340,7 +407,8 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } };
return new Promise((resolve, reject) => {
const child = spawn(spec.bin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
// resolveBin: PATH-robust resolution (issue #78) — see runLocalAgent.
const child = spawn(resolveBin(spec.bin), args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
let out = '';
let errOut = '';
let done = false;