diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 264ac80d..10dcf615 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os' import * as path from 'node:path' import { ConversationService } from '../services/conversationService.js' import { ProviderService } from '../services/providerService.js' +import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js' describe('ConversationService', () => { let tmpDir: string @@ -16,6 +17,11 @@ describe('ConversationService', () => { let originalOAuthToken: string | undefined let originalProviderManagedByHost: string | undefined let originalDiagnosticsFile: string | undefined + let originalHome: string | undefined + let originalPath: string | undefined + let originalShell: string | undefined + let originalZdotdir: string | undefined + let originalDisableTerminalShellEnv: string | undefined beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-')) @@ -28,6 +34,11 @@ describe('ConversationService', () => { originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE + originalHome = process.env.HOME + originalPath = process.env.PATH + originalShell = process.env.SHELL + originalZdotdir = process.env.ZDOTDIR + originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.ANTHROPIC_API_KEY = 'stale-parent-api-key' @@ -40,6 +51,8 @@ describe('ConversationService', () => { delete process.env.CLAUDE_CODE_ENTRYPOINT delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE + process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1' + resetTerminalShellEnvironmentCacheForTests() }) afterEach(async () => { @@ -70,9 +83,49 @@ describe('ConversationService', () => { if (originalDiagnosticsFile === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + + if (originalPath === undefined) delete process.env.PATH + else process.env.PATH = originalPath + + if (originalShell === undefined) delete process.env.SHELL + else process.env.SHELL = originalShell + + if (originalZdotdir === undefined) delete process.env.ZDOTDIR + else process.env.ZDOTDIR = originalZdotdir + + if (originalDisableTerminalShellEnv === undefined) delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + else process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv + + resetTerminalShellEnvironmentCacheForTests() await fs.rm(tmpDir, { recursive: true, force: true }) }) + async function writeFakeZsh(filePath: string) { + await fs.writeFile( + filePath, + [ + '#!/bin/sh', + 'command=', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "-c" ]; then', + ' shift', + ' command="$1"', + ' break', + ' fi', + ' shift', + 'done', + 'if [ -f "$HOME/.zshrc" ]; then', + ' . "$HOME/.zshrc" /dev/null 2>/dev/null || true', + 'fi', + 'exec /bin/sh -c "$command"', + '', + ].join('\n'), + { mode: 0o755 }, + ) + } + test('keeps inherited provider env when no desktop provider config exists', async () => { const service = new ConversationService() as any const env = (await service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha')) as Record @@ -101,6 +154,37 @@ describe('ConversationService', () => { expect(env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE).not.toContain('myself_code') }) + test('buildChildEnv inherits exported terminal shell variables for desktop CLI sessions', async () => { + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + const nvmDir = path.join(tmpDir, '.nvm') + await fs.mkdir(nodeBin, { recursive: true }) + await fs.mkdir(nvmDir, { recursive: true }) + await writeFakeZsh(shellPath) + await fs.writeFile( + path.join(tmpDir, '.zshrc'), + [ + `export NVM_DIR="${nvmDir}"`, + `export PATH="${nodeBin}:$PATH"`, + '', + ].join('\n'), + ) + + delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + process.env.HOME = tmpDir + process.env.SHELL = shellPath + process.env.PATH = '/usr/bin:/bin' + delete process.env.ZDOTDIR + resetTerminalShellEnvironmentCacheForTests() + + const service = new ConversationService() as any + const env = (await service.buildChildEnv(tmpDir)) as Record + + expect(env.NVM_DIR).toBe(nvmDir) + expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin) + expect(env.PATH.split(path.delimiter)).toContain('/usr/bin') + }) + test('strips inherited provider env when desktop provider config exists', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index b6fc9c9a..8241adb3 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -10,6 +10,7 @@ import { } from '../services/cronScheduler.js' import { CronService } from '../services/cronService.js' import { ProviderService } from '../services/providerService.js' +import { resetTerminalShellEnvironmentCacheForTests } from '../../utils/terminalShellEnvironment.js' const originalConfigDir = process.env.CLAUDE_CONFIG_DIR const originalPath = process.env.PATH @@ -18,6 +19,10 @@ const originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL const originalAnthropicModel = process.env.ANTHROPIC_MODEL const originalClaudeCodeEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT +const originalHome = process.env.HOME +const originalShell = process.env.SHELL +const originalZdotdir = process.env.ZDOTDIR +const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV const isWindows = process.platform === 'win32' const unixOnly = isWindows ? it.skip : it @@ -81,6 +86,27 @@ function restoreEnv(): void { } else { delete process.env.CLAUDE_CODE_ENTRYPOINT } + if (originalHome) { + process.env.HOME = originalHome + } else { + delete process.env.HOME + } + if (originalShell) { + process.env.SHELL = originalShell + } else { + delete process.env.SHELL + } + if (originalZdotdir) { + process.env.ZDOTDIR = originalZdotdir + } else { + delete process.env.ZDOTDIR + } + if (originalDisableTerminalShellEnv) { + process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv + } else { + delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + } + resetTerminalShellEnvironmentCacheForTests() } describe('cron scheduler launcher resolution', () => { @@ -89,6 +115,8 @@ describe('cron scheduler launcher resolution', () => { beforeEach(async () => { tmpDir = await createTmpDir() process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, 'config') + process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1' + resetTerminalShellEnvironmentCacheForTests() }) afterEach(async () => { @@ -300,4 +328,96 @@ describe('cron scheduler launcher resolution', () => { expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1') expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli') }) + + unixOnly('executeTask inherits exported terminal shell variables', async () => { + const appRoot = path.join(tmpDir, 'app-root') + const sidecarPath = path.join(tmpDir, 'claude-sidecar') + const sidecarEnvPath = path.join(tmpDir, 'sidecar.env') + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + const nvmDir = path.join(tmpDir, '.nvm') + + await fs.mkdir(appRoot, { recursive: true }) + await fs.mkdir(nodeBin, { recursive: true }) + await fs.mkdir(nvmDir, { recursive: true }) + await fs.writeFile( + shellPath, + [ + '#!/bin/sh', + 'command=', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "-c" ]; then', + ' shift', + ' command="$1"', + ' break', + ' fi', + ' shift', + 'done', + 'if [ -f "$HOME/.zshrc" ]; then', + ' . "$HOME/.zshrc" /dev/null 2>/dev/null || true', + 'fi', + 'exec /bin/sh -c "$command"', + '', + ].join('\n'), + 'utf-8', + ) + await fs.chmod(shellPath, 0o755) + await fs.writeFile( + path.join(tmpDir, '.zshrc'), + [ + `export NVM_DIR="${nvmDir}"`, + `export PATH="${nodeBin}:$PATH"`, + '', + ].join('\n'), + ) + await fs.writeFile( + sidecarPath, + [ + '#!/bin/sh', + `env | sort > "${sidecarEnvPath}"`, + '/bin/cat >/dev/null', + 'printf \'%s\\n\' \'{"type":"result","result":"shell env ok"}\'', + 'exit 0', + '', + ].join('\n'), + 'utf-8', + ) + await fs.chmod(sidecarPath, 0o755) + + delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + process.env.HOME = tmpDir + process.env.SHELL = shellPath + process.env.PATH = '/usr/bin:/bin' + delete process.env.ZDOTDIR + process.env.CLAUDE_CLI_PATH = sidecarPath + process.env.CLAUDE_APP_ROOT = appRoot + resetTerminalShellEnvironmentCacheForTests() + + const cronService = new CronService() + const scheduler = new CronScheduler(cronService) + const task = await cronService.createTask({ + cron: '* * * * *', + prompt: 'cron shell env test', + name: 'Shell Env Task', + recurring: true, + folderPath: tmpDir, + }) + + const run = await scheduler.executeTask(task) + + expect(run.status).toBe('completed') + expect(run.output).toBe('shell env ok') + + const env = Object.fromEntries( + (await fs.readFile(sidecarEnvPath, 'utf-8')) + .trim() + .split('\n') + .map((line) => { + const index = line.indexOf('=') + return [line.slice(0, index), line.slice(index + 1)] + }), + ) + expect(env.NVM_DIR).toBe(nvmDir) + expect(env.PATH.split(path.delimiter)[0]).toBe(nodeBin) + }) }) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 2418b852..4444a4ef 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -25,6 +25,7 @@ import { import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { findCanonicalGitRoot } from '../../utils/git.js' import { sanitizePath } from '../../utils/path.js' +import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' const MAX_CAPTURED_PROCESS_LINES = 80 const MAX_CAPTURED_SDK_MESSAGES = 40 @@ -887,7 +888,7 @@ export class ConversationService { 'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS', ] as const - const cleanEnv = { ...process.env } + const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN if (this.shouldStripInheritedProviderEnv(options?.providerId)) { for (const key of PROVIDER_ENV_KEYS) { diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index b1def870..702b661b 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -21,6 +21,7 @@ import { buildClaudeCliArgs, resolveClaudeCliLauncher, } from '../../utils/desktopBundledCli.js' +import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' // ─── Types ───────────────────────────────────────────────────────────────────── @@ -640,7 +641,7 @@ export class CronScheduler { workDir: string, task: CronTask, ): Promise> { - const cleanEnv = { ...process.env } + const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN if (this.shouldStripInheritedProviderEnv(task.providerId)) { diff --git a/src/server/services/mcpHostPreflight.test.ts b/src/server/services/mcpHostPreflight.test.ts new file mode 100644 index 00000000..c9678f4d --- /dev/null +++ b/src/server/services/mcpHostPreflight.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { resetMcpStdioEnvironmentCacheForTests } from '../../utils/mcpStdioEnvironment.js' +import { inspectMcpHostCommand } from './mcpHostPreflight.js' + +let tmpDir: string +let originalEnv: { + HOME?: string + PATH?: string + SHELL?: string + ZDOTDIR?: string + CC_HAHA_DISABLE_TERMINAL_SHELL_ENV?: string +} + +async function writeExecutable(filePath: string, content: string) { + await writeFile(filePath, content, { mode: 0o755 }) +} + +async function writeFakeZsh(filePath: string) { + await writeExecutable( + filePath, + [ + '#!/bin/sh', + 'command=', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "-c" ]; then', + ' shift', + ' command="$1"', + ' break', + ' fi', + ' shift', + 'done', + 'if [ -f "$HOME/.zshrc" ]; then', + ' . "$HOME/.zshrc" /dev/null 2>/dev/null || true', + 'fi', + 'exec /bin/sh -c "$command"', + '', + ].join('\n'), + ) +} + +describe('MCP host preflight', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'mcp-host-preflight-test-')) + originalEnv = { + HOME: process.env.HOME, + PATH: process.env.PATH, + SHELL: process.env.SHELL, + ZDOTDIR: process.env.ZDOTDIR, + CC_HAHA_DISABLE_TERMINAL_SHELL_ENV: + process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV, + } + delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + resetMcpStdioEnvironmentCacheForTests() + }) + + afterEach(async () => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + resetMcpStdioEnvironmentCacheForTests() + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('finds npx from user shell PATH when the desktop process PATH is minimal', async () => { + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + const npxPath = path.join(nodeBin, 'npx') + await mkdir(nodeBin, { recursive: true }) + await writeFakeZsh(shellPath) + await writeExecutable(npxPath, '#!/bin/sh\nexit 0\n') + await writeFile( + path.join(tmpDir, '.zshrc'), + `export PATH="${nodeBin}:$PATH"\n`, + ) + + process.env.HOME = tmpDir + process.env.SHELL = shellPath + process.env.PATH = '/usr/bin:/bin' + delete process.env.ZDOTDIR + + await expect(inspectMcpHostCommand('npx', tmpDir, {})).resolves.toEqual({ + ok: true, + resolvedCommand: npxPath, + }) + }) + + it('finds commands from the desktop process PATH before shell fallback', async () => { + const processBin = path.join(tmpDir, 'process-bin') + const toolPath = path.join(processBin, 'mcp-tool') + await mkdir(processBin, { recursive: true }) + await writeExecutable(toolPath, '#!/bin/sh\nexit 0\n') + + process.env.HOME = tmpDir + process.env.SHELL = '/bin/zsh' + process.env.PATH = processBin + + await expect( + inspectMcpHostCommand('mcp-tool', tmpDir, {}), + ).resolves.toEqual({ + ok: true, + resolvedCommand: toolPath, + }) + }) + + it('uses explicit MCP PATH to resolve host commands', async () => { + const configuredBin = path.join(tmpDir, 'configured-bin') + const toolPath = path.join(configuredBin, 'mcp-tool') + await mkdir(configuredBin, { recursive: true }) + await writeExecutable(toolPath, '#!/bin/sh\nexit 0\n') + + process.env.HOME = tmpDir + process.env.SHELL = '/bin/zsh' + process.env.PATH = '/usr/bin:/bin' + + await expect( + inspectMcpHostCommand('mcp-tool', tmpDir, { PATH: configuredBin }), + ).resolves.toEqual({ + ok: true, + resolvedCommand: toolPath, + }) + }) + + it('does not fall back to shell PATH when MCP PATH is explicit', async () => { + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + const emptyBin = path.join(tmpDir, 'empty-bin') + const npxPath = path.join(nodeBin, 'npx') + await mkdir(nodeBin, { recursive: true }) + await mkdir(emptyBin, { recursive: true }) + await writeFakeZsh(shellPath) + await writeExecutable(npxPath, '#!/bin/sh\nexit 0\n') + await writeFile( + path.join(tmpDir, '.zshrc'), + `export PATH="${nodeBin}:$PATH"\n`, + ) + + process.env.HOME = tmpDir + process.env.SHELL = shellPath + process.env.PATH = '/usr/bin:/bin' + delete process.env.ZDOTDIR + + await expect( + inspectMcpHostCommand('npx', tmpDir, { PATH: emptyBin }), + ).resolves.toEqual({ + ok: false, + message: + 'Host command "npx" is not available in PATH. This STDIO MCP runs on the host machine. Install Node.js on this machine so "npx" is available in PATH, then retry.', + }) + }) +}) diff --git a/src/server/services/mcpHostPreflight.ts b/src/server/services/mcpHostPreflight.ts index 57ddca2b..2fae89d1 100644 --- a/src/server/services/mcpHostPreflight.ts +++ b/src/server/services/mcpHostPreflight.ts @@ -2,7 +2,7 @@ import { constants } from 'node:fs' import { access } from 'node:fs/promises' import path from 'node:path' import { getCwd } from '../../utils/cwd.js' -import { which } from '../../utils/which.js' +import { getMcpStdioEnvironment } from '../../utils/mcpStdioEnvironment.js' type HostCommandCheckResult = | { @@ -148,10 +148,10 @@ export async function inspectMcpHostCommand( } } - const resolvedCommand = - env?.PATH - ? await resolveCommandFromPath(trimmedCommand, env.PATH) - : await which(trimmedCommand) + 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, @@ -159,6 +159,20 @@ export async function inspectMcpHostCommand( } } + 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), diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index e7d6b6ab..298f2ce9 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -95,7 +95,7 @@ import { } from '../../utils/proxy.js' import { recursivelySanitizeUnicode } from '../../utils/sanitization.js' import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js' -import { subprocessEnv } from '../../utils/subprocessEnv.js' +import { getMcpStdioEnvironment } from '../../utils/mcpStdioEnvironment.js' import { isPersistError, persistToolResult, @@ -943,13 +943,11 @@ export const connectToServer = memoize( const finalArgs = process.env.CLAUDE_CODE_SHELL_PREFIX ? [[serverRef.command, ...serverRef.args].join(' ')] : serverRef.args + const stdioEnv = await getMcpStdioEnvironment(serverRef.env) transport = new StdioClientTransport({ command: finalCommand, args: finalArgs, - env: { - ...subprocessEnv(), - ...serverRef.env, - } as Record, + env: stdioEnv, stderr: 'pipe', // prevents error output from the MCP server from printing to the UI }) } else { diff --git a/src/utils/mcpStdioEnvironment.test.ts b/src/utils/mcpStdioEnvironment.test.ts new file mode 100644 index 00000000..bee53071 --- /dev/null +++ b/src/utils/mcpStdioEnvironment.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + getMcpStdioEnvironment, + resetMcpStdioEnvironmentCacheForTests, +} from './mcpStdioEnvironment.js' + +let tmpDir: string +let originalEnv: { + HOME?: string + PATH?: string + SHELL?: string + ZDOTDIR?: string + CC_HAHA_DISABLE_TERMINAL_SHELL_ENV?: string +} + +async function writeExecutable(filePath: string, content: string) { + await writeFile(filePath, content, { mode: 0o755 }) +} + +async function writeFakeZsh(filePath: string) { + await writeExecutable( + filePath, + [ + '#!/bin/sh', + 'command=', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "-c" ]; then', + ' shift', + ' command="$1"', + ' break', + ' fi', + ' shift', + 'done', + 'if [ -f "$HOME/.zshrc" ]; then', + ' . "$HOME/.zshrc" /dev/null 2>/dev/null || true', + 'fi', + 'exec /bin/sh -c "$command"', + '', + ].join('\n'), + ) +} + +describe('MCP stdio environment', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'mcp-stdio-env-test-')) + originalEnv = { + HOME: process.env.HOME, + PATH: process.env.PATH, + SHELL: process.env.SHELL, + ZDOTDIR: process.env.ZDOTDIR, + CC_HAHA_DISABLE_TERMINAL_SHELL_ENV: + process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV, + } + delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + resetMcpStdioEnvironmentCacheForTests() + }) + + afterEach(async () => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + resetMcpStdioEnvironmentCacheForTests() + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('adds PATH entries sourced from the user zshrc when MCP env has no explicit PATH', async () => { + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + await mkdir(nodeBin, { recursive: true }) + await writeFakeZsh(shellPath) + await writeFile( + path.join(tmpDir, '.zshrc'), + [ + `export NVM_DIR="${path.join(tmpDir, '.nvm')}"`, + `export PATH="${nodeBin}:$PATH"`, + '', + ].join('\n'), + ) + + process.env.HOME = tmpDir + process.env.SHELL = shellPath + process.env.PATH = '/usr/bin:/bin' + delete process.env.ZDOTDIR + + const env = await getMcpStdioEnvironment({}) + + expect(env.PATH?.split(path.delimiter)[0]).toBe(nodeBin) + expect(env.PATH?.split(path.delimiter)).toContain('/usr/bin') + expect(env.NVM_DIR).toBe(path.join(tmpDir, '.nvm')) + }) + + it('keeps an explicit MCP PATH instead of reading shell config', async () => { + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + await mkdir(nodeBin, { recursive: true }) + await writeFakeZsh(shellPath) + await writeFile( + path.join(tmpDir, '.zshrc'), + `export PATH="${nodeBin}:$PATH"\n`, + ) + + process.env.HOME = tmpDir + process.env.SHELL = shellPath + process.env.PATH = '/usr/bin:/bin' + + const env = await getMcpStdioEnvironment({ PATH: '/custom/bin' }) + + expect(env.PATH).toBe('/custom/bin') + }) +}) diff --git a/src/utils/mcpStdioEnvironment.ts b/src/utils/mcpStdioEnvironment.ts new file mode 100644 index 00000000..71933eff --- /dev/null +++ b/src/utils/mcpStdioEnvironment.ts @@ -0,0 +1,26 @@ +import { subprocessEnv } from './subprocessEnv.js' +import { + getTerminalShellEnvironment, + mergeTerminalShellEnvironment, + resetTerminalShellEnvironmentCacheForTests, + toStringEnv, +} from './terminalShellEnvironment.js' + +export async function getMcpStdioEnvironment( + configEnv?: Record, +): Promise> { + const baseEnv = toStringEnv(subprocessEnv()) + const explicitPath = configEnv && Object.hasOwn(configEnv, 'PATH') + const shellEnv = explicitPath + ? null + : await getTerminalShellEnvironment(baseEnv) + + return { + ...mergeTerminalShellEnvironment(baseEnv, shellEnv), + ...(configEnv ?? {}), + } +} + +export function resetMcpStdioEnvironmentCacheForTests(): void { + resetTerminalShellEnvironmentCacheForTests() +} diff --git a/src/utils/terminalShellEnvironment.test.ts b/src/utils/terminalShellEnvironment.test.ts new file mode 100644 index 00000000..c7b52b61 --- /dev/null +++ b/src/utils/terminalShellEnvironment.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + getTerminalShellEnvironment, + mergeTerminalShellEnvironment, + resetTerminalShellEnvironmentCacheForTests, +} from './terminalShellEnvironment.js' + +let tmpDir: string + +async function writeFakeZsh(filePath: string) { + await writeFile( + filePath, + [ + '#!/bin/sh', + 'command=', + 'while [ "$#" -gt 0 ]; do', + ' if [ "$1" = "-c" ]; then', + ' shift', + ' command="$1"', + ' break', + ' fi', + ' shift', + 'done', + 'if [ -f "$HOME/.zshrc" ]; then', + ' . "$HOME/.zshrc" /dev/null 2>/dev/null || true', + 'fi', + 'exec /bin/sh -c "$command"', + '', + ].join('\n'), + { mode: 0o755 }, + ) +} + +describe('terminal shell environment', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'terminal-shell-env-test-')) + resetTerminalShellEnvironmentCacheForTests() + }) + + afterEach(async () => { + resetTerminalShellEnvironmentCacheForTests() + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('captures exported variables from an interactive user shell', async () => { + const shellPath = path.join(tmpDir, 'zsh') + const nodeBin = path.join(tmpDir, 'node-bin') + const nvmDir = path.join(tmpDir, '.nvm') + await mkdir(nodeBin, { recursive: true }) + await mkdir(nvmDir, { recursive: true }) + await writeFakeZsh(shellPath) + await writeFile( + path.join(tmpDir, '.zshrc'), + [ + `export NVM_DIR="${nvmDir}"`, + `export PATH="${nodeBin}:$PATH"`, + '', + ].join('\n'), + ) + + const env = await getTerminalShellEnvironment({ + HOME: tmpDir, + SHELL: shellPath, + PATH: '/usr/bin:/bin', + }) + + expect(env?.NVM_DIR).toBe(nvmDir) + expect(env?.PATH?.split(path.delimiter)[0]).toBe(nodeBin) + }) + + it('merges shell PATH before base PATH while preserving app env overrides', () => { + const merged = mergeTerminalShellEnvironment( + { + PATH: '/usr/bin:/bin', + CC_HAHA_DESKTOP_SERVER_URL: 'http://127.0.0.1:3456', + TOOL_HOME: '/base/tool', + }, + { + PATH: '/opt/homebrew/bin:/usr/bin', + NVM_DIR: '/Users/test/.nvm', + TOOL_HOME: '/shell/tool', + }, + ) + + expect(merged.PATH).toBe('/opt/homebrew/bin:/usr/bin:/bin') + expect(merged.NVM_DIR).toBe('/Users/test/.nvm') + expect(merged.TOOL_HOME).toBe('/base/tool') + expect(merged.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456') + }) + + it('can be disabled for deterministic tests and controlled environments', async () => { + const env = await getTerminalShellEnvironment({ + HOME: tmpDir, + SHELL: path.join(tmpDir, 'zsh'), + PATH: '/usr/bin:/bin', + CC_HAHA_DISABLE_TERMINAL_SHELL_ENV: '1', + }) + + expect(env).toBeNull() + }) +}) diff --git a/src/utils/terminalShellEnvironment.ts b/src/utils/terminalShellEnvironment.ts new file mode 100644 index 00000000..f13367a8 --- /dev/null +++ b/src/utils/terminalShellEnvironment.ts @@ -0,0 +1,191 @@ +import { execFile } from 'node:child_process' +import { constants } from 'node:fs' +import { access } from 'node:fs/promises' +import path from 'node:path' +import { isEnvTruthy } from './envUtils.js' +import { logForDebugging } from './debug.js' + +const TERMINAL_SHELL_ENV_TIMEOUT_MS = 5000 +const TERMINAL_ENV_MARKER = '__CC_HAHA_TERMINAL_ENV_START__' + +let cachedTerminalShellEnv: + | Promise | null> + | undefined + +export function toStringEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ) +} + +function quoteForShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function getShellKind(shellPath: string): 'zsh' | 'bash' | null { + const basename = path.basename(shellPath) + if (basename.includes('zsh')) return 'zsh' + if (basename.includes('bash')) return 'bash' + return null +} + +async function isExecutable(filePath: string): Promise { + try { + await access(filePath, constants.X_OK) + return true + } catch { + return false + } +} + +async function findSupportedShell( + env: Record, +): Promise { + const candidates = [ + env.SHELL, + '/bin/zsh', + '/usr/bin/zsh', + '/bin/bash', + '/usr/bin/bash', + ].filter((candidate): candidate is string => Boolean(candidate)) + + for (const candidate of candidates) { + if (!getShellKind(candidate)) continue + if (await isExecutable(candidate)) { + return candidate + } + } + + return null +} + +export function mergePathLists(...values: Array): string | null { + const seen = new Set() + const merged: string[] = [] + + for (const value of values) { + for (const entry of (value ?? '').split(path.delimiter)) { + const trimmed = entry.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + merged.push(trimmed) + } + } + + return merged.length > 0 ? merged.join(path.delimiter) : null +} + +function parseNullDelimitedEnv( + stdout: string, + marker: string, +): Record { + const markerToken = `${marker}\0` + const markerIndex = stdout.indexOf(markerToken) + const envOutput = + markerIndex === -1 + ? stdout + : stdout.slice(markerIndex + markerToken.length) + const env: Record = {} + + for (const entry of envOutput.split('\0')) { + if (!entry) continue + const equals = entry.indexOf('=') + if (equals <= 0) continue + env[entry.slice(0, equals)] = entry.slice(equals + 1) + } + + return env +} + +async function captureTerminalShellEnvironment( + baseEnv: Record, +): Promise | null> { + if ( + process.platform === 'win32' || + isEnvTruthy(baseEnv.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV) + ) { + return null + } + + const shellPath = await findSupportedShell(baseEnv) + if (!shellPath) { + return null + } + + const script = [ + `printf '%s\\0' ${quoteForShell(TERMINAL_ENV_MARKER)}`, + 'env -0', + ].join('\n') + const captureEnv = { + ...baseEnv, + SHELL: shellPath, + PS1: '', + PROMPT: '', + } + delete captureEnv.BASH_ENV + delete captureEnv.ENV + + return await new Promise(resolve => { + execFile( + shellPath, + ['-l', '-i', '-c', script], + { + env: captureEnv, + timeout: TERMINAL_SHELL_ENV_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + encoding: 'utf8', + windowsHide: true, + }, + (error, stdout) => { + if (error) { + logForDebugging( + `Failed to capture terminal shell environment from ${shellPath}: ${error.message}`, + ) + resolve(null) + return + } + + resolve(parseNullDelimitedEnv(stdout, TERMINAL_ENV_MARKER)) + }, + ) + }) +} + +export async function getTerminalShellEnvironment( + baseEnv: Record = toStringEnv(process.env), +): Promise | null> { + cachedTerminalShellEnv ??= captureTerminalShellEnvironment(baseEnv) + return cachedTerminalShellEnv +} + +export function mergeTerminalShellEnvironment( + baseEnv: Record, + shellEnv: Record | null, +): Record { + if (!shellEnv) { + return { ...baseEnv } + } + + const mergedPath = mergePathLists(shellEnv.PATH, baseEnv.PATH) + return { + ...shellEnv, + ...baseEnv, + ...(mergedPath ? { PATH: mergedPath } : {}), + } +} + +export async function getProcessEnvWithTerminalShellEnvironment(): Promise< + Record +> { + const baseEnv = toStringEnv(process.env) + return mergeTerminalShellEnvironment( + baseEnv, + await getTerminalShellEnvironment(baseEnv), + ) +} + +export function resetTerminalShellEnvironmentCacheForTests(): void { + cachedTerminalShellEnv = undefined +}