Skip to content

Commit 635fa20

Browse files
author
e2e
committed
fix(cli): avoid app-local agent shims
1 parent 2c4999b commit 635fa20

2 files changed

Lines changed: 79 additions & 3 deletions

File tree

server/modules/providers/services/external-cli-sessions.service.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { spawn } from 'node:child_process';
2-
import { readFile, realpath, stat } from 'node:fs/promises';
2+
import { constants as fsConstants } from 'node:fs';
3+
import { access, readFile, realpath, stat } from 'node:fs/promises';
34
import { homedir } from 'node:os';
4-
import { isAbsolute, join, relative, sep } from 'node:path';
5+
import { delimiter, dirname, isAbsolute, join, relative, sep } from 'node:path';
56
import { setTimeout as delay } from 'node:timers/promises';
67

78
import Database from 'better-sqlite3';
@@ -651,10 +652,56 @@ const EXTERNAL_CLI_COMMAND: Record<ExternalSpawnCli, string> = {
651652
omp: 'omp',
652653
};
653654

655+
type ExternalCliExecutableResolverOptions = {
656+
path?: string;
657+
pathExt?: string;
658+
platform?: NodeJS.Platform;
659+
isExecutable?: (candidate: string) => Promise<boolean>;
660+
};
661+
662+
export function withoutNodeModulesBins(pathValue: string): string {
663+
return pathValue
664+
.split(delimiter)
665+
.filter((entry) => entry && !(dirname(entry).endsWith(`${sep}node_modules`) && entry.endsWith(`${sep}.bin`)))
666+
.join(delimiter);
667+
}
668+
669+
/** Resolves user-installed agents without letting ChatMux's npm scripts shadow them. */
670+
export async function resolveExternalCliExecutable(
671+
cli: ExternalSpawnCli,
672+
options: ExternalCliExecutableResolverOptions = {},
673+
): Promise<string> {
674+
const command = EXTERNAL_CLI_COMMAND[cli];
675+
const platform = options.platform ?? process.platform;
676+
const searchPath = withoutNodeModulesBins(options.path ?? process.env.PATH ?? '');
677+
const extensions = platform === 'win32'
678+
? (options.pathExt ?? process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';')
679+
: [''];
680+
const isExecutable = options.isExecutable ?? (async (candidate: string) => {
681+
try {
682+
await access(candidate, fsConstants.X_OK);
683+
return (await stat(candidate)).isFile();
684+
} catch {
685+
return false;
686+
}
687+
});
688+
689+
for (const directory of searchPath.split(delimiter).filter(Boolean)) {
690+
for (const extension of extensions) {
691+
const candidate = join(directory, `${command}${extension}`);
692+
if (await isExecutable(candidate)) {
693+
return candidate;
694+
}
695+
}
696+
}
697+
return command;
698+
}
699+
654700
/** Boots and tags a native CLI in a fresh detached tmux session. */
655701
export async function spawnExternalCliSession(cli: ExternalSpawnCli, tmuxName: string, cwd: string): Promise<void> {
702+
const executable = await resolveExternalCliExecutable(cli);
656703
await runCommand('tmux', [
657-
'new-session', '-d', '-s', tmuxName, '-c', cwd, EXTERNAL_CLI_COMMAND[cli],
704+
'new-session', '-d', '-s', tmuxName, '-c', cwd, executable,
658705
]);
659706
try {
660707
await runCommand('tmux', ['set-option', '-t', tmuxName, '@chatmux_cli_kind', cli]);

server/modules/providers/tests/external-cli-sessions.service.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,37 @@ import {
1111
parseClaudeRuntimeSession,
1212
parseExternalPanes,
1313
parsePsTree,
14+
resolveExternalCliExecutable,
15+
withoutNodeModulesBins,
1416
} from '@/modules/providers/services/external-cli-sessions.service.js';
1517

18+
test('external CLI resolution excludes app-local npm shims', async () => {
19+
const searchPath = [
20+
'/app/node_modules/.bin',
21+
'/Users/test/.local/bin',
22+
'/opt/homebrew/bin',
23+
].join(':');
24+
assert.equal(
25+
withoutNodeModulesBins(searchPath),
26+
['/Users/test/.local/bin', '/opt/homebrew/bin'].join(':'),
27+
);
28+
29+
const checked: string[] = [];
30+
const resolved = await resolveExternalCliExecutable('codex', {
31+
path: searchPath,
32+
platform: 'darwin',
33+
isExecutable: async (candidate) => {
34+
checked.push(candidate);
35+
return candidate === '/opt/homebrew/bin/codex';
36+
},
37+
});
38+
39+
assert.equal(resolved, '/opt/homebrew/bin/codex');
40+
assert.deepEqual(checked, [
41+
'/Users/test/.local/bin/codex',
42+
'/opt/homebrew/bin/codex',
43+
]);
44+
});
1645
test('parseExternalPanes splits session_name<TAB>pane_pid<TAB>pane_current_command', () => {
1746
const out = parseExternalPanes('patina\t113501\tclaude\ntest\t360992\tnode\n\nbad-line\n');
1847
assert.deepEqual(out, [

0 commit comments

Comments
 (0)