fix(runtime): bound binary path discovery

This commit is contained in:
777genius 2026-05-22 00:17:54 +03:00
parent 3c427ac617
commit 13b14762bc
16 changed files with 390 additions and 151 deletions

View file

@ -263,6 +263,7 @@ async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
await resolveInteractiveShellEnvBestEffort({
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
fallbackEnv: process.env,
background: false,
});
CodexBinaryResolver.clearCache();
return CodexBinaryResolver.resolve();

View file

@ -1,13 +1,17 @@
import { CODEX_RUNTIME_PROGRESS } from '@features/codex-runtime-installer/contracts';
import { execCli } from '@main/utils/childProcess';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getAppDataPath } from '@main/utils/pathDecoder';
import {
findFirstRuntimePathBinaryCandidate,
isAbsoluteExistingFile,
RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
} from '@main/utils/runtimePathBinaryResolver';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash, randomUUID } from 'crypto';
import { existsSync, promises as fsp, readFileSync, statSync } from 'fs';
import { promises as fsp, readFileSync } from 'fs';
import path from 'path';
import { gunzipSync } from 'zlib';
@ -28,7 +32,6 @@ const MAX_TARBALL_BYTES = 160 * 1024 * 1024;
const MAX_UNPACKED_BYTES = 650 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
const VERSION_TIMEOUT_MS = 10_000;
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
interface NpmPackageMetadata {
name?: string;
@ -71,17 +74,6 @@ function getCurrentManifestPath(): string {
return path.join(getRuntimeRootPath(), 'current.json');
}
function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
return false;
}
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function parseManifest(value: unknown): CodexRuntimeManifest | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
@ -141,41 +133,13 @@ function getPathExecutableNames(): string[] {
: ['codex'];
}
function splitPathEnv(pathValue: string | undefined): string[] {
if (!pathValue) {
return [];
}
return pathValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
function resolvePathCodexBinary(
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
): string | null {
const shellEnv = getCachedShellEnv() ?? {};
const pathEntries = [
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
...splitPathEnv(shellEnv.PATH),
...splitPathEnv(buildMergedCliPath(null)),
...splitPathEnv(process.env.PATH),
];
const seen = new Set<string>();
for (const entry of pathEntries) {
const normalizedEntry = path.resolve(entry);
if (seen.has(normalizedEntry)) {
continue;
}
seen.add(normalizedEntry);
for (const executableName of getPathExecutableNames()) {
const candidate = path.join(normalizedEntry, executableName);
if (isAbsoluteExistingFile(candidate)) {
return candidate;
}
}
}
return null;
return findFirstRuntimePathBinaryCandidate({
executableNames: getPathExecutableNames(),
additionalEnvSources,
});
}
async function resolvePathCodexBinaryWithBestEffortEnv(
@ -187,8 +151,9 @@ async function resolvePathCodexBinaryWithBestEffortEnv(
}
const shellEnv = await resolveInteractiveShellEnvBestEffort({
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
fallbackEnv: process.env,
background: false,
});
return resolvePathCodexBinary([shellEnv]);
}

View file

@ -6,7 +6,6 @@ import { TmuxPackageManagerResolver } from '@features/tmux-installer/main/infras
import { TmuxPlatformResolver } from '@features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver';
import { TmuxWslService } from '@features/tmux-installer/main/infrastructure/wsl/TmuxWslService';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import type {
@ -83,7 +82,6 @@ export class TmuxStatusSourceAdapter implements TmuxStatusSourcePort {
async #probeStatus(): Promise<TmuxStatus> {
const resolvedPlatform = await this.#platformResolver.resolve();
const checkedAt = new Date().toISOString();
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
const plan = await this.#strategyResolver.resolve();

View file

@ -1,6 +1,6 @@
import { buildTmuxAutoInstallCapability } from '@features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { getShellPreferredHome } from '@main/utils/shellEnv';
import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver';
import { TmuxPlatformResolver } from '../platform/TmuxPlatformResolver';
@ -51,7 +51,6 @@ export class TmuxInstallStrategyResolver {
}
async resolve(): Promise<TmuxInstallPlan> {
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
const cwd = getShellPreferredHome();
const resolvedPlatform = await this.#platformResolver.resolve();

View file

@ -4,7 +4,6 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver';
import { TmuxWslService } from '../wsl/TmuxWslService';
@ -71,7 +70,6 @@ export class TmuxPlatformCommandExecutor {
return this.#wslService.execTmux(effectiveArgs, null, timeout);
}
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
const executable = await this.#resolveNativeTmuxExecutable(env);
return new Promise((resolve) => {
@ -250,13 +248,11 @@ export class TmuxPlatformCommandExecutor {
}
async #execNativePs(): Promise<ExecResult> {
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
return new Promise((resolve) => {
execFile(
'ps',
['-ax', '-o', 'pid=,ppid=,command='],
{ env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
{ env: process.env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
(error, stdout, stderr) => {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error

View file

@ -1,16 +1,16 @@
import { execCli } from '@main/utils/childProcess';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getAppDataPath } from '@main/utils/pathDecoder';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import {
getCachedShellEnv,
getShellPreferredHome,
resolveInteractiveShellEnvBestEffort,
} from '@main/utils/shellEnv';
collectRuntimePathBinaryCandidates,
isAbsoluteExistingFile,
RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
} from '@main/utils/runtimePathBinaryResolver';
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash, randomUUID } from 'crypto';
import { existsSync, promises as fsp, readdirSync, readFileSync, statSync } from 'fs';
import { promises as fsp, readdirSync, readFileSync } from 'fs';
import path from 'path';
import { gunzipSync } from 'zlib';
@ -27,7 +27,6 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
const VERSION_TIMEOUT_MS = 10_000;
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
interface NpmPackageMetadata {
name?: string;
@ -61,17 +60,6 @@ function getCurrentManifestPath(): string {
return path.join(getRuntimeRootPath(), 'current.json');
}
function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
return false;
}
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function parseManifest(value: unknown): OpenCodeRuntimeManifest | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
@ -130,56 +118,16 @@ function getPathExecutableNames(): string[] {
: ['opencode'];
}
function splitPathEnv(pathValue: string | undefined): string[] {
if (!pathValue) {
return [];
}
return pathValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
function collectPathOpenCodeBinaryCandidates(
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = [],
options: { includeFallbackPathEntries?: boolean } = {}
): string[] {
const shellEnv = getCachedShellEnv() ?? {};
const directPathEntries = [
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
...splitPathEnv(shellEnv.PATH),
];
const fallbackPathEntries =
options.includeFallbackPathEntries === false
? []
: [...splitPathEnv(buildMergedCliPath()), ...splitPathEnv(process.env.PATH)];
const seen = new Set<string>();
return [
...collectOpenCodeBinariesFromPathEntries(directPathEntries, seen),
...collectNvmOpenCodeBinaryCandidates().filter(isAbsoluteExistingFile),
...collectOpenCodeBinariesFromPathEntries(fallbackPathEntries, seen),
];
}
function collectOpenCodeBinariesFromPathEntries(
pathEntries: string[],
seen: Set<string>
): string[] {
const results: string[] = [];
for (const entry of pathEntries) {
const normalizedEntry = path.resolve(entry);
if (seen.has(normalizedEntry)) {
continue;
}
seen.add(normalizedEntry);
for (const executableName of getPathExecutableNames()) {
const candidate = path.join(normalizedEntry, executableName);
if (isAbsoluteExistingFile(candidate)) {
results.push(candidate);
}
}
}
return results;
return collectRuntimePathBinaryCandidates({
executableNames: getPathExecutableNames(),
additionalEnvSources,
includeFallbackPathEntries: options.includeFallbackPathEntries,
extraCandidates: collectNvmOpenCodeBinaryCandidates(),
});
}
function collectNvmOpenCodeBinaryCandidates(): string[] {
@ -283,8 +231,9 @@ async function probeFirstWorkingPathOpenCodeBinary(
firstFailure = cachedProbe.firstFailure;
const shellEnv = await resolveInteractiveShellEnvBestEffort({
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
timeoutMs: options.shellEnvTimeoutMs ?? RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
fallbackEnv: process.env,
background: false,
});
const shellProbe = await probeFirstWorkingOpenCodeBinaryCandidate(
collectPathOpenCodeBinaryCandidates([shellEnv], {

View file

@ -73,6 +73,8 @@ function isPathLikeCandidate(candidate: string): boolean {
}
function getPathEntries(): string[] {
// TODO: Consider sharing runtimePathBinaryResolver here after preserving this resolver's
// path-like candidate support and Windows PATHEXT normalization exactly.
const delimiter = process.platform === 'win32' ? ';' : path.delimiter;
const shellEnv = getCachedShellEnv() ?? {};
const seen = new Set<string>();

View file

@ -144,7 +144,7 @@ export class ScheduledTaskExecutor {
throw new Error('Claude CLI binary not found');
}
const shellEnv = await resolveInteractiveShellEnv();
const shellEnv = await resolveInteractiveShellEnv({ source: 'scheduled-task-executor' });
validateFastModeLaunchConfig(request.config);

View file

@ -1,6 +1,6 @@
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import * as fs from 'fs';
import * as path from 'path';
@ -124,6 +124,9 @@ async function collectNvmWindowsCandidates(): Promise<string[]> {
}
async function resolveFromPathEnv(binaryName: string, pathEnv?: string): Promise<string | null> {
// TODO: Consider migrating this PATH candidate collection to runtimePathBinaryResolver once
// Claude-specific executable checks, Windows PATHEXT handling, and parallel stat behavior
// can be preserved exactly.
const rawPath = pathEnv && pathEnv.length > 0 ? pathEnv : process.env.PATH;
if (!rawPath) {
return null;
@ -299,7 +302,10 @@ export class ClaudeBinaryResolver {
}
}
await resolveInteractiveShellEnv({
await resolveInteractiveShellEnvBestEffort({
timeoutMs: 1_500,
fallbackEnv: process.env,
background: false,
onProgress: (progress) => emitProgress(options, progress.phase, progress.message),
});
const enrichedPath = buildMergedCliPath(null);

View file

@ -0,0 +1,92 @@
import { existsSync, statSync } from 'fs';
import path from 'path';
import { buildMergedCliPath } from './cliPathMerge';
import { getCachedShellEnv } from './shellEnv';
export const RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
export function isAbsoluteExistingFile(filePath: string | null | undefined): filePath is string {
if (!filePath || !path.isAbsolute(filePath) || !existsSync(filePath)) {
return false;
}
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
export function splitPathEnv(pathValue: string | undefined): string[] {
if (!pathValue) {
return [];
}
return pathValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
function collectExecutableCandidatesFromPathEntries(
pathEntries: string[],
executableNames: string[],
seenPathEntries: Set<string>
): string[] {
const results: string[] = [];
for (const entry of pathEntries) {
const normalizedEntry = path.resolve(entry);
if (seenPathEntries.has(normalizedEntry)) {
continue;
}
seenPathEntries.add(normalizedEntry);
for (const executableName of executableNames) {
const candidate = path.join(normalizedEntry, executableName);
if (isAbsoluteExistingFile(candidate)) {
results.push(candidate);
}
}
}
return results;
}
export interface RuntimePathBinaryCandidateOptions {
executableNames: string[];
additionalEnvSources?: (NodeJS.ProcessEnv | null | undefined)[];
includeFallbackPathEntries?: boolean;
extraCandidates?: string[];
}
export function collectRuntimePathBinaryCandidates(
options: RuntimePathBinaryCandidateOptions
): string[] {
const additionalEnvSources = options.additionalEnvSources ?? [];
const shellEnv = getCachedShellEnv() ?? {};
const directPathEntries = [
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
...splitPathEnv(shellEnv.PATH),
];
const fallbackPathEntries =
options.includeFallbackPathEntries === false
? []
: [...splitPathEnv(buildMergedCliPath(null)), ...splitPathEnv(process.env.PATH)];
const seenPathEntries = new Set<string>();
return [
...collectExecutableCandidatesFromPathEntries(
directPathEntries,
options.executableNames,
seenPathEntries
),
...(options.extraCandidates ?? []).filter(isAbsoluteExistingFile),
...collectExecutableCandidatesFromPathEntries(
fallbackPathEntries,
options.executableNames,
seenPathEntries
),
];
}
export function findFirstRuntimePathBinaryCandidate(
options: RuntimePathBinaryCandidateOptions
): string | null {
return collectRuntimePathBinaryCandidates(options)[0] ?? null;
}

View file

@ -27,18 +27,32 @@ let lastShellEnvFailureMessage: string | null = null;
export interface ShellEnvResolveProgress {
phase: string;
message: string;
source?: string;
}
export interface ShellEnvResolveOptions {
onProgress?: (progress: ShellEnvResolveProgress) => void;
/**
* Stable diagnostic label for the caller that initiated the shell probe.
* Keep this to a short feature/service id, not a filesystem path.
*/
source?: string;
}
export interface ShellEnvBestEffortResolveOptions extends ShellEnvResolveOptions {
/**
* Max time to wait on the critical path before returning fallbackEnv.
* The full shell resolve continues in the background and caches on success.
* By default, the full shell resolve continues in the background and caches
* on success. Set background=false for hot paths that only want cached env
* or an immediate fallback.
*/
timeoutMs?: number;
/**
* Whether a slow shell probe should continue in the background after the
* caller falls back. Disable this for startup/status hot paths where a
* delayed hard timeout would only create log noise and process pressure.
*/
background?: boolean;
/**
* Returned when shell env is not ready quickly enough. This is intentionally
* not cached as a real shell env.
@ -51,7 +65,21 @@ function emitProgress(
phase: string,
message: string
): void {
options?.onProgress?.({ phase, message });
const source = normalizeShellEnvSource(options?.source);
options?.onProgress?.(source ? { phase, message, source } : { phase, message });
}
function normalizeShellEnvSource(source: string | undefined): string | null {
const trimmed = source?.trim();
if (!trimmed) {
return null;
}
return trimmed.replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 80);
}
function formatShellEnvSource(options: ShellEnvResolveOptions | undefined): string {
const source = normalizeShellEnvSource(options?.source);
return source ? ` source=${source}` : '';
}
function rememberShellEnvFailure(message: string): void {
@ -177,7 +205,6 @@ export async function resolveInteractiveShellEnv(
return loginEnv;
} catch (loginError) {
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
logger.warn(`Failed to resolve login shell env: ${loginMessage}`);
try {
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
@ -187,7 +214,11 @@ export async function resolveInteractiveShellEnv(
} catch (interactiveError) {
const interactiveMessage =
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
logger.warn(
`Failed to resolve shell env after login and interactive probes${formatShellEnvSource(
options
)}: login=${loginMessage}; interactive=${interactiveMessage}`
);
rememberShellEnvFailure(interactiveMessage);
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
return {};
@ -222,6 +253,10 @@ export async function resolveInteractiveShellEnvBestEffort(
const fallbackEnv = options.fallbackEnv ?? {};
const timeoutMs = Math.max(0, options.timeoutMs ?? SHELL_ENV_BEST_EFFORT_TIMEOUT_MS);
const startedAt = Date.now();
if (options.background === false) {
emitProgress(options, 'shell-env-best-effort-fallback', 'Using fallback shell environment...');
return fallbackEnv;
}
if (!shellEnvResolvePromise && startedAt < shellEnvFailureCooldownUntil) {
const retryInMs = Math.max(0, shellEnvFailureCooldownUntil - startedAt);
emitProgress(

View file

@ -2,8 +2,8 @@ import { createHash } from 'crypto';
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { gzipSync } from 'zlib';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { gzipSync } from 'zlib';
const execCliMock = vi.hoisted(() => vi.fn());
const buildMergedCliPathMock = vi.hoisted(() => vi.fn(() => process.env.PATH ?? ''));

View file

@ -1,12 +1,15 @@
// @vitest-environment node
import type { PathLike } from 'fs';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PathLike } from 'fs';
const mockBuildMergedCliPath = vi.fn<(binaryPath: string | null) => string>();
const mockGetShellPreferredHome = vi.fn<() => string>();
const mockGetClaudeBasePath = vi.fn<() => string>();
const mockResolveInteractiveShellEnv = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
const mockResolveInteractiveShellEnvBestEffort = vi.fn<
(options?: unknown) => Promise<NodeJS.ProcessEnv>
>();
const mockGetConfiguredCliFlavor = vi.fn<() => 'claude' | 'agent_teams_orchestrator'>();
const mockGetDoctorInvokedCandidates = vi.fn<(commandName: string) => Promise<string[]>>();
@ -19,7 +22,8 @@ vi.mock('@main/utils/cliPathMerge', () => ({
vi.mock('@main/utils/shellEnv', () => ({
getShellPreferredHome: () => mockGetShellPreferredHome(),
resolveInteractiveShellEnv: () => mockResolveInteractiveShellEnv(),
resolveInteractiveShellEnvBestEffort: (options?: unknown) =>
mockResolveInteractiveShellEnvBestEffort(options),
}));
vi.mock('@main/utils/pathDecoder', () => ({
@ -62,7 +66,7 @@ describe('ClaudeBinaryResolver', () => {
mockBuildMergedCliPath.mockReturnValue(['/usr/local/bin', '/usr/bin'].join(path.delimiter));
mockGetShellPreferredHome.mockReturnValue('/Users/tester');
mockGetClaudeBasePath.mockReturnValue('/Users/tester/.claude');
mockResolveInteractiveShellEnv.mockResolvedValue({});
mockResolveInteractiveShellEnvBestEffort.mockResolvedValue({});
mockGetConfiguredCliFlavor.mockReturnValue('agent_teams_orchestrator');
mockGetDoctorInvokedCandidates.mockResolvedValue([]);
Object.defineProperty(process, 'platform', {
@ -139,7 +143,9 @@ describe('ClaudeBinaryResolver', () => {
it('does not wait for shell env before using an explicit absolute runtime override', async () => {
const expectedBinary = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli-dev';
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = expectedBinary;
mockResolveInteractiveShellEnv.mockRejectedValue(new Error('shell env should not be needed'));
mockResolveInteractiveShellEnvBestEffort.mockRejectedValue(
new Error('shell env should not be needed')
);
accessMock.mockImplementation((filePath) => {
if (filePath === expectedBinary) {
@ -152,7 +158,7 @@ describe('ClaudeBinaryResolver', () => {
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(mockResolveInteractiveShellEnv).not.toHaveBeenCalled();
expect(mockResolveInteractiveShellEnvBestEffort).not.toHaveBeenCalled();
});
it('resolves extensionless Windows explicit overrides to a real executable file first', async () => {
@ -214,6 +220,13 @@ describe('ClaudeBinaryResolver', () => {
ClaudeBinaryResolver.clearCache();
await expect(ClaudeBinaryResolver.resolve()).resolves.toBe(expectedBinary);
expect(mockResolveInteractiveShellEnvBestEffort).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 1_500,
fallbackEnv: process.env,
background: false,
})
);
expect(accessMock).toHaveBeenCalledWith(expectedBinary, 1);
});

View file

@ -0,0 +1,99 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const buildMergedCliPathMock = vi.hoisted(() => vi.fn(() => ''));
const getCachedShellEnvMock = vi.hoisted(() => vi.fn<() => NodeJS.ProcessEnv | null>(() => null));
vi.mock('@main/utils/cliPathMerge', () => ({
buildMergedCliPath: buildMergedCliPathMock,
}));
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: getCachedShellEnvMock,
}));
import {
collectRuntimePathBinaryCandidates,
findFirstRuntimePathBinaryCandidate,
RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS,
} from '@main/utils/runtimePathBinaryResolver';
describe('runtimePathBinaryResolver', () => {
let tempRoot: string | null = null;
let originalPath: string | undefined;
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'runtime-path-binary-resolver-'));
originalPath = process.env.PATH;
process.env.PATH = '';
buildMergedCliPathMock.mockReset();
buildMergedCliPathMock.mockReturnValue('');
getCachedShellEnvMock.mockReset();
getCachedShellEnvMock.mockReturnValue(null);
});
afterEach(async () => {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
}
});
async function createExecutable(dirName: string, name: string): Promise<string> {
const binaryPath = path.join(tempRoot!, dirName, name);
await mkdir(path.dirname(binaryPath), { recursive: true });
await writeFile(binaryPath, 'binary');
if (process.platform !== 'win32') {
await chmod(binaryPath, 0o755);
}
return binaryPath;
}
it('prefers explicit env sources before cached and fallback PATH entries', async () => {
const explicitBinary = await createExecutable('explicit-bin', 'tool');
const cachedBinary = await createExecutable('cached-bin', 'tool');
const fallbackBinary = await createExecutable('fallback-bin', 'tool');
getCachedShellEnvMock.mockReturnValue({ PATH: path.dirname(cachedBinary) });
buildMergedCliPathMock.mockReturnValue(path.dirname(fallbackBinary));
expect(
findFirstRuntimePathBinaryCandidate({
executableNames: ['tool'],
additionalEnvSources: [{ PATH: path.dirname(explicitBinary) }],
})
).toBe(explicitBinary);
});
it('keeps extra candidates before fallback PATH entries and filters missing files', async () => {
const extraBinary = await createExecutable('extra-bin', 'tool');
const fallbackBinary = await createExecutable('fallback-bin', 'tool');
buildMergedCliPathMock.mockReturnValue(path.dirname(fallbackBinary));
expect(
collectRuntimePathBinaryCandidates({
executableNames: ['tool'],
extraCandidates: [path.join(tempRoot!, 'missing', 'tool'), extraBinary],
})
).toEqual([extraBinary, fallbackBinary]);
});
it('can skip fallback PATH entries for staged shell-env lookup', async () => {
const fallbackBinary = await createExecutable('fallback-bin', 'tool');
buildMergedCliPathMock.mockReturnValue(path.dirname(fallbackBinary));
expect(
collectRuntimePathBinaryCandidates({
executableNames: ['tool'],
includeFallbackPathEntries: false,
})
).toEqual([]);
expect(RUNTIME_PATH_SHELL_ENV_TIMEOUT_MS).toBe(1_500);
});
});

View file

@ -1,16 +1,14 @@
// @vitest-environment node
import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearShellEnvCache,
getCachedShellEnv,
resolveInteractiveShellEnv,
resolveInteractiveShellEnvBestEffort,
} from '@main/utils/shellEnv';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const describePosix = process.platform === 'win32' ? describe.skip : describe;
@ -134,6 +132,37 @@ setTimeout(() => {
});
});
it('returns fallback without spawning shell when background resolution is disabled', async () => {
const invocationFile = path.join(tempDir, 'no-background-invocations.log');
process.env.FAKE_SHELL_INVOCATIONS = invocationFile;
const fakeShell = await createFakeShell(
tempDir,
'no-background-shell.js',
envWriterSource(`
const fs = require('fs');
fs.appendFileSync(process.env.FAKE_SHELL_INVOCATIONS, 'spawned\\n');
writeEnv({
PATH: '/should-not-run/bin',
HOME: '/should-not-run-home',
});
`)
);
process.env.SHELL = fakeShell;
const startedAt = Date.now();
const env = await resolveInteractiveShellEnvBestEffort({
timeoutMs: 25,
fallbackEnv: { PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' },
background: false,
});
const elapsedMs = Date.now() - startedAt;
expect(elapsedMs).toBeLessThan(150);
expect(env).toMatchObject({ PATH: 'FALLBACK_PATH', HOME: 'FALLBACK_HOME' });
expect(getCachedShellEnv()).toBeNull();
await expect(readFile(invocationFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
});
it('falls back from a failed login shell process to a successful interactive shell process', async () => {
const fakeShell = await createFakeShell(
tempDir,
@ -154,10 +183,7 @@ writeEnv({
PATH: '/interactive-real/bin:/usr/bin',
HOME: '/interactive-home',
});
expect(console.warn).toHaveBeenCalledWith(
'[Utils:shellEnv]',
'Failed to resolve login shell env: shell env command exited with code 42'
);
expect(console.warn).not.toHaveBeenCalled();
vi.mocked(console.warn).mockClear();
expect(getCachedShellEnv()).toMatchObject({
PATH: '/interactive-real/bin:/usr/bin',

View file

@ -1,6 +1,5 @@
// @vitest-environment node
import { EventEmitter } from 'events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
@ -127,6 +126,48 @@ describe('shellEnv', () => {
);
});
it('adds a sanitized source label to strict shell failure diagnostics', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
const child = createChild();
children.push(child);
const attempt = children.length;
queueMicrotask(() => {
emitError(child, attempt === 1 ? 'login blocked' : 'interactive blocked');
});
return child;
});
const progress = vi.fn();
const shellEnv = await importShellEnv();
await expect(
shellEnv.resolveInteractiveShellEnv({
source: ' mcp node/runtime ',
onProgress: progress,
})
).resolves.toEqual({});
expect(progress).toHaveBeenCalledWith({
phase: 'shell-env-login',
message: 'Reading login shell environment...',
source: 'mcp_node_runtime',
});
expect(progress).toHaveBeenCalledWith({
phase: 'shell-env-interactive',
message: 'Trying interactive shell environment...',
source: 'mcp_node_runtime',
});
expect(progress).toHaveBeenCalledWith({
phase: 'shell-env-fallback',
message: 'Using current process environment...',
source: 'mcp_node_runtime',
});
expect(hoisted.loggerWarn).toHaveBeenCalledWith(
'Failed to resolve shell env after login and interactive probes source=mcp_node_runtime: login=login blocked; interactive=interactive blocked'
);
});
it('returns fallback on soft timeout without caching it, then caches background success', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
@ -259,6 +300,22 @@ describe('shellEnv', () => {
});
});
it('can return fallback without starting a background shell probe', async () => {
const shellEnv = await importShellEnv();
const fallbackEnv = { PATH: '/fallback/no-background' };
await expect(
shellEnv.resolveInteractiveShellEnvBestEffort({
timeoutMs: 0,
fallbackEnv,
background: false,
})
).resolves.toBe(fallbackEnv);
expect(hoisted.spawn).not.toHaveBeenCalled();
expect(shellEnv.getCachedShellEnv()).toBeNull();
});
it('keeps resolving in the background through the strict interactive fallback', async () => {
const children: MockChildProcess[] = [];
hoisted.spawn.mockImplementation(() => {
@ -315,9 +372,7 @@ describe('shellEnv', () => {
HOME: '/Users/tester',
});
expect(hoisted.spawn).toHaveBeenCalledTimes(2);
expect(hoisted.loggerWarn).toHaveBeenCalledWith(
'Failed to resolve login shell env: shell env command exited with code 42'
);
expect(hoisted.loggerWarn).not.toHaveBeenCalled();
});
it('coalesces concurrent best-effort calls behind one shell process', async () => {
@ -443,6 +498,9 @@ describe('shellEnv', () => {
await expect(result).resolves.toMatchObject({ PATH: '/fallback/stuck' });
expect(children[1].kill).toHaveBeenCalledWith();
expect(shellEnv.getCachedShellEnv()).toBeNull();
expect(hoisted.loggerWarn).toHaveBeenCalledWith(
'Failed to resolve shell env after login and interactive probes: login=shell env resolve timeout; interactive=shell env resolve timeout'
);
await vi.advanceTimersByTimeAsync(3_000);