mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
8ff5353455
commit
af5175aa2a
@ -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 })
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)) {
|
||||
|
||||
157
src/server/services/mcpHostPreflight.test.ts
Normal file
157
src/server/services/mcpHostPreflight.test.ts
Normal 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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
|
||||
@ -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 {
|
||||
|
||||
117
src/utils/mcpStdioEnvironment.test.ts
Normal file
117
src/utils/mcpStdioEnvironment.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
26
src/utils/mcpStdioEnvironment.ts
Normal file
26
src/utils/mcpStdioEnvironment.ts
Normal 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()
|
||||
}
|
||||
104
src/utils/terminalShellEnvironment.test.ts
Normal file
104
src/utils/terminalShellEnvironment.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
191
src/utils/terminalShellEnvironment.ts
Normal file
191
src/utils/terminalShellEnvironment.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user