Skip to content
Draft
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
38 changes: 35 additions & 3 deletions src/__tests__/main/agents/detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,38 @@ describe('agent-detector', () => {
expect(agents.find((a) => a.id === 'codex')?.available).toBe(false);
});

it('prefers the Codex multi-auth wrapper while retaining plain Codex as a candidate', async () => {
mockExecFileNoThrow.mockImplementation(async (_cmd, args) => {
const binaryName = args[0];
if (binaryName === 'bash') {
return { stdout: '/bin/bash\n', stderr: '', exitCode: 0 };
}
if (binaryName === 'codex-multi-auth-codex') {
return {
stdout: '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex\n',
stderr: '',
exitCode: 0,
};
}
if (binaryName === 'codex') {
return { stdout: '/opt/homebrew/bin/codex\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: 'not found', exitCode: 1 };
});

const agents = await detector.detectAgents();
const codexAgent = agents.find((a) => a.id === 'codex');

expect(codexAgent?.available).toBe(true);
expect(codexAgent?.path).toBe(
'/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex'
);
expect(codexAgent?.pathCandidates).toEqual([
'/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex',
'/opt/homebrew/bin/codex',
]);
});

it('should use deduplication for parallel calls', async () => {
let callCount = 0;
mockExecFileNoThrow.mockImplementation(async () => {
Expand Down Expand Up @@ -1457,8 +1489,8 @@ describe('agent-detector', () => {
vi.doMock('../../../main/agents/path-prober', () => ({
getExpandedEnv: () => ({ PATH: 'C:\\Tools' }),
checkCustomPath: vi.fn(async () => ({ exists: false })),
checkBinaryExists: vi.fn(async (binaryName: string) =>
binaryName === 'opencode' ? { exists: true } : { exists: false }
findBinaryCandidates: vi.fn(async (binaryName: string) =>
binaryName === 'opencode' ? ['opencode'] : []
),
}));
vi.doMock('../../../main/utils/execFile', () => ({
Expand Down Expand Up @@ -1501,7 +1533,7 @@ describe('agent-detector', () => {
expect(details?.agents).toContainEqual(
expect.objectContaining({
id: 'opencode',
pathExtension: 'none',
pathExtension: '',
willUseShell: true,
})
);
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/main/agents/path-prober.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getExpandedEnv,
checkCustomPath,
checkBinaryExists,
findBinaryCandidates,
probeWindowsPaths,
probeUnixPaths,
type BinaryDetectionResult,
Expand Down Expand Up @@ -367,6 +368,46 @@ describe('path-prober', () => {
});
});

describe('findBinaryCandidates', () => {
let accessMock: ReturnType<typeof vi.spyOn>;
const execMock = vi.mocked(execFileNoThrow);

beforeEach(() => {
accessMock = vi.spyOn(fs.promises, 'access');
});

afterEach(() => {
accessMock.mockRestore();
});

it('returns multiple Unix candidates in detection priority order', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });

try {
accessMock.mockImplementation(async (probePath) => {
const candidate = String(probePath);
if (candidate === '/opt/homebrew/bin/codex' || candidate === '/usr/local/bin/codex') {
return undefined;
}
throw new Error('ENOENT');
});
execMock.mockResolvedValue({
exitCode: 0,
stdout: '/opt/homebrew/bin/codex\n',
stderr: '',
});

const result = await findBinaryCandidates('codex');

expect(result).toEqual(['/opt/homebrew/bin/codex', '/usr/local/bin/codex']);
expect(execMock).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
});

describe('checkBinaryExists', () => {
let accessMock: ReturnType<typeof vi.spyOn>;
const execMock = vi.mocked(execFileNoThrow);
Expand Down
45 changes: 44 additions & 1 deletion src/__tests__/renderer/components/NewInstanceModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@ describe('NewInstanceModal', () => {
expect(screen.getByPlaceholderText('/path/to/claude')).toBeInTheDocument();
});

// Set custom path and args, then blur both inputs. These values are local until create.
// Set custom path and args, then blur both inputs.
const customPathInput = screen.getByPlaceholderText('/path/to/claude');
fireEvent.change(customPathInput, { target: { value: '/custom/path/to/claude' } });
fireEvent.blur(customPathInput);
Expand Down Expand Up @@ -1729,6 +1729,49 @@ describe('NewInstanceModal', () => {
expect(customPathInput).toHaveValue('/detected/bin/claude');
});

it('persists a selected detected path as the next default for new agents', async () => {
vi.mocked(window.maestro.agents.detect).mockResolvedValue([
createAgentConfig({
id: 'codex',
name: 'Codex',
available: true,
binaryName: 'codex',
path: '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex',
pathCandidates: [
'/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex',
'/opt/homebrew/bin/codex',
],
}),
]);

render(
<NewInstanceModal
isOpen={true}
onClose={onClose}
onCreate={onCreate}
theme={theme}
existingSessions={[]}
/>
);

await waitFor(() => {
expect(screen.getByText('Codex')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Codex'));

await waitFor(() => {
expect(screen.getByTitle('Choose detected path')).toBeInTheDocument();
});

fireEvent.click(screen.getByTitle('Choose detected path'));
fireEvent.click(screen.getByText('/opt/homebrew/bin/codex'));

expect(window.maestro.agents.setCustomPath).toHaveBeenCalledWith(
'codex',
'/opt/homebrew/bin/codex'
);
});

it('should preload saved per-agent path, arguments, and environment variables', async () => {
vi.mocked(window.maestro.agents.detect).mockResolvedValue([
createAgentConfig({ id: 'claude-code', name: 'Claude Code', available: true }),
Expand Down
24 changes: 24 additions & 0 deletions src/__tests__/renderer/components/shared/AgentConfigPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,30 @@ describe('AgentConfigPanel', () => {
expect(props.onRefreshAgent).toHaveBeenCalled();
});

it('offers detected path choices and commits the selected path', () => {
const props = createDefaultProps({
agent: createMockAgent({
id: 'codex',
name: 'Codex',
binaryName: 'codex',
path: '/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex',
}),
pathOptions: [
'/Users/test/.nvm/versions/node/v25.3.0/bin/codex-multi-auth-codex',
'/opt/homebrew/bin/codex',
],
onPathOptionSelect: vi.fn(),
});

render(<AgentConfigPanel {...props} />);

fireEvent.click(screen.getByTitle('Choose detected path'));
fireEvent.click(screen.getByText('/opt/homebrew/bin/codex'));

expect(props.onCustomPathChange).toHaveBeenCalledWith('/opt/homebrew/bin/codex');
expect(props.onPathOptionSelect).toHaveBeenCalledWith('/opt/homebrew/bin/codex');
});

it('shows a read-only remote command field when SSH is enabled without a custom path', () => {
const props = createDefaultProps({
isSshEnabled: true,
Expand Down
3 changes: 3 additions & 0 deletions src/main/agents/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export interface AgentConfig {
args: string[]; // Base args always included (excludes batch mode prefix)
available: boolean;
path?: string;
pathCandidates?: string[];
customPath?: string; // User-specified custom path (shown in UI even if not available)
requiresPty?: boolean; // Whether this agent needs a pseudo-terminal
configOptions?: AgentConfigOption[]; // Agent-specific configuration
Expand All @@ -99,6 +100,7 @@ export interface AgentConfig {
defaultEnvVars?: Record<string, string>; // Default environment variables for this agent (merged with user customEnvVars)
readOnlyEnvOverrides?: Record<string, string>; // Env var overrides applied in read-only mode (replaces keys from defaultEnvVars)
readOnlyCliEnforced?: boolean; // Whether the agent's CLI enforces read-only mode (false = prompt-only enforcement)
pathCandidateBinaryNames?: string[]; // Extra binary names to offer as selectable executable paths
}

/**
Expand Down Expand Up @@ -145,6 +147,7 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [
name: 'Codex',
binaryName: 'codex',
command: 'codex',
pathCandidateBinaryNames: ['codex-multi-auth-codex'],
// Base args for interactive mode (no flags that are exec-only)
args: [],
// Codex CLI argument builders
Expand Down
30 changes: 26 additions & 4 deletions src/main/agents/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { execFileNoThrow } from '../utils/execFile';
import { logger } from '../utils/logger';
import { captureException } from '../utils/sentry';
import { getAgentCapabilities } from './capabilities';
import { checkBinaryExists, checkCustomPath, getExpandedEnv } from './path-prober';
import { checkCustomPath, findBinaryCandidates, getExpandedEnv } from './path-prober';
import { AGENT_DEFINITIONS, type AgentConfig } from './definitions';
import { isWindows } from '../../shared/platformDetection';

Expand Down Expand Up @@ -96,20 +96,36 @@ export class AgentDetector {

for (const agentDef of AGENT_DEFINITIONS) {
const customPath = this.customPaths[agentDef.id];
let detection: { exists: boolean; path?: string };
let detection: { exists: boolean; path?: string; paths?: string[] };
const binaryNames = Array.from(
new Set([...(agentDef.pathCandidateBinaryNames ?? []), agentDef.binaryName])
);
const detectCandidatePaths = async () => {
const candidatesByBinary = await Promise.all(
binaryNames.map((binaryName) => findBinaryCandidates(binaryName))
);
return Array.from(new Set(candidatesByBinary.flat()));
};

// If user has specified a custom path, check that first
if (customPath) {
detection = await checkCustomPath(customPath);
const detectedPaths = await detectCandidatePaths();
if (detection.exists) {
const orderedPaths = Array.from(new Set([detection.path!, ...detectedPaths]));
detection.paths = orderedPaths;
logger.info(
`Agent "${agentDef.name}" found at custom path: ${detection.path}`,
LOG_CONTEXT
);
} else {
logger.warn(`Agent "${agentDef.name}" custom path not valid: ${customPath}`, LOG_CONTEXT);
// Fall back to PATH detection
detection = await checkBinaryExists(agentDef.binaryName);
detection = {
exists: detectedPaths.length > 0,
path: detectedPaths[0],
paths: detectedPaths,
};
if (detection.exists) {
logger.info(
`Agent "${agentDef.name}" found in PATH at: ${detection.path}`,
Expand All @@ -118,7 +134,12 @@ export class AgentDetector {
}
}
} else {
detection = await checkBinaryExists(agentDef.binaryName);
const detectedPaths = await detectCandidatePaths();
detection = {
exists: detectedPaths.length > 0,
path: detectedPaths[0],
paths: detectedPaths,
};

if (detection.exists) {
logger.info(`Agent "${agentDef.name}" found at: ${detection.path}`, LOG_CONTEXT);
Expand All @@ -136,6 +157,7 @@ export class AgentDetector {
...agentDef,
available: detection.exists,
path: detection.path,
pathCandidates: detection.paths,
customPath: customPath || undefined,
capabilities: getAgentCapabilities(agentDef.id),
});
Expand Down
3 changes: 3 additions & 0 deletions src/main/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export {
getExpandedEnv,
checkCustomPath,
probeWindowsPaths,
probeWindowsPathCandidates,
probeUnixPaths,
probeUnixPathCandidates,
findBinaryCandidates,
checkBinaryExists,
} from './path-prober';

Expand Down
Loading