Merge terminal shell env fix into main

The worktree fix diverged from local main because main already contains local desktop update proxy work. Merge the completed worktree commit into main so the terminal-like environment behavior lands without rewriting either line of local history.

Constraint: Local main is not an ancestor of the worktree commit, so fast-forward merge is unavailable
Rejected: Rebase or cherry-pick the worktree commit | user requested a worktree-to-main merge
Confidence: high
Scope-risk: moderate
Directive: Preserve the terminal shell env merge order when touching desktop subprocess startup
Tested: bun run verify
Not-tested: Push or remote PR validation
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 15:15:17 +08:00
commit 8cd8ed16f0
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
}