mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-31 08:29:07 +08:00
feat: refactor shell environment handling and move to utils
This commit is contained in:
parent
0a80fc5517
commit
781b01ee17
@ -7,6 +7,7 @@ import { createInMemoryMCPServer } from '@main/mcpServers/factory'
|
||||
import { makeSureDirExists, removeEnvProxy } from '@main/utils'
|
||||
import { buildFunctionCallToolName } from '@main/utils/mcp'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import getLoginShellEnvironment from '@main/utils/shell-env'
|
||||
import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
@ -43,14 +44,12 @@ import {
|
||||
} from '@types'
|
||||
import { app, net } from 'electron'
|
||||
import { EventEmitter } from 'events'
|
||||
import { memoize } from 'lodash'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import DxtService from './DxtService'
|
||||
import { CallBackServer } from './mcp/oauth/callback'
|
||||
import { McpOAuthClientProvider } from './mcp/oauth/provider'
|
||||
import getLoginShellEnvironment from './mcp/shell-env'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
// Generic type for caching wrapped functions
|
||||
@ -335,7 +334,7 @@ class McpService {
|
||||
|
||||
getServerLogger(server).debug(`Starting server`, { command: cmd, args })
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
const loginShellEnv = await this.getLoginShellEnv()
|
||||
const loginShellEnv = await getLoginShellEnvironment()
|
||||
|
||||
// Bun not support proxy https://github.com/oven-sh/bun/issues/16812
|
||||
if (cmd.includes('bun')) {
|
||||
@ -878,20 +877,6 @@ class McpService {
|
||||
return await cachedGetResource(server, uri)
|
||||
}
|
||||
|
||||
private getLoginShellEnv = memoize(async (): Promise<Record<string, string>> => {
|
||||
try {
|
||||
const loginEnv = await getLoginShellEnvironment()
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':'
|
||||
const cherryBinPath = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
loginEnv.PATH = `${loginEnv.PATH}${pathSeparator}${cherryBinPath}`
|
||||
logger.debug('Successfully fetched login shell environment variables:')
|
||||
return loginEnv
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch login shell environment variables:', error as Error)
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// 实现 abortTool 方法
|
||||
public async abortTool(_: Electron.IpcMainInvokeEvent, callId: string) {
|
||||
const activeToolCall = this.activeToolCalls.get(callId)
|
||||
|
||||
@ -6,6 +6,7 @@ import { McpHttpServerConfig, Options, query, SDKMessage } from '@anthropic-ai/c
|
||||
import { loggerService } from '@logger'
|
||||
import { config as apiConfigService } from '@main/apiServer/config'
|
||||
import { validateModelId } from '@main/apiServer/utils'
|
||||
import getLoginShellEnvironment from '@main/utils/shell-env'
|
||||
import { app } from 'electron'
|
||||
|
||||
import { GetAgentSessionResponse } from '../..'
|
||||
@ -71,8 +72,10 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
const apiConfig = await apiConfigService.get()
|
||||
// process.env.ANTHROPIC_AUTH_TOKEN = apiConfig.apiKey
|
||||
// process.env.ANTHROPIC_BASE_URL = `http://${apiConfig.host}:${apiConfig.port}`
|
||||
|
||||
const loginShellEnv = await getLoginShellEnvironment()
|
||||
const env = {
|
||||
...process.env,
|
||||
...loginShellEnv,
|
||||
ANTHROPIC_API_KEY: apiConfig.apiKey,
|
||||
ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { spawn } from 'child_process'
|
||||
import os from 'os'
|
||||
|
||||
const logger = loggerService.withContext('ShellEnv')
|
||||
|
||||
/**
|
||||
* Spawns a login shell in the user's home directory to capture its environment variables.
|
||||
* @returns {Promise<Object>} A promise that resolves with an object containing
|
||||
* the environment variables, or rejects with an error.
|
||||
*/
|
||||
function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const homeDirectory = os.homedir()
|
||||
if (!homeDirectory) {
|
||||
return reject(new Error("Could not determine user's home directory."))
|
||||
}
|
||||
|
||||
let shellPath = process.env.SHELL
|
||||
let commandArgs
|
||||
let shellCommandToGetEnv
|
||||
|
||||
const platform = os.platform()
|
||||
|
||||
if (platform === 'win32') {
|
||||
// On Windows, 'cmd.exe' is the common shell.
|
||||
// The 'set' command lists environment variables.
|
||||
// We don't typically talk about "login shells" in the same way,
|
||||
// but cmd will load the user's environment.
|
||||
shellPath = process.env.COMSPEC || 'cmd.exe'
|
||||
shellCommandToGetEnv = 'set'
|
||||
commandArgs = ['/c', shellCommandToGetEnv] // /c Carries out the command specified by string and then terminates
|
||||
} else {
|
||||
// For POSIX systems (Linux, macOS)
|
||||
if (!shellPath) {
|
||||
// Fallback if process.env.SHELL is not set (less common for interactive users)
|
||||
// Defaulting to bash, but this might not be the user's actual login shell.
|
||||
// A more robust solution might involve checking /etc/passwd or similar,
|
||||
// but that's more complex and often requires higher privileges or native modules.
|
||||
logger.warn("process.env.SHELL is not set. Defaulting to /bin/bash. This might not be the user's login shell.")
|
||||
shellPath = '/bin/bash' // A common default
|
||||
}
|
||||
// -l: Make it a login shell. This sources profile files like .profile, .bash_profile, .zprofile etc.
|
||||
// -i: Make it interactive. Some shells or profile scripts behave differently.
|
||||
// 'env': The command to print environment variables.
|
||||
// Using 'env -0' would be more robust for parsing if values contain newlines,
|
||||
// but requires splitting by null character. For simplicity, we'll use 'env'.
|
||||
shellCommandToGetEnv = 'env'
|
||||
commandArgs = ['-ilc', shellCommandToGetEnv] // -i for interactive, -l for login, -c to execute command
|
||||
}
|
||||
|
||||
logger.debug(`Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`)
|
||||
|
||||
const child = spawn(shellPath, commandArgs, {
|
||||
cwd: homeDirectory, // Run the command in the user's home directory
|
||||
detached: true, // Allows the parent to exit independently of the child
|
||||
stdio: ['ignore', 'pipe', 'pipe'], // stdin, stdout, stderr
|
||||
shell: false // We are specifying the shell command directly
|
||||
})
|
||||
|
||||
let output = ''
|
||||
let errorOutput = ''
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString()
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString()
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
logger.error(`Failed to start shell process: ${shellPath}`, error)
|
||||
reject(new Error(`Failed to start shell: ${error.message}`))
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
const errorMessage = `Shell process exited with code ${code}. Shell: ${shellPath}. Args: ${commandArgs.join(' ')}. CWD: ${homeDirectory}. Stderr: ${errorOutput.trim()}`
|
||||
logger.error(errorMessage)
|
||||
return reject(new Error(errorMessage))
|
||||
}
|
||||
|
||||
if (errorOutput.trim()) {
|
||||
// Some shells might output warnings or non-fatal errors to stderr
|
||||
// during profile loading. Log it, but proceed if exit code is 0.
|
||||
logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`)
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
const lines = output.split('\n')
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim()
|
||||
if (trimmedLine) {
|
||||
const separatorIndex = trimmedLine.indexOf('=')
|
||||
if (separatorIndex > 0) {
|
||||
// Ensure '=' is present and it's not the first character
|
||||
const key = trimmedLine.substring(0, separatorIndex)
|
||||
const value = trimmedLine.substring(separatorIndex + 1)
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(env).length === 0 && output.length < 100) {
|
||||
// Arbitrary small length check
|
||||
// This might indicate an issue if no env vars were parsed or output was minimal
|
||||
logger.warn(
|
||||
'Parsed environment is empty or output was very short. This might indicate an issue with shell execution or environment variable retrieval.'
|
||||
)
|
||||
logger.warn(`Raw output from shell:\n${output}`)
|
||||
}
|
||||
|
||||
env.PATH = env.Path || env.PATH || ''
|
||||
|
||||
resolve(env)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default getLoginShellEnvironment
|
||||
260
src/main/utils/shell-env.ts
Normal file
260
src/main/utils/shell-env.ts
Normal file
@ -0,0 +1,260 @@
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { isMac, isWin } from '@main/constant'
|
||||
import { spawn } from 'child_process'
|
||||
import { memoize } from 'lodash'
|
||||
|
||||
const logger = loggerService.withContext('ShellEnv')
|
||||
|
||||
// Give shells enough time to source profile files, but fail fast when they hang.
|
||||
const SHELL_ENV_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Ensures the Cherry Studio bin directory is appended to the user's PATH while
|
||||
* preserving the original key casing and avoiding duplicate segments.
|
||||
*/
|
||||
const appendCherryBinToPath = (env: Record<string, string>) => {
|
||||
const pathSeparator = isWin ? ';' : ':'
|
||||
const homeDirFromEnv = env.HOME || env.Home || env.USERPROFILE || env.UserProfile || os.homedir()
|
||||
const cherryBinPath = path.join(homeDirFromEnv, '.cherrystudio', 'bin')
|
||||
const pathKeys = Object.keys(env).filter((key) => key.toLowerCase() === 'path')
|
||||
const canonicalPathKey = pathKeys[0] || (isWin ? 'Path' : 'PATH')
|
||||
const existingPathValue = env[canonicalPathKey] || env.PATH || ''
|
||||
|
||||
const normaliseSegment = (segment: string) => {
|
||||
const normalized = path.normalize(segment)
|
||||
return isWin ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
const uniqueSegments: string[] = []
|
||||
const seenSegments = new Set<string>()
|
||||
const pushIfUnique = (segment: string) => {
|
||||
if (!segment) {
|
||||
return
|
||||
}
|
||||
const canonicalSegment = normaliseSegment(segment)
|
||||
if (!seenSegments.has(canonicalSegment)) {
|
||||
seenSegments.add(canonicalSegment)
|
||||
uniqueSegments.push(segment)
|
||||
}
|
||||
}
|
||||
|
||||
existingPathValue
|
||||
.split(pathSeparator)
|
||||
.map((segment) => segment.trim())
|
||||
.forEach(pushIfUnique)
|
||||
|
||||
pushIfUnique(cherryBinPath)
|
||||
|
||||
const updatedPath = uniqueSegments.join(pathSeparator)
|
||||
|
||||
if (pathKeys.length > 0) {
|
||||
pathKeys.forEach((key) => {
|
||||
env[key] = updatedPath
|
||||
})
|
||||
} else {
|
||||
env[canonicalPathKey] = updatedPath
|
||||
}
|
||||
|
||||
if (!isWin) {
|
||||
env.PATH = updatedPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a login shell in the user's home directory to capture its environment variables.
|
||||
*
|
||||
* We explicitly run a login + interactive shell so it sources the same init files that a user
|
||||
* would typically rely on inside their terminal. Many CLIs export PATH or other variables from
|
||||
* these scripts; capturing them keeps spawned processes aligned with the user’s expectations.
|
||||
*
|
||||
* Timeout handling is important because profile scripts might block forever (e.g. misconfigured
|
||||
* `read` or prompts). We proactively kill the shell and surface an error in that case so that
|
||||
* the app does not hang.
|
||||
* @returns {Promise<Object>} A promise that resolves with an object containing
|
||||
* the environment variables, or rejects with an error.
|
||||
*/
|
||||
function getLoginShellEnvironment(): Promise<Record<string, string>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const homeDirectory =
|
||||
process.env.HOME || process.env.Home || process.env.USERPROFILE || process.env.UserProfile || os.homedir()
|
||||
if (!homeDirectory) {
|
||||
return reject(new Error("Could not determine user's home directory."))
|
||||
}
|
||||
|
||||
let shellPath = process.env.SHELL
|
||||
let commandArgs
|
||||
let shellCommandToGetEnv
|
||||
|
||||
if (isWin) {
|
||||
// On Windows, 'cmd.exe' is the common shell.
|
||||
// The 'set' command lists environment variables.
|
||||
// We don't typically talk about "login shells" in the same way,
|
||||
// but cmd will load the user's environment.
|
||||
shellPath = process.env.COMSPEC || 'cmd.exe'
|
||||
shellCommandToGetEnv = 'set'
|
||||
commandArgs = ['/c', shellCommandToGetEnv] // /c Carries out the command specified by string and then terminates
|
||||
} else {
|
||||
// For POSIX systems (Linux, macOS)
|
||||
if (!shellPath) {
|
||||
// Fallback if process.env.SHELL is not set (less common for interactive users)
|
||||
// A more robust solution might involve checking /etc/passwd or similar,
|
||||
// but that's more complex and often requires higher privileges or native modules.
|
||||
if (isMac) {
|
||||
// macOS defaults to zsh since Catalina (10.15)
|
||||
logger.warn(
|
||||
"process.env.SHELL is not set. Defaulting to /bin/zsh for macOS. This might not be the user's login shell."
|
||||
)
|
||||
shellPath = '/bin/zsh'
|
||||
} else {
|
||||
// Other POSIX systems (Linux) default to bash
|
||||
logger.warn(
|
||||
"process.env.SHELL is not set. Defaulting to /bin/bash. This might not be the user's login shell."
|
||||
)
|
||||
shellPath = '/bin/bash'
|
||||
}
|
||||
}
|
||||
// -l: Make it a login shell. This sources profile files like .profile, .bash_profile, .zprofile etc.
|
||||
// -i: Make it interactive. Some shells or profile scripts behave differently.
|
||||
// 'env': The command to print environment variables.
|
||||
// Using 'env -0' would be more robust for parsing if values contain newlines,
|
||||
// but requires splitting by null character. For simplicity, we'll use 'env'.
|
||||
shellCommandToGetEnv = 'env'
|
||||
commandArgs = ['-ilc', shellCommandToGetEnv] // -i for interactive, -l for login, -c to execute command
|
||||
}
|
||||
|
||||
logger.debug(`Spawning shell: ${shellPath} with args: ${commandArgs.join(' ')} in ${homeDirectory}`)
|
||||
|
||||
let settled = false
|
||||
let timeoutId: NodeJS.Timeout | undefined
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const resolveOnce = (value: Record<string, string>) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const child = spawn(shellPath, commandArgs, {
|
||||
cwd: homeDirectory, // Run the command in the user's home directory
|
||||
detached: false, // Stay attached so we can clean up reliably
|
||||
stdio: ['ignore', 'pipe', 'pipe'], // stdin, stdout, stderr
|
||||
shell: false // We are specifying the shell command directly
|
||||
})
|
||||
|
||||
let output = ''
|
||||
let errorOutput = ''
|
||||
|
||||
// Protects against shells that wait for user input or hang during profile sourcing.
|
||||
timeoutId = setTimeout(() => {
|
||||
const errorMessage = `Timed out after ${SHELL_ENV_TIMEOUT_MS}ms while retrieving shell environment. Shell: ${shellPath}. Args: ${commandArgs.join(
|
||||
' '
|
||||
)}. CWD: ${homeDirectory}`
|
||||
logger.error(errorMessage)
|
||||
child.kill()
|
||||
rejectOnce(new Error(errorMessage))
|
||||
}, SHELL_ENV_TIMEOUT_MS)
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data.toString()
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString()
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
logger.error(`Failed to start shell process: ${shellPath}`, error)
|
||||
rejectOnce(new Error(`Failed to start shell: ${error.message}`))
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
const errorMessage = `Shell process exited with code ${code}. Shell: ${shellPath}. Args: ${commandArgs.join(' ')}. CWD: ${homeDirectory}. Stderr: ${errorOutput.trim()}`
|
||||
logger.error(errorMessage)
|
||||
return rejectOnce(new Error(errorMessage))
|
||||
}
|
||||
|
||||
if (errorOutput.trim()) {
|
||||
// Some shells might output warnings or non-fatal errors to stderr
|
||||
// during profile loading. Log it, but proceed if exit code is 0.
|
||||
logger.warn(`Shell process stderr output (even with exit code 0):\n${errorOutput.trim()}`)
|
||||
}
|
||||
|
||||
// Convert each VAR=VALUE line into our env map.
|
||||
const env: Record<string, string> = {}
|
||||
const lines = output.split(/\r?\n/)
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmedLine = line.trim()
|
||||
if (trimmedLine) {
|
||||
const separatorIndex = trimmedLine.indexOf('=')
|
||||
if (separatorIndex > 0) {
|
||||
// Ensure '=' is present and it's not the first character
|
||||
const key = trimmedLine.substring(0, separatorIndex)
|
||||
const value = trimmedLine.substring(separatorIndex + 1)
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(env).length === 0 && output.length < 100) {
|
||||
// Arbitrary small length check
|
||||
// This might indicate an issue if no env vars were parsed or output was minimal
|
||||
logger.warn(
|
||||
'Parsed environment is empty or output was very short. This might indicate an issue with shell execution or environment variable retrieval.'
|
||||
)
|
||||
logger.warn(`Raw output from shell:\n${output}`)
|
||||
}
|
||||
|
||||
appendCherryBinToPath(env)
|
||||
|
||||
resolveOnce(env)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const memoizedGetShellEnvs = memoize(async () => {
|
||||
try {
|
||||
return await getLoginShellEnvironment()
|
||||
} catch (error) {
|
||||
logger.error('Failed to get shell environment, falling back to process.env', { error })
|
||||
// Fallback to current process environment with cherry studio bin path
|
||||
const fallbackEnv: Record<string, string> = {}
|
||||
for (const key in process.env) {
|
||||
fallbackEnv[key] = process.env[key] || ''
|
||||
}
|
||||
appendCherryBinToPath(fallbackEnv)
|
||||
return fallbackEnv
|
||||
}
|
||||
})
|
||||
|
||||
export default memoizedGetShellEnvs
|
||||
|
||||
export const refreshShellEnvCache = () => {
|
||||
memoizedGetShellEnvs.cache.clear?.()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user