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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 15:13:06 +08:00
parent 8ff5353455
commit af5175aa2a
11 changed files with 825 additions and 12 deletions

View File

@ -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 >/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<string, string>
@ -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<string, string>
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 })

View File

@ -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 >/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)
})
})

View File

@ -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) {

View File

@ -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<Record<string, string | undefined>> {
const cleanEnv = { ...process.env }
const cleanEnv = await getProcessEnvWithTerminalShellEnvironment()
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN
if (this.shouldStripInheritedProviderEnv(task.providerId)) {

View File

@ -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 >/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.',
})
})
})

View File

@ -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),

View File

@ -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<string, string>,
env: stdioEnv,
stderr: 'pipe', // prevents error output from the MCP server from printing to the UI
})
} else {

View File

@ -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 >/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')
})
})

View File

@ -0,0 +1,26 @@
import { subprocessEnv } from './subprocessEnv.js'
import {
getTerminalShellEnvironment,
mergeTerminalShellEnvironment,
resetTerminalShellEnvironmentCacheForTests,
toStringEnv,
} from './terminalShellEnvironment.js'
export async function getMcpStdioEnvironment(
configEnv?: Record<string, string>,
): Promise<Record<string, string>> {
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()
}

View File

@ -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 >/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()
})
})

View File

@ -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<Record<string, string> | null>
| undefined
export function toStringEnv(env: NodeJS.ProcessEnv): Record<string, string> {
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<boolean> {
try {
await access(filePath, constants.X_OK)
return true
} catch {
return false
}
}
async function findSupportedShell(
env: Record<string, string>,
): Promise<string | null> {
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 | undefined>): string | null {
const seen = new Set<string>()
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<string, string> {
const markerToken = `${marker}\0`
const markerIndex = stdout.indexOf(markerToken)
const envOutput =
markerIndex === -1
? stdout
: stdout.slice(markerIndex + markerToken.length)
const env: Record<string, string> = {}
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<string, string>,
): Promise<Record<string, string> | 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<string, string> = toStringEnv(process.env),
): Promise<Record<string, string> | null> {
cachedTerminalShellEnv ??= captureTerminalShellEnvironment(baseEnv)
return cachedTerminalShellEnv
}
export function mergeTerminalShellEnvironment(
baseEnv: Record<string, string>,
shellEnv: Record<string, string> | null,
): Record<string, string> {
if (!shellEnv) {
return { ...baseEnv }
}
const mergedPath = mergePathLists(shellEnv.PATH, baseEnv.PATH)
return {
...shellEnv,
...baseEnv,
...(mergedPath ? { PATH: mergedPath } : {}),
}
}
export async function getProcessEnvWithTerminalShellEnvironment(): Promise<
Record<string, string>
> {
const baseEnv = toStringEnv(process.env)
return mergeTerminalShellEnvironment(
baseEnv,
await getTerminalShellEnvironment(baseEnv),
)
}
export function resetTerminalShellEnvironmentCacheForTests(): void {
cachedTerminalShellEnv = undefined
}