cc-haha/src/server/services/mcpHostPreflight.ts
程序员阿江(Relakkes) af5175aa2a fix: Align desktop subprocesses with terminal shell env
Desktop launches can inherit a sparse GUI environment, while user runtimes such as nvm and Homebrew are often initialized from shell startup files. Capture a bounded login+interactive shell environment once and reuse it for desktop CLI sessions, scheduled tasks, and MCP stdio startup while keeping explicit MCP PATH and provider-managed env overrides authoritative.

Constraint: macOS app launches may omit user shell PATH and exported runtime variables
Rejected: Patch only MCP stdio | leaves normal desktop sessions and scheduled tasks inconsistent
Confidence: high
Scope-risk: moderate
Directive: Keep provider/OAuth scrubbing after shell env merging when changing child env builders
Tested: bun test src/utils/terminalShellEnvironment.test.ts src/utils/mcpStdioEnvironment.test.ts src/server/services/mcpHostPreflight.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/cron-scheduler-launcher.test.ts src/server/__tests__/mcp.test.ts
Tested: bun run check:server
Tested: bun run check:coverage
Tested: bun run verify
Not-tested: Real desktop UI live-model smoke on an installed .app
2026-05-18 15:13:06 +08:00

181 lines
5.0 KiB
TypeScript

import { constants } from 'node:fs'
import { access } from 'node:fs/promises'
import path from 'node:path'
import { getCwd } from '../../utils/cwd.js'
import { getMcpStdioEnvironment } from '../../utils/mcpStdioEnvironment.js'
type HostCommandCheckResult =
| {
ok: true
resolvedCommand: string
}
| {
ok: false
message: string
}
function getPathSearchList(envPath?: string) {
return (envPath ?? process.env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
}
function getWindowsExecutableCandidates(command: string) {
if (process.platform !== 'win32') {
return [command]
}
const ext = path.extname(command)
if (ext) {
return [command]
}
const pathext = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean)
return [command, ...pathext.map((entry) => `${command}${entry.toLowerCase()}`)]
}
function isPathLikeCommand(command: string) {
return (
path.isAbsolute(command) ||
command.startsWith('./') ||
command.startsWith('../') ||
command.startsWith('.\\') ||
command.startsWith('..\\') ||
command.includes('/') ||
command.includes('\\')
)
}
function buildRuntimeHint(command: string) {
const normalized = path.basename(command).toLowerCase()
if (['node', 'npm', 'npx', 'pnpm', 'yarn'].includes(normalized)) {
return `Install Node.js on this machine so "${command}" is available in PATH, then retry.`
}
if (['python', 'python3', 'pip', 'pip3'].includes(normalized)) {
return `Install Python on this machine so "${command}" is available in PATH, then retry.`
}
if (normalized === 'uv') {
return 'Install uv on this machine so "uv" is available in PATH, then retry.'
}
if (normalized === 'bun') {
return 'Install Bun on this machine so "bun" is available in PATH, then retry.'
}
return `Install "${command}" on this machine or update PATH, then retry.`
}
function buildMissingCommandMessage(command: string) {
return `Host command "${command}" is not available in PATH. This STDIO MCP runs on the host machine. ${buildRuntimeHint(command)}`
}
function buildMissingPathMessage(command: string, resolvedPath: string) {
return `Host command path "${command}" could not be found at "${resolvedPath}". This STDIO MCP runs on the host machine, so the configured executable path must exist locally.`
}
function buildNonExecutablePathMessage(command: string, resolvedPath: string) {
return `Host command path "${command}" exists at "${resolvedPath}" but is not executable. This STDIO MCP runs on the host machine, so the configured executable must be runnable by the local OS user.`
}
async function resolveCommandFromPath(
command: string,
envPath?: string,
): Promise<string | null> {
const pathEntries = getPathSearchList(envPath)
for (const entry of pathEntries) {
for (const candidate of getWindowsExecutableCandidates(command)) {
const resolvedPath = path.join(entry, candidate)
try {
await access(
resolvedPath,
process.platform === 'win32' ? constants.F_OK : constants.X_OK,
)
return resolvedPath
} catch {
// Continue searching other PATH entries.
}
}
}
return null
}
export async function inspectMcpHostCommand(
command: string,
cwd: string = getCwd(),
env?: Record<string, string>,
): Promise<HostCommandCheckResult> {
const trimmedCommand = command.trim()
if (!trimmedCommand) {
return {
ok: false,
message: 'STDIO MCP command is empty.',
}
}
if (isPathLikeCommand(trimmedCommand)) {
const resolvedPath = path.isAbsolute(trimmedCommand)
? trimmedCommand
: path.resolve(cwd, trimmedCommand)
try {
await access(
resolvedPath,
process.platform === 'win32' ? constants.F_OK : constants.X_OK,
)
return {
ok: true,
resolvedCommand: resolvedPath,
}
} catch (error) {
const maybeErr = error as NodeJS.ErrnoException
return {
ok: false,
message:
maybeErr.code === 'ENOENT'
? buildMissingPathMessage(trimmedCommand, resolvedPath)
: buildNonExecutablePathMessage(trimmedCommand, resolvedPath),
}
}
}
const hasExplicitPath = env ? Object.hasOwn(env, 'PATH') : false
const resolvedCommand = hasExplicitPath
? await resolveCommandFromPath(trimmedCommand, env?.PATH)
: await resolveCommandFromPath(trimmedCommand, process.env.PATH)
if (resolvedCommand) {
return {
ok: true,
resolvedCommand,
}
}
if (!hasExplicitPath) {
const stdioEnv = await getMcpStdioEnvironment(env)
const shellResolvedCommand = await resolveCommandFromPath(
trimmedCommand,
stdioEnv.PATH,
)
if (shellResolvedCommand) {
return {
ok: true,
resolvedCommand: shellResolvedCommand,
}
}
}
return {
ok: false,
message: buildMissingCommandMessage(trimmedCommand),
}
}