mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
commit
8cd8ed16f0
@ -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