diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index a7b9f8ce..1a521042 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -1,7 +1,8 @@ import { spawn } from 'node:child_process'; -import { readFile, realpath, stat } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { access, readFile, realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { isAbsolute, join, relative, sep } from 'node:path'; +import { delimiter, dirname, isAbsolute, join, relative, sep } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import Database from 'better-sqlite3'; @@ -667,10 +668,56 @@ const EXTERNAL_CLI_COMMAND: Record = { omp: 'omp', }; +type ExternalCliExecutableResolverOptions = { + path?: string; + pathExt?: string; + platform?: NodeJS.Platform; + isExecutable?: (candidate: string) => Promise; +}; + +export function withoutNodeModulesBins(pathValue: string): string { + return pathValue + .split(delimiter) + .filter((entry) => entry && !(dirname(entry).endsWith(`${sep}node_modules`) && entry.endsWith(`${sep}.bin`))) + .join(delimiter); +} + +/** Resolves user-installed agents without letting ChatMux's npm scripts shadow them. */ +export async function resolveExternalCliExecutable( + cli: ExternalSpawnCli, + options: ExternalCliExecutableResolverOptions = {}, +): Promise { + const command = EXTERNAL_CLI_COMMAND[cli]; + const platform = options.platform ?? process.platform; + const searchPath = withoutNodeModulesBins(options.path ?? process.env.PATH ?? ''); + const extensions = platform === 'win32' + ? (options.pathExt ?? process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';') + : ['']; + const isExecutable = options.isExecutable ?? (async (candidate: string) => { + try { + await access(candidate, fsConstants.X_OK); + return (await stat(candidate)).isFile(); + } catch { + return false; + } + }); + + for (const directory of searchPath.split(delimiter).filter(Boolean)) { + for (const extension of extensions) { + const candidate = join(directory, `${command}${extension}`); + if (await isExecutable(candidate)) { + return candidate; + } + } + } + return command; +} + /** Boots and tags a native CLI in a fresh detached tmux session. */ export async function spawnExternalCliSession(cli: ExternalSpawnCli, tmuxName: string, cwd: string): Promise { + const executable = await resolveExternalCliExecutable(cli); await runCommand('tmux', [ - 'new-session', '-d', '-s', tmuxName, '-c', cwd, EXTERNAL_CLI_COMMAND[cli], + 'new-session', '-d', '-s', tmuxName, '-c', cwd, executable, ]); try { await runCommand('tmux', ['set-option', '-t', tmuxName, '@chatmux_cli_kind', cli]); diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts index 24b476fa..2b4a1577 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -12,15 +12,43 @@ import { parseClaudeRuntimeSession, parseExternalPanes, parsePsTree, + resolveExternalCliExecutable, + withoutNodeModulesBins, } from '@/modules/providers/services/external-cli-sessions.service.js'; +test('external CLI resolution excludes app-local npm shims', async () => { + const searchPath = [ + '/app/node_modules/.bin', + '/Users/test/.local/bin', + '/opt/homebrew/bin', + ].join(':'); + assert.equal( + withoutNodeModulesBins(searchPath), + ['/Users/test/.local/bin', '/opt/homebrew/bin'].join(':'), + ); + + const checked: string[] = []; + const resolved = await resolveExternalCliExecutable('codex', { + path: searchPath, + platform: 'darwin', + isExecutable: async (candidate) => { + checked.push(candidate); + return candidate === '/opt/homebrew/bin/codex'; + }, + }); + + assert.equal(resolved, '/opt/homebrew/bin/codex'); + assert.deepEqual(checked, [ + '/Users/test/.local/bin/codex', + '/opt/homebrew/bin/codex', + ]); +}); test('normalizeExternalPaneOutput removes control bytes and bounds the pane tail', () => { assert.equal( normalizeExternalPaneOutput('old\r\n\u0000Trust this folder?\u0007\n1. Yes\n', 24), 'Trust this folder?\n1. Yes'.slice(-24), ); }); - test('parseExternalPanes splits session_namepane_pidpane_current_command', () => { const out = parseExternalPanes('patina\t113501\tclaude\ntest\t360992\tnode\n\nbad-line\n'); assert.deepEqual(out, [