diff --git a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts index dc3c99227..93d3c8db7 100644 --- a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts +++ b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts @@ -3,7 +3,7 @@ import fs from 'fs' import path from 'path' import { nanoid } from 'nanoid' import logger from '@shared/logger' -import { getShellEnvironment, getUserShell } from './shellEnvHelper' +import { getUserShell } from './shellEnvHelper' import { terminateProcessTree } from './processTree' import { resolveSessionDir } from './sessionPaths' @@ -121,7 +121,6 @@ export class BackgroundExecSessionManager { const config = getConfig() const sessionId = `bg_${nanoid(12)}` const { shell, args } = getUserShell() - const shellEnv = await getShellEnvironment() const sessionDir = resolveSessionDir(conversationId) if (sessionDir) { @@ -134,11 +133,7 @@ export class BackgroundExecSessionManager { const child = spawn(shell, [...args, command], { cwd, - env: { - ...process.env, - ...shellEnv, - ...options?.env - }, + env: { ...process.env, ...(options?.env ?? {}) }, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] }) diff --git a/src/main/lib/agentRuntime/rtkRuntimeService.ts b/src/main/lib/agentRuntime/rtkRuntimeService.ts index 6dab3badf..26cd190c1 100644 --- a/src/main/lib/agentRuntime/rtkRuntimeService.ts +++ b/src/main/lib/agentRuntime/rtkRuntimeService.ts @@ -12,7 +12,7 @@ import type { UsageDashboardRtkSummary } from '@shared/types/agent-interface' import logger from '@shared/logger' -import { getShellEnvironment } from './shellEnvHelper' +import { getShellEnvironment, mergeCommandEnvironment } from './shellEnvHelper' import { RuntimeHelper } from '../runtimeHelper' const RTK_ENABLED_SETTING_KEY = 'rtkEnabled' @@ -650,15 +650,12 @@ export class RtkRuntimeService { dbPath?: string ): Promise> { const shellEnv = await this.getShellEnvironmentImpl() - const env = this.runtimeHelper.prependBundledRuntimeToEnv({ - ...Object.fromEntries( - Object.entries(process.env).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) - ), - ...shellEnv, - ...baseEnv - }) + const env = this.runtimeHelper.prependBundledRuntimeToEnv( + mergeCommandEnvironment({ + shellEnv, + overrides: baseEnv + }) + ) if (dbPath) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }) diff --git a/src/main/lib/agentRuntime/shellEnvHelper.ts b/src/main/lib/agentRuntime/shellEnvHelper.ts index a8a502a25..9f075450f 100644 --- a/src/main/lib/agentRuntime/shellEnvHelper.ts +++ b/src/main/lib/agentRuntime/shellEnvHelper.ts @@ -1,19 +1,265 @@ import { spawn } from 'child_process' +import { app } from 'electron' import * as path from 'path' +import { RuntimeHelper } from '../runtimeHelper' + +const runtimeHelper = RuntimeHelper.getInstance() + +const PATH_ENV_KEYS = ['PATH', 'Path', 'path'] as const +const NODE_ENV_KEYS = [ + 'NVM_DIR', + 'NVM_CD_FLAGS', + 'NVM_BIN', + 'NODE_PATH', + 'NODE_VERSION', + 'FNM_DIR', + 'VOLTA_HOME', + 'N_PREFIX' +] as const +const NPM_ENV_KEYS = [ + 'npm_config_registry', + 'npm_config_cache', + 'npm_config_prefix', + 'npm_config_tmp', + 'NPM_CONFIG_REGISTRY', + 'NPM_CONFIG_CACHE', + 'NPM_CONFIG_PREFIX', + 'NPM_CONFIG_TMP' +] as const +const RELEVANT_ENV_KEYS = [...PATH_ENV_KEYS, ...NODE_ENV_KEYS, ...NPM_ENV_KEYS] as const -// Memory cache for shell environment variables let cachedShellEnv: Record | null = null -const TIMEOUT_MS = 3000 // 3 seconds timeout +const TIMEOUT_MS = 8000 + +function getPathSeparator(): string { + return process.platform === 'win32' ? ';' : ':' +} + +function getPrimaryPathKey(): 'PATH' | 'Path' { + return process.platform === 'win32' ? 'Path' : 'PATH' +} + +function toStringEnvEntries( + input: NodeJS.ProcessEnv | Record | undefined +): Record { + if (!input) { + return {} + } + + return Object.fromEntries( + Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ) +} + +function pickRelevantEnvironment( + input: NodeJS.ProcessEnv | Record | undefined +): Record { + const env = toStringEnvEntries(input) + const filtered: Record = {} + + for (const key of RELEVANT_ENV_KEYS) { + const value = env[key] + if (typeof value === 'string' && value !== '') { + filtered[key] = value + } + } + + return filtered +} + +function getDefaultPathEntries(): string[] { + try { + return runtimeHelper.getDefaultPaths(app.getPath('home')) + } catch { + const homeDir = process.env.HOME || process.env.USERPROFILE || '' + return homeDir ? runtimeHelper.getDefaultPaths(homeDir) : [] + } +} + +export function getPathEntriesFromEnv( + input: NodeJS.ProcessEnv | Record | undefined +): string[] { + const env = toStringEnvEntries(input) + const separator = getPathSeparator() + const entries: string[] = [] + + for (const key of PATH_ENV_KEYS) { + const value = env[key] + if (typeof value !== 'string' || value.length === 0) { + continue + } + entries.push(...value.split(separator)) + } + + return entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0) +} + +export function mergePathEntries( + pathSources: Array, + options: { + includeDefaultPaths?: boolean + defaultPaths?: string[] + } = {} +): { key: 'PATH' | 'Path'; value: string; entries: string[] } { + const entries: string[] = [] + const separator = getPathSeparator() + const includeDefaultPaths = options.includeDefaultPaths !== false + + for (const source of pathSources) { + if (!source) { + continue + } + + if (Array.isArray(source)) { + entries.push(...source) + continue + } + + entries.push(...source.split(separator)) + } + + if (includeDefaultPaths) { + entries.push(...(options.defaultPaths ?? getDefaultPathEntries())) + } + + const seen = new Set() + const deduped = entries + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry) => { + const normalized = process.platform === 'win32' ? entry.toLowerCase() : entry + if (seen.has(normalized)) { + return false + } + seen.add(normalized) + return true + }) + + return { + key: getPrimaryPathKey(), + value: deduped.join(separator), + entries: deduped + } +} + +export function setPathEntriesOnEnv( + env: Record, + pathSources: Array, + options: { + includeDefaultPaths?: boolean + defaultPaths?: string[] + } = {} +): Record { + const normalized = mergePathEntries(pathSources, options) + + delete env.PATH + delete env.Path + delete env.path + + if (normalized.value.length > 0) { + env[normalized.key] = normalized.value + if (process.platform === 'win32') { + env.PATH = normalized.value + env.Path = normalized.value + } + } + + return env +} + +export function mergeCommandEnvironment( + options: { + processEnv?: NodeJS.ProcessEnv | Record + shellEnv?: Record + overrides?: Record + prependPathSources?: Array + includeDefaultPaths?: boolean + } = {} +): Record { + const processEnv = toStringEnvEntries(options.processEnv ?? process.env) + const shellEnv = pickRelevantEnvironment(options.shellEnv) + const overrides = toStringEnvEntries(options.overrides) + const env: Record = { + ...processEnv, + ...shellEnv + } + + for (const [key, value] of Object.entries(overrides)) { + if (PATH_ENV_KEYS.includes(key as (typeof PATH_ENV_KEYS)[number])) { + continue + } + env[key] = value + } + + setPathEntriesOnEnv( + env, + [ + ...(options.prependPathSources ?? []), + getPathEntriesFromEnv(overrides), + getPathEntriesFromEnv(shellEnv), + getPathEntriesFromEnv(processEnv) + ], + { + includeDefaultPaths: options.includeDefaultPaths + } + ) + + return env +} + +function getShellBootstrapMarkers() { + const suffix = `${process.pid}_${Date.now().toString(36)}` + return { + start: `__DEEPCHAT_SHELL_ENV_START_${suffix}__`, + end: `__DEEPCHAT_SHELL_ENV_END_${suffix}__` + } +} + +function buildShellBootstrapCommand(startMarker: string, endMarker: string): string { + return `printf '%s\\n' '${startMarker}'; env; printf '%s\\n' '${endMarker}'` +} + +function parseShellBootstrapOutput(output: string, startMarker: string, endMarker: string) { + const lines = output.split(/\r?\n/) + const startIndex = lines.findIndex((line) => line.trim() === startMarker) + const endIndex = lines.findIndex((line, index) => index > startIndex && line.trim() === endMarker) + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + throw new Error('Missing shell environment markers in bootstrap output') + } + + const env: Record = {} + for (const line of lines.slice(startIndex + 1, endIndex)) { + const separatorIndex = line.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = line.slice(0, separatorIndex).trim() + const value = line.slice(separatorIndex + 1).trim() + if (key.length > 0) { + env[key] = value + } + } + + return env +} + +function getShellBootstrapArgs(shellPath: string, command: string): string[] { + const shellName = path.basename(shellPath).toLowerCase() + + if (shellName.includes('fish') || shellName.includes('zsh') || shellName.includes('bash')) { + return ['-l', '-i', '-c', command] + } + + return ['-l', '-c', command] +} -/** - * Get user's default shell - */ export function getUserShell(): { shell: string; args: string[] } { const platform = process.platform if (platform === 'win32') { - // Windows: use PowerShell or cmd.exe const powershell = process.env.PSModulePath ? 'powershell.exe' : null if (powershell) { return { shell: powershell, args: ['-NoProfile', '-Command'] } @@ -21,56 +267,30 @@ export function getUserShell(): { shell: string; args: string[] } { return { shell: 'cmd.exe', args: ['/c'] } } - // Unix-like: use SHELL env var or default to bash - const shell = process.env.SHELL || '/bin/bash' - if (shell.includes('bash')) { - return { shell, args: ['-c'] } - } - if (shell.includes('zsh')) { - return { shell, args: ['-c'] } - } - if (shell.includes('fish')) { - return { shell, args: ['-c'] } - } + const fallbackShell = + platform === 'darwin' ? '/bin/zsh' : platform === 'linux' ? '/bin/bash' : '/bin/sh' + const shell = process.env.SHELL || fallbackShell + return { shell, args: ['-c'] } } -/** - * Execute shell command to get environment variables - * This will source shell initialization files to get nvm/n/fnm/volta paths - */ -async function executeShellEnvCommand(): Promise> { - const { shell, args } = getUserShell() - const platform = process.platform - - let envCommand: string - - if (platform === 'win32') { - envCommand = 'Get-ChildItem Env: | ForEach-Object { "$($_.Name)=$($_.Value)" }' - } else { - const shellName = path.basename(shell) - - if (shellName === 'bash') { - envCommand = ` - [ -f ~/.bashrc ] && source ~/.bashrc - [ -f ~/.bash_profile ] && source ~/.bash_profile - [ -f ~/.profile ] && source ~/.profile - env - `.trim() - } else if (shellName === 'zsh') { - envCommand = ` - [ -f ~/.zshrc ] && source ~/.zshrc - env - `.trim() - } else { - envCommand = 'env' - } +export async function resolveShellBootstrapEnv(): Promise> { + if (process.platform === 'win32') { + return pickRelevantEnvironment(process.env) } + const { shell } = getUserShell() + const { start, end } = getShellBootstrapMarkers() + const envCommand = buildShellBootstrapCommand(start, end) + const args = getShellBootstrapArgs(shell, envCommand) + return await new Promise>((resolve, reject) => { - const child = spawn(shell, [...args, envCommand], { + const child = spawn(shell, args, { stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env } + env: { + ...process.env, + TERM: process.env.TERM || 'dumb' + } }) let stdout = '' @@ -82,55 +302,64 @@ async function executeShellEnvCommand(): Promise> { reject(new Error(`Shell environment command timed out after ${TIMEOUT_MS}ms`)) }, TIMEOUT_MS) - child.stdout?.on('data', (data: Buffer) => { + child.stdout?.on('data', (data: Buffer | string) => { stdout += data.toString() }) - child.stderr?.on('data', (data: Buffer) => { + child.stderr?.on('data', (data: Buffer | string) => { stderr += data.toString() }) child.on('error', (error) => { - if (timeoutId) clearTimeout(timeoutId) + if (timeoutId) { + clearTimeout(timeoutId) + } reject(error) }) child.on('exit', (code, signal) => { - if (timeoutId) clearTimeout(timeoutId) + if (timeoutId) { + clearTimeout(timeoutId) + } if (code !== 0 && signal === null) { - console.warn( - `[ACP] Shell environment command exited with code ${code}, stderr: ${stderr.substring(0, 200)}` + reject( + new Error( + `Shell environment command exited with code ${code}, stderr: ${stderr.substring(0, 200)}` + ) ) - resolve({}) return } if (signal) { - console.warn(`[ACP] Shell environment command killed by signal: ${signal}`) - resolve({}) + reject(new Error(`Shell environment command killed by signal: ${signal}`)) return } - const env: Record = {} - const lines = stdout.split('\n').filter((line) => line.trim().length > 0) - for (const line of lines) { - const match = line.match(/^([^=]+)=(.*)$/) - if (match) { - const [, key, value] = match - env[key.trim()] = value.trim() - } - } - - resolve(env) + resolve(parseShellBootstrapOutput(stdout, start, end)) }) }) } -/** - * Get shell environment variables with caching - * This will source shell initialization files to get nvm/n/fnm/volta paths - */ +function normalizeShellEnvironment( + shellBootstrapEnv: Record, + processEnv: NodeJS.ProcessEnv | Record = process.env +): Record { + const processRelevantEnv = pickRelevantEnvironment(processEnv) + const shellRelevantEnv = pickRelevantEnvironment(shellBootstrapEnv) + const merged: Record = { + ...processRelevantEnv, + ...shellRelevantEnv + } + + setPathEntriesOnEnv(merged, [ + getPathEntriesFromEnv(shellRelevantEnv), + getPathEntriesFromEnv(processRelevantEnv) + ]) + + return merged +} + export async function getShellEnvironment(): Promise> { if (cachedShellEnv !== null) { console.log('[ACP] Using cached shell environment variables') @@ -140,73 +369,27 @@ export async function getShellEnvironment(): Promise> { console.log('[ACP] Fetching shell environment variables (this may take a moment)...') try { - const shellEnv = await executeShellEnvCommand() - const filteredEnv: Record = {} - - if (shellEnv.PATH) { - filteredEnv.PATH = shellEnv.PATH - } - if (shellEnv.Path) { - filteredEnv.Path = shellEnv.Path - } - - const nodeEnvVars = [ - 'NVM_DIR', - 'NVM_CD_FLAGS', - 'NVM_BIN', - 'NODE_PATH', - 'NODE_VERSION', - 'FNM_DIR', - 'VOLTA_HOME', - 'N_PREFIX' - ] - - for (const key of nodeEnvVars) { - if (shellEnv[key]) { - filteredEnv[key] = shellEnv[key] - } - } - - const npmEnvVars = [ - 'npm_config_registry', - 'npm_config_cache', - 'npm_config_prefix', - 'npm_config_tmp', - 'NPM_CONFIG_REGISTRY', - 'NPM_CONFIG_CACHE', - 'NPM_CONFIG_PREFIX', - 'NPM_CONFIG_TMP' - ] - - for (const key of npmEnvVars) { - if (shellEnv[key]) { - filteredEnv[key] = shellEnv[key] - } - } + const shellEnv = await resolveShellBootstrapEnv() + const normalizedEnv = normalizeShellEnvironment(shellEnv) - cachedShellEnv = filteredEnv + cachedShellEnv = normalizedEnv console.log('[ACP] Shell environment variables fetched and cached:', { - pathLength: filteredEnv.PATH?.length || filteredEnv.Path?.length || 0, - hasNvm: !!filteredEnv.NVM_DIR, - hasFnm: !!filteredEnv.FNM_DIR, - hasVolta: !!filteredEnv.VOLTA_HOME, - hasN: !!filteredEnv.N_PREFIX, - envVarCount: Object.keys(filteredEnv).length + pathLength: normalizedEnv.PATH?.length || normalizedEnv.Path?.length || 0, + hasNvm: !!normalizedEnv.NVM_DIR, + hasFnm: !!normalizedEnv.FNM_DIR, + hasVolta: !!normalizedEnv.VOLTA_HOME, + hasN: !!normalizedEnv.N_PREFIX, + envVarCount: Object.keys(normalizedEnv).length }) - return filteredEnv + return normalizedEnv } catch (error) { console.warn('[ACP] Failed to get shell environment variables:', error) - cachedShellEnv = {} - return {} + return normalizeShellEnvironment(toStringEnvEntries(process.env), process.env) } } -/** - * Clear the shell environment cache - * Should be called when ACP configuration changes (e.g., useBuiltinRuntime) - */ export function clearShellEnvironmentCache(): void { cachedShellEnv = null console.log('[ACP] Shell environment cache cleared') diff --git a/src/main/presenter/configPresenter/acpInitHelper.ts b/src/main/presenter/configPresenter/acpInitHelper.ts index 0128e3bc7..53ac75170 100644 --- a/src/main/presenter/configPresenter/acpInitHelper.ts +++ b/src/main/presenter/configPresenter/acpInitHelper.ts @@ -7,7 +7,12 @@ import type { AcpBuiltinAgentId, AcpAgentConfig, AcpAgentProfile } from '@shared import { spawn } from 'node-pty' import type { IPty } from 'node-pty' import { RuntimeHelper } from '@/lib/runtimeHelper' -import { getShellEnvironment } from '@/lib/agentRuntime/shellEnvHelper' +import { + getPathEntriesFromEnv, + getShellEnvironment, + mergeCommandEnvironment, + setPathEntriesOnEnv +} from '@/lib/agentRuntime/shellEnvHelper' const execAsync = promisify(exec) @@ -507,84 +512,48 @@ class AcpInitHelper { hasProfileEnv: !!(profile.env && Object.keys(profile.env).length > 0) }) - const env: Record = {} - - // Add system environment variables - const systemEnvCount = Object.entries(process.env).filter( - ([, value]) => value !== undefined && value !== '' - ).length - Object.entries(process.env).forEach(([key, value]) => { - if (value !== undefined && value !== '') { - env[key] = value - } - }) + let env = mergeCommandEnvironment() + const systemEnvCount = Object.keys(env).length console.log('[ACP Init] Added system environment variables:', systemEnvCount) - // Merge shell environment (includes user PATH from shell startup) - const pathKeys = ['PATH', 'Path', 'path'] - const existingPaths: string[] = [] - pathKeys.forEach((key) => { - const value = env[key] - if (value) existingPaths.push(value) - }) - try { const shellEnv = await getShellEnvironment() - Object.entries(shellEnv).forEach(([key, value]) => { - if (value !== undefined && value !== '' && !pathKeys.includes(key)) { - env[key] = value - } - }) - const shellPath = shellEnv.PATH || shellEnv.Path || shellEnv.path - if (shellPath) { - existingPaths.unshift(shellPath) - } + env = mergeCommandEnvironment({ shellEnv }) } catch (error) { console.warn('[ACP Init] Failed to merge shell environment variables:', error) } - // Prepare PATH merging - const HOME_DIR = app.getPath('home') - const defaultPaths = this.runtimeHelper.getDefaultPaths(HOME_DIR) - const allPaths = [...existingPaths, ...defaultPaths] + const prependPathSources: string[] = [] - // Add runtime paths to PATH if using builtin runtime if (useBuiltinRuntime) { - const runtimePaths: string[] = [] - const uvRuntimePath = this.runtimeHelper.getUvRuntimePath() const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() if (uvRuntimePath) { - runtimePaths.push(uvRuntimePath) + prependPathSources.push(uvRuntimePath) console.log('[ACP Init] Added UV runtime path:', uvRuntimePath) } if (process.platform === 'win32') { if (nodeRuntimePath) { - runtimePaths.push(nodeRuntimePath) + prependPathSources.push(nodeRuntimePath) console.log('[ACP Init] Added Node runtime path (Windows):', nodeRuntimePath) } - } else { - if (nodeRuntimePath) { - const nodeBinPath = path.join(nodeRuntimePath, 'bin') - runtimePaths.push(nodeBinPath) - console.log('[ACP Init] Added Node runtime path (Unix):', nodeBinPath) - } + } else if (nodeRuntimePath) { + const nodeBinPath = path.join(nodeRuntimePath, 'bin') + prependPathSources.push(nodeBinPath) + console.log('[ACP Init] Added Node runtime path (Unix):', nodeBinPath) } - if (runtimePaths.length > 0) { - allPaths.unshift(...runtimePaths) + if (prependPathSources.length > 0) { + setPathEntriesOnEnv(env, [prependPathSources, getPathEntriesFromEnv(env)], { + includeDefaultPaths: false + }) } else { console.warn('[ACP Init] No runtime paths available to add to PATH') } } - // Normalize and set PATH - const normalizedPath = this.runtimeHelper.normalizePathEnv(allPaths) - env[normalizedPath.key] = normalizedPath.value - - // Add registry environment variables if using builtin runtime if (useBuiltinRuntime) { if (npmRegistry && npmRegistry !== '') { env.npm_config_registry = npmRegistry @@ -598,8 +567,6 @@ class AcpInitHelper { console.log('[ACP Init] Set UV registry:', uvRegistry) } - // On Windows, if app is installed in system directory, set npm prefix to user directory - // to avoid permission issues when installing global packages if (process.platform === 'win32' && this.runtimeHelper.isInstalledInSystemDirectory()) { const userNpmPrefix = this.runtimeHelper.getUserNpmPrefix() @@ -611,47 +578,38 @@ class AcpInitHelper { userNpmPrefix ) - // Add user npm bin directory to PATH - const pathKey = 'Path' - const separator = ';' - const existingPath = env[pathKey] || '' const userNpmBinPath = userNpmPrefix - - // Ensure the user npm bin path is at the beginning of PATH - if (existingPath) { - env[pathKey] = [userNpmBinPath, existingPath].filter(Boolean).join(separator) - } else { - env[pathKey] = userNpmBinPath - } + setPathEntriesOnEnv(env, [userNpmBinPath, getPathEntriesFromEnv(env)], { + includeDefaultPaths: false + }) console.log('[ACP Init] Added user npm bin directory to PATH:', userNpmBinPath) } } } - // Add custom environment variables from profile if (profile.env) { const customEnvCount = Object.entries(profile.env).filter( ([, value]) => value !== undefined && value !== '' ).length Object.entries(profile.env).forEach(([key, value]) => { - if (value !== undefined && value !== '') { - if (['PATH', 'Path', 'path'].includes(key)) { - // Merge PATH variables - const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' - const separator = process.platform === 'win32' ? ';' : ':' - const existingPath = env[pathKey] || env[normalizedPath.key] || '' - env[pathKey] = [value, existingPath].filter(Boolean).join(separator) - console.log('[ACP Init] Merged custom PATH from profile:', { - customPath: value, - mergedPathLength: env[pathKey].length - }) - } else { - env[key] = value - console.log('[ACP Init] Added custom env var:', key) - } + if (value !== undefined && value !== '' && !['PATH', 'Path', 'path'].includes(key)) { + env[key] = value + console.log('[ACP Init] Added custom env var:', key) } }) + + const customPathEntries = getPathEntriesFromEnv(profile.env) + if (customPathEntries.length > 0) { + setPathEntriesOnEnv(env, [customPathEntries, getPathEntriesFromEnv(env)], { + includeDefaultPaths: false + }) + console.log('[ACP Init] Merged custom PATH from profile:', { + customPath: profile.env.PATH || profile.env.Path || profile.env.path, + mergedPathLength: env[process.platform === 'win32' ? 'Path' : 'PATH']?.length || 0 + }) + } + console.log('[ACP Init] Added custom environment variables from profile:', customEnvCount) } diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 6a2125ebf..22767fa29 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -16,7 +16,8 @@ import { AcpAgentState, AcpManualAgent, AcpRegistryAgent, - AcpResolvedLaunchSpec + AcpResolvedLaunchSpec, + ProviderDbRefreshResult } from '@shared/presenter' import { ProviderBatchUpdate } from '@shared/provider-operations' import { SearchEngineTemplate } from '@shared/chat' @@ -343,7 +344,9 @@ export class ConfigPresenter implements IConfigPresenter { this.initProviderModelsDir() // 初始化 Provider DB(外部聚合 JSON,本地内置为兜底) - providerDbLoader.initialize().catch(() => {}) + providerDbLoader.initialize().catch((error) => { + console.warn('[ConfigPresenter] Failed to initialize provider DB:', error) + }) // If application version is updated, update appVersion if (this.store.get('appVersion') !== this.currentAppVersion) { @@ -524,6 +527,10 @@ export class ConfigPresenter implements IConfigPresenter { return providerDbLoader.getDb() } + async refreshProviderDb(force = false): Promise { + return providerDbLoader.refreshIfNeeded(force) + } + supportsReasoningCapability(providerId: string, modelId: string): boolean { return modelCapabilities.supportsReasoning(providerId, modelId) } diff --git a/src/main/presenter/configPresenter/providerDbLoader.ts b/src/main/presenter/configPresenter/providerDbLoader.ts index bf4b98b8a..0cc742fcd 100644 --- a/src/main/presenter/configPresenter/providerDbLoader.ts +++ b/src/main/presenter/configPresenter/providerDbLoader.ts @@ -22,12 +22,20 @@ type MetaFile = { lastAttemptedAt?: number } +export type ProviderDbRefreshResult = { + status: 'updated' | 'not-modified' | 'skipped' | 'error' + lastUpdated: number | null + providersCount: number + message?: string +} + export class ProviderDbLoader { private cache: ProviderAggregate | null = null private userDataDir: string private cacheDir: string private cacheFilePath: string private metaFilePath: string + private refreshPromise: Promise | null = null constructor() { this.userDataDir = app.getPath('userData') @@ -53,8 +61,16 @@ export class ProviderDbLoader { } catch {} } - // Background refresh if needed (npm 缓存风格) - this.refreshIfNeeded().catch(() => {}) + // Always refresh once in the background on startup to pick up upstream updates. + void this.refreshIfNeeded(true) + .then((result) => { + if (result.status === 'error') { + console.warn('[ProviderDbLoader] Startup refresh failed:', result.message) + } + }) + .catch((error) => { + console.warn('[ProviderDbLoader] Startup refresh failed:', error) + }) } getDb(): ProviderAggregate | null { @@ -135,8 +151,8 @@ export class ProviderDbLoader { private getTtlHours(): number { const env = process.env.PROVIDER_DB_TTL_HOURS - const v = env ? Number(env) : 12 - return Number.isFinite(v) && v > 0 ? v : 12 + const v = env ? Number(env) : 4 + return Number.isFinite(v) && v > 0 ? v : 4 } private getProviderDbUrl(): string { @@ -149,20 +165,58 @@ export class ProviderDbLoader { return DEFAULT_PROVIDER_DB_URL } - async refreshIfNeeded(force = false): Promise { + async refreshIfNeeded(force = false): Promise { + if (this.refreshPromise) return this.refreshPromise + const meta = this.readMeta() const ttlHours = this.getTtlHours() const url = this.getProviderDbUrl() const needFirstFetch = !meta || !fs.existsSync(this.cacheFilePath) - const expired = meta ? this.now() - meta.lastUpdated > ttlHours * 3600 * 1000 : true + const freshnessTimestamp = meta?.lastAttemptedAt ?? meta?.lastUpdated ?? 0 + const expired = meta ? this.now() - freshnessTimestamp > ttlHours * 3600 * 1000 : true - if (!force && !needFirstFetch && !expired) return + if (!force && !needFirstFetch && !expired) { + return this.createResult('skipped', meta) + } - await this.fetchAndUpdate(url, meta || undefined) + this.refreshPromise = this.fetchAndUpdate(url, meta || undefined).finally(() => { + this.refreshPromise = null + }) + + return this.refreshPromise } - private async fetchAndUpdate(url: string, prevMeta?: MetaFile): Promise { + private createResult( + status: ProviderDbRefreshResult['status'], + meta?: MetaFile | null, + message?: string + ): ProviderDbRefreshResult { + const db = this.getDb() + const providersCount = Object.keys(db?.providers || {}).length + return { + status, + lastUpdated: meta?.lastUpdated ?? null, + providersCount, + ...(message ? { message } : {}) + } + } + + private createAttemptMeta( + prevMeta: MetaFile | undefined, + url: string, + now: number + ): MetaFile | null { + if (!prevMeta) return null + return { + ...prevMeta, + sourceUrl: url, + ttlHours: this.getTtlHours(), + lastAttemptedAt: now + } + } + + private async fetchAndUpdate(url: string, prevMeta?: MetaFile): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 15000) try { @@ -173,40 +227,44 @@ export class ProviderDbLoader { const now = this.now() if (res.status === 304 && prevMeta) { - // Not modified; update attempted time only - this.writeMeta({ ...prevMeta, lastAttemptedAt: now }) - return + const meta: MetaFile = { + ...prevMeta, + sourceUrl: url, + ttlHours: this.getTtlHours(), + lastAttemptedAt: now + } + this.writeMeta(meta) + return this.createResult('not-modified', meta) } if (!res.ok) { - // Keep old cache - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, `Request failed with status ${res.status}`) } const text = await res.text() // Size guard (≈ 5MB) if (text.length > 5 * 1024 * 1024) { - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, 'Provider DB payload exceeds size limit') } let parsed: unknown try { parsed = JSON.parse(text) } catch { - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, 'Provider DB payload is not valid JSON') } const sanitized = sanitizeAggregate(parsed) if (!sanitized) { - const meta = prevMeta ? { ...prevMeta, lastAttemptedAt: now } : undefined + const meta = this.createAttemptMeta(prevMeta, url, now) if (meta) this.writeMeta(meta) - return + return this.createResult('error', meta, 'Provider DB payload failed validation') } const etag = res.headers.get('etag') || undefined @@ -229,8 +287,12 @@ export class ProviderDbLoader { lastUpdated: meta.lastUpdated }) } catch {} - } catch { - // ignore + return this.createResult('updated', meta) + } catch (error) { + const meta = this.createAttemptMeta(prevMeta, url, this.now()) + if (meta) this.writeMeta(meta) + const message = error instanceof Error ? error.message : 'Unknown provider DB refresh error' + return this.createResult('error', meta, message) } finally { clearTimeout(timeout) } diff --git a/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts b/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts index 0b6cba52b..c8a21d52f 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts @@ -18,7 +18,12 @@ import type { AcpResolvedLaunchSpec } from '@shared/presenter' import type { AgentProcessHandle, AgentProcessManager } from './types' -import { getShellEnvironment } from '@/lib/agentRuntime/shellEnvHelper' +import { + getPathEntriesFromEnv, + getShellEnvironment, + mergeCommandEnvironment, + setPathEntriesOnEnv +} from '@/lib/agentRuntime/shellEnvHelper' import { RuntimeHelper } from '@/lib/runtimeHelper' import { buildClientCapabilities } from './acpCapabilities' import { AcpFsHandler } from './acpFsHandler' @@ -798,36 +803,13 @@ export class AcpProcessManager implements AgentProcessManager = {} - Object.entries(process.env).forEach(([key, value]) => { - if (value !== undefined && value !== '') { - env[key] = value - } - }) - let pathKey = process.platform === 'win32' ? 'Path' : 'PATH' - let pathValue = '' - - // Collect existing PATH values - const existingPaths: string[] = [] - const pathKeys = ['PATH', 'Path', 'path'] - pathKeys.forEach((key) => { - const value = env[key] - if (value) { - existingPaths.push(value) - } - }) + let env = mergeCommandEnvironment() - // Get shell environment variables for ALL commands (not just Node.js commands) - // This ensures commands like kimi-cli can find their dependencies in Release builds let shellEnv: Record = {} try { shellEnv = await getShellEnvironment() console.info(`[ACP] Retrieved shell environment variables for agent ${agent.id}`) - Object.entries(shellEnv).forEach(([key, value]) => { - if (value !== undefined && value !== '' && !pathKeys.includes(key)) { - env[key] = value - } - }) + env = mergeCommandEnvironment({ shellEnv }) } catch (error) { console.warn( `[ACP] Failed to get shell environment variables for agent ${agent.id}, using fallback:`, @@ -835,37 +817,16 @@ export class AcpProcessManager implements AgentProcessManager existing PATH) const shellPath = shellEnv.PATH || shellEnv.Path || shellEnv.path if (shellPath) { - const shellPaths = shellPath.split(process.platform === 'win32' ? ';' : ':') - existingPaths.unshift(...shellPaths) console.info(`[ACP] Using shell PATH for agent ${agent.id} (length: ${shellPath.length})`) } - // Get default paths - const defaultPaths = this.runtimeHelper.getDefaultPaths(HOME_DIR) - - // Merge all paths (priority: shell PATH > existing PATH > default paths) - const allPaths = [...existingPaths, ...defaultPaths] - - // Normalize and set PATH - const normalized = this.runtimeHelper.normalizePathEnv(allPaths) - pathKey = normalized.key - pathValue = normalized.value - env[pathKey] = pathValue - // Merge distribution/base environment variables first. if (launchSpec.env) { Object.entries(launchSpec.env).forEach(([key, value]) => { if (value !== undefined && value !== '') { - if (['PATH', 'Path', 'path'].includes(key)) { - const currentPathKey = process.platform === 'win32' ? 'Path' : 'PATH' - const separator = process.platform === 'win32' ? ';' : ':' - env[currentPathKey] = env[currentPathKey] - ? `${value}${separator}${env[currentPathKey]}` - : value - } else { + if (!['PATH', 'Path', 'path'].includes(key)) { env[key] = value } } @@ -899,12 +860,28 @@ export class AcpProcessManager implements AgentProcessManager { if (value !== undefined && value !== '') { - env[key] = value + if (!['PATH', 'Path', 'path'].includes(key)) { + env[key] = value + } } }) } + setPathEntriesOnEnv( + env, + [ + getPathEntriesFromEnv(userEnvOverride), + getPathEntriesFromEnv(launchSpec.env), + getPathEntriesFromEnv(env) + ], + { + includeDefaultPaths: false + } + ) + const mergedEnv = env + const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' + const pathValue = mergedEnv[pathKey] || mergedEnv.PATH || '' console.info(`[ACP] Environment variables for agent ${agent.id}:`, { pathKey, diff --git a/src/main/presenter/skillPresenter/skillExecutionService.ts b/src/main/presenter/skillPresenter/skillExecutionService.ts index 5e1fead96..580f63a78 100644 --- a/src/main/presenter/skillPresenter/skillExecutionService.ts +++ b/src/main/presenter/skillPresenter/skillExecutionService.ts @@ -11,7 +11,11 @@ import type { } from '@shared/types/skill' import { backgroundExecSessionManager } from '@/lib/agentRuntime/backgroundExecSessionManager' import { rtkRuntimeService } from '@/lib/agentRuntime/rtkRuntimeService' -import { getShellEnvironment, getUserShell } from '@/lib/agentRuntime/shellEnvHelper' +import { + getShellEnvironment, + getUserShell, + mergeCommandEnvironment +} from '@/lib/agentRuntime/shellEnvHelper' import { resolveSessionDir } from '@/lib/agentRuntime/sessionPaths' import { RuntimeHelper } from '@/lib/runtimeHelper' @@ -61,12 +65,6 @@ interface SpawnPlan { spawnMode: 'direct' | 'shell' } -function toStringEnv(input: NodeJS.ProcessEnv | Record): Record { - return Object.fromEntries( - Object.entries(input).filter((entry): entry is [string, string] => typeof entry[1] === 'string') - ) -} - export class SkillExecutionService { private readonly runtimeHelper = RuntimeHelper.getInstance() private readonly configPresenter?: Pick @@ -144,13 +142,14 @@ export class SkillExecutionService { const extension = await this.skillPresenter.getSkillExtension(input.skill) const shellEnv = await getShellEnvironment() const executionCwd = await this.resolveExecutionCwd(conversationId, metadata.skillRoot) - const mergedEnv = { - ...toStringEnv(process.env), - ...shellEnv, - ...extension.env, - SKILL_ROOT: metadata.skillRoot, - DEEPCHAT_SKILL_ROOT: metadata.skillRoot - } + const mergedEnv = mergeCommandEnvironment({ + shellEnv, + overrides: { + ...extension.env, + SKILL_ROOT: metadata.skillRoot, + DEEPCHAT_SKILL_ROOT: metadata.skillRoot + } + }) const runtime = await this.resolveRuntimeCommand( script, diff --git a/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts b/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts index 002e98b46..05dbebf86 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts @@ -9,7 +9,11 @@ import { getBackgroundExecConfig } from '@/lib/agentRuntime/backgroundExecSessio import { backgroundExecSessionManager } from '@/lib/agentRuntime/backgroundExecSessionManager' import { terminateProcessTree } from '@/lib/agentRuntime/processTree' import { rtkRuntimeService } from '@/lib/agentRuntime/rtkRuntimeService' -import { getShellEnvironment, getUserShell } from '@/lib/agentRuntime/shellEnvHelper' +import { + getShellEnvironment, + getUserShell, + mergeCommandEnvironment +} from '@/lib/agentRuntime/shellEnvHelper' import { resolveSessionDir } from '@/lib/agentRuntime/sessionPaths' // Consider moving to a shared handlers location in future refactoring @@ -323,17 +327,12 @@ export class AgentBashHandler { options: ExecuteCommandOptions ): Promise { const { shell, args } = getUserShell() - const shellEnv = await getShellEnvironment() const outputFilePath = this.createOutputFilePath(options.conversationId, options.outputPrefix) return new Promise((resolve, reject) => { const child = spawn(shell, [...args, command], { cwd, - env: { - ...process.env, - ...shellEnv, - ...options.env - }, + env: options.env ? { ...options.env } : { ...process.env }, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] }) @@ -591,10 +590,14 @@ export class AgentBashHandler { ): Promise { const baseEnv = env ?? {} if (!this.configPresenter) { + const shellEnv = await getShellEnvironment() return { originalCommand: command, command, - env: baseEnv, + env: mergeCommandEnvironment({ + shellEnv, + overrides: baseEnv + }), rewritten: false, rtkApplied: false, rtkMode: 'bypass', diff --git a/src/renderer/settings/components/DataSettings.vue b/src/renderer/settings/components/DataSettings.vue index 61842c1db..90505fd92 100644 --- a/src/renderer/settings/components/DataSettings.vue +++ b/src/renderer/settings/components/DataSettings.vue @@ -147,6 +147,40 @@ +
+
+ +
+
{{ t('settings.data.modelConfigUpdate.title') }}
+

+ {{ t('settings.data.modelConfigUpdate.description') }} +

+
+
+ +
+ + + @@ -372,6 +406,7 @@ const languageStore = useLanguageStore() const syncStore = useSyncStore() const devicePresenter = usePresenter('devicePresenter') const yoBrowserPresenter = usePresenter('yoBrowserPresenter') +const configPresenter = usePresenter('configPresenter') const { backups: backupsRef } = storeToRefs(syncStore) const { toast } = useToast() @@ -382,6 +417,7 @@ const selectedBackup = ref('') const isResetDialogOpen = ref(false) const resetType = ref<'chat' | 'knowledge' | 'config' | 'all'>('chat') const isResetting = ref(false) +const isUpdatingModelConfig = ref(false) const isClearingSandbox = ref(false) const isClearSandboxDialogOpen = ref(false) @@ -462,6 +498,51 @@ const handleBackup = async () => { }) } +const handleRefreshProviderDb = async () => { + if (isUpdatingModelConfig.value) return + + isUpdatingModelConfig.value = true + try { + const result = await configPresenter.refreshProviderDb(true) + + if (!result || result.status === 'error') { + console.error('Failed to refresh provider DB:', result?.message) + toast({ + title: t('settings.data.modelConfigUpdate.failedTitle'), + description: t('settings.data.modelConfigUpdate.failedDescription'), + variant: 'destructive', + duration: 4000 + }) + return + } + + const isUpToDate = result.status === 'not-modified' || result.status === 'skipped' + toast({ + title: t( + isUpToDate + ? 'settings.data.modelConfigUpdate.upToDateTitle' + : 'settings.data.modelConfigUpdate.updatedTitle' + ), + description: t( + isUpToDate + ? 'settings.data.modelConfigUpdate.upToDateDescription' + : 'settings.data.modelConfigUpdate.updatedDescription' + ), + duration: 4000 + }) + } catch (error) { + console.error('Failed to refresh provider DB:', error) + toast({ + title: t('settings.data.modelConfigUpdate.failedTitle'), + description: t('settings.data.modelConfigUpdate.failedDescription'), + variant: 'destructive', + duration: 4000 + }) + } finally { + isUpdatingModelConfig.value = false + } +} + // 关闭导入对话框 const closeImportDialog = () => { isImportDialogOpen.value = false diff --git a/src/renderer/src/components/chat/mentions/SuggestionList.vue b/src/renderer/src/components/chat/mentions/SuggestionList.vue index 9fd9946e0..7ca37d562 100644 --- a/src/renderer/src/components/chat/mentions/SuggestionList.vue +++ b/src/renderer/src/components/chat/mentions/SuggestionList.vue @@ -10,7 +10,17 @@ @click="selectIndex(index)" >
- {{ categoryTag(item.category) }} + + + {{ categoryTag(item.category) }} +
{{ item.label }}
@@ -26,6 +36,7 @@