diff --git a/integration-tests/file-system-interactive.test.ts b/integration-tests/file-system-interactive.test.ts index 6f955a1378..187cccf342 100644 --- a/integration-tests/file-system-interactive.test.ts +++ b/integration-tests/file-system-interactive.test.ts @@ -35,7 +35,7 @@ describe('Interactive file system', () => { const run = await rig.runInteractive(); // Step 1: Read the file - const readPrompt = `Read the version from ${fileName}`; + const readPrompt = `Read the version from ${fileName} using the read_file tool`; await run.type(readPrompt); await run.type('\r'); diff --git a/packages/a2a-server/src/agent/executor.test.ts b/packages/a2a-server/src/agent/executor.test.ts index fa0f8fdc03..32971f6e95 100644 --- a/packages/a2a-server/src/agent/executor.test.ts +++ b/packages/a2a-server/src/agent/executor.test.ts @@ -21,6 +21,17 @@ vi.mock('../utils/path_utils.js', () => ({ })); // Mocks for constructor dependencies +vi.mock('@google/gemini-cli-core', () => ({ + GeminiEventType: { + PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED', + SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED', + }, + SimpleExtensionLoader: vi.fn(), + checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }), + isHeadlessMode: vi.fn().mockReturnValue(true), + resolveToRealPath: vi.fn().mockImplementation((p) => p), +})); + vi.mock('../config/config.js', () => ({ loadConfig: vi.fn().mockReturnValue({ getSessionId: () => 'test-session', @@ -30,6 +41,10 @@ vi.mock('../config/config.js', () => ({ loadEnvironment: vi.fn(), setIsTrusted: vi.fn().mockReturnValue(false), setTargetDir: vi.fn().mockReturnValue('/tmp'), + envStorage: { + run: (env: Record, cb: () => unknown) => cb(), + }, + cwdSymbol: Symbol('cwd'), })); vi.mock('../config/settings.js', () => ({ diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts index 80184d62de..0da6c26494 100644 --- a/packages/a2a-server/src/agent/executor.ts +++ b/packages/a2a-server/src/agent/executor.ts @@ -17,6 +17,9 @@ import { SimpleExtensionLoader, type ToolCallRequestInfo, type Config, + checkPathTrust, + isHeadlessMode, + resolveToRealPath, } from '@google/gemini-cli-core'; import { v4 as uuidv4 } from 'uuid'; @@ -33,16 +36,25 @@ import { } from '../types.js'; import { loadConfig, - loadEnvironment, - setIsTrusted, setTargetDir, + envStorage, + cwdSymbol, + loadEnvironment, + type TaskEnv, } from '../config/config.js'; import { loadSettings } from '../config/settings.js'; import { loadExtensions } from '../config/extension.js'; import { Task } from './task.js'; import { requestStorage } from '../http/requestStorage.js'; import { pushTaskStateFailed } from '../utils/executor_utils.js'; -import { validateWorkspacePath } from '../utils/path_utils.js'; + +function validateWorkspacePath(workspacePath?: string): string { + const trimmed = workspacePath?.trim(); + if (!trimmed) { + return process.env['CODER_AGENT_WORKSPACE_PATH'] ?? process.cwd(); + } + return resolveToRealPath(trimmed); +} /** * Provides a wrapper for Task. Passes data from Task to SDKTask. @@ -100,23 +112,56 @@ export class CoderAgentExecutor implements AgentExecutor { constructor(private taskStore?: TaskStore) {} - private async getConfig( + private async getConfigWithEnv( agentSettings: AgentSettings, taskId: string, + isTrusted: boolean, + workspaceRoot: string, ): Promise { - const workspaceRoot = setTargetDir(agentSettings); - loadEnvironment(); // Will override any global env with workspace envs - const isTrusted = setIsTrusted(agentSettings); const settings = loadSettings(workspaceRoot, isTrusted); - const extensions = loadExtensions(workspaceRoot); + const extensions = loadExtensions(workspaceRoot, isTrusted); return loadConfig( settings, new SimpleExtensionLoader(extensions), taskId, isTrusted, + workspaceRoot, ); } + private async runInIsolatedEnv( + agentSettings: AgentSettings, + fn: (isTrusted: boolean, workspaceRoot: string) => Promise, + ): Promise { + const workspaceRoot = await setTargetDir(agentSettings); + const initialSettings = loadSettings(workspaceRoot, false); + const { isTrusted } = checkPathTrust({ + path: workspaceRoot, + isFolderTrustEnabled: initialSettings.folderTrust ?? true, + isHeadless: isHeadlessMode(), + }); + + const envVars: TaskEnv = { ...process.env }; + envVars[cwdSymbol] = workspaceRoot; + + return envStorage.run(envVars, async () => { + const loadedEnv = await loadEnvironment( + isTrusted ?? false, + workspaceRoot, + ); + Object.assign(envVars, loadedEnv); + return fn(isTrusted ?? false, workspaceRoot); + }); + } + + private cleanupAndEvictTask(taskId: string) { + const wrapper = this.tasks.get(taskId); + if (wrapper) { + wrapper.task.dispose(); + this.tasks.delete(taskId); + } + } + /** * Reconstructs TaskWrapper from SDKTask. */ @@ -133,11 +178,11 @@ export class CoderAgentExecutor implements AgentExecutor { ); } - let agentSettings; + let agentSettings: AgentSettings; try { agentSettings = { ...(persistedState._agentSettings ?? {}), - workspacePath: await validateWorkspacePath( + workspacePath: validateWorkspacePath( persistedState._agentSettings?.workspacePath, ), isTrusted: false, @@ -157,23 +202,34 @@ export class CoderAgentExecutor implements AgentExecutor { } throw error; // Re-throw to be caught by caller } - const config = await this.getConfig(agentSettings, sdkTask.id); - const contextId: string = - getContextIdFromMetadata(metadata) || sdkTask.contextId; - const runtimeTask = await Task.create( - sdkTask.id, - contextId, - config, - eventBus, - agentSettings.autoExecute, - ); - runtimeTask.taskState = persistedState._taskState; - await runtimeTask.geminiClient.initialize(); - const wrapper = new TaskWrapper(runtimeTask, agentSettings); - this.tasks.set(sdkTask.id, wrapper); - logger.info(`Task ${sdkTask.id} reconstructed from store.`); - return wrapper; + return this.runInIsolatedEnv( + agentSettings, + async (isTrusted, workspaceRoot) => { + const config = await this.getConfigWithEnv( + agentSettings, + sdkTask.id, + isTrusted, + workspaceRoot, + ); + const contextId: string = + getContextIdFromMetadata(metadata) || sdkTask.contextId; + const runtimeTask = await Task.create( + sdkTask.id, + contextId, + config, + eventBus, + agentSettings.autoExecute, + ); + runtimeTask.taskState = persistedState._taskState; + await runtimeTask.geminiClient.initialize(); + + const wrapper = new TaskWrapper(runtimeTask, agentSettings); + this.tasks.set(sdkTask.id, wrapper); + logger.info(`Task ${sdkTask.id} reconstructed from store.`); + return wrapper; + }, + ); } async createTask( @@ -186,20 +242,30 @@ export class CoderAgentExecutor implements AgentExecutor { kind: CoderAgentEvent.StateAgentSettingsEvent, workspacePath: process.cwd(), }; - const config = await this.getConfig(agentSettings, taskId); - const runtimeTask = await Task.create( - taskId, - contextId, - config, - eventBus, - agentSettings.autoExecute, - ); - await runtimeTask.geminiClient.initialize(); + return this.runInIsolatedEnv( + agentSettings, + async (isTrusted, workspaceRoot) => { + const config = await this.getConfigWithEnv( + agentSettings, + taskId, + isTrusted, + workspaceRoot, + ); + const runtimeTask = await Task.create( + taskId, + contextId, + config, + eventBus, + agentSettings.autoExecute, + ); + await runtimeTask.geminiClient.initialize(); - const wrapper = new TaskWrapper(runtimeTask, agentSettings); - this.tasks.set(taskId, wrapper); - logger.info(`New task ${taskId} created.`); - return wrapper; + const wrapper = new TaskWrapper(runtimeTask, agentSettings); + this.tasks.set(taskId, wrapper); + logger.info(`New task ${taskId} created.`); + return wrapper; + }, + ); } getTask(taskId: string): TaskWrapper | undefined { @@ -210,17 +276,6 @@ export class CoderAgentExecutor implements AgentExecutor { return Array.from(this.tasks.values()); } - private cleanupAndEvictTask(taskId: string) { - const wrapper = this.tasks.get(taskId); - if (wrapper) { - logger.info( - `[CoderAgentExecutor] Task ${taskId} reached terminal state ${wrapper.task.taskState}. Evicting and disposing.`, - ); - wrapper.task.dispose(); - this.tasks.delete(taskId); - } - } - cancelTask = async ( taskId: string, eventBus: ExecutionEventBus, @@ -330,7 +385,7 @@ export class CoderAgentExecutor implements AgentExecutor { try { logger.info( - `[CoderAgentExecutor] Initiating cancellation for idle task ${taskId}.`, + `[CoderAgentExecutor] Initiating cancellation for task ${taskId}.`, ); task.cancelPendingTools('Task canceled by user request.'); @@ -397,434 +452,487 @@ export class CoderAgentExecutor implements AgentExecutor { getContextIdFromMetadata(sdkTask?.metadata) || uuidv4(); - logger.info( - `[CoderAgentExecutor] Executing for taskId: ${taskId}, contextId: ${contextId}`, - ); - logger.info( - `[CoderAgentExecutor] userMessage: ${JSON.stringify(userMessage)}`, - ); - eventBus.on('event', (event: AgentExecutionEvent) => - logger.info('[EventBus event]: ', event), - ); - - const store = requestStorage.getStore(); - if (!store) { - logger.error( - '[CoderAgentExecutor] Could not get request from async local storage. Cancellation on socket close will not be handled for this request.', - ); - } - - const abortController = new AbortController(); - const abortSignal = abortController.signal; - - if (!this.activeAbortControllers.has(taskId)) { - this.activeAbortControllers.set(taskId, new Set()); - } - this.activeAbortControllers.get(taskId)!.add(abortController); - - let proceedToMainLoop = false; - let wrapper: TaskWrapper | undefined; - let isPrimaryExecution = false; - + let agentSettings: AgentSettings; try { - if (store) { - const socket = store.req.socket; - const onSocketEnd = () => { - logger.info( - `[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`, - ); - if (!abortController.signal.aborted) { - abortController.abort(); - } - socket.removeListener('end', onSocketEnd); + const existingWrapper = this.tasks.get(taskId); + if (existingWrapper) { + agentSettings = existingWrapper.agentSettings; + } else if (sdkTask) { + const persistedState = getPersistedState(sdkTask.metadata || {}); + agentSettings = { + kind: CoderAgentEvent.StateAgentSettingsEvent, + ...(persistedState?._agentSettings ?? {}), + workspacePath: validateWorkspacePath( + persistedState?._agentSettings?.workspacePath, + ), + isTrusted: false, }; - socket.on('end', onSocketEnd); - socket.once('close', () => socket.removeListener('end', onSocketEnd)); - abortSignal.addEventListener('abort', () => - socket.removeListener('end', onSocketEnd), - ); - logger.info( - `[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`, + } else { + const rawAgentSettings = getAgentSettingsFromMetadata( + userMessage.metadata, ); + agentSettings = { + kind: CoderAgentEvent.StateAgentSettingsEvent, + ...(rawAgentSettings || {}), + workspacePath: validateWorkspacePath(rawAgentSettings?.workspacePath), + isTrusted: false, + }; } + } catch (error) { + logger.error( + `[CoderAgentExecutor] Invalid workspace path during execution setup for task ${taskId}:`, + error, + ); + if (eventBus) { + void pushTaskStateFailed(error, eventBus, taskId, contextId); + } + throw error; + } - // Check if the task is currently initializing - if (this.initializingTasks.has(taskId)) { + return this.runInIsolatedEnv(agentSettings, async () => { + try { logger.info( - `[CoderAgentExecutor] Task ${taskId} is currently initializing. Waiting for initialization to complete.`, + `[CoderAgentExecutor] Executing for taskId: ${taskId}, contextId: ${contextId}`, ); - const initPromise = this.initializationPromises.get(taskId); - if (initPromise) { - try { - wrapper = await initPromise; - } catch { + logger.info( + `[CoderAgentExecutor] userMessage: ${JSON.stringify(userMessage)}`, + ); + eventBus.on('event', (event: AgentExecutionEvent) => + logger.info('[EventBus event]: ', event), + ); + + const store = requestStorage.getStore(); + if (!store) { + logger.error( + '[CoderAgentExecutor] Could not get request from async local storage. Cancellation on socket close will not be handled for this request.', + ); + } + + const abortController = new AbortController(); + const abortSignal = abortController.signal; + + if (!this.activeAbortControllers.has(taskId)) { + this.activeAbortControllers.set(taskId, new Set()); + } + this.activeAbortControllers.get(taskId)!.add(abortController); + + let proceedToMainLoop = false; + let wrapper: TaskWrapper | undefined; + let isPrimaryExecution = false; + + try { + if (store) { + const socket = store.req.socket; + const onSocketEnd = () => { + logger.info( + `[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`, + ); + if (!abortController.signal.aborted) { + abortController.abort(); + } + socket.removeListener('end', onSocketEnd); + }; + socket.on('end', onSocketEnd); + socket.once('close', () => + socket.removeListener('end', onSocketEnd), + ); + abortSignal.addEventListener('abort', () => + socket.removeListener('end', onSocketEnd), + ); + logger.info( + `[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`, + ); + } + + // Check if the task is currently initializing + if (this.initializingTasks.has(taskId)) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} is currently initializing. Waiting for initialization to complete.`, + ); + const initPromise = this.initializationPromises.get(taskId); + if (initPromise) { + try { + wrapper = await initPromise; + } catch { + logger.error( + `[CoderAgentExecutor] Failed to wait for task ${taskId} initialization.`, + ); + return; + } + } + } + + if (!wrapper) { + this.initializingTasks.add(taskId); + const initPromise = (async () => { + let initializedWrapper: TaskWrapper | undefined; + initializedWrapper = this.tasks.get(taskId); + + if (initializedWrapper) { + initializedWrapper.task.eventBus = eventBus; + logger.info( + `[CoderAgentExecutor] Task ${taskId} found in memory cache.`, + ); + } else if (sdkTask) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} found in TaskStore. Reconstructing...`, + ); + try { + initializedWrapper = await this.reconstruct( + sdkTask, + eventBus, + ); + } catch (e) { + logger.error( + `[CoderAgentExecutor] Aborting execution due to failed task reconstruction for task ${taskId}:`, + e, + ); + throw e; + } + } else { + initializedWrapper = await this.createTask( + taskId, + contextId, + agentSettings, + eventBus, + ); + const newTaskSDK = initializedWrapper.toSDKTask(); + eventBus.publish({ + ...newTaskSDK, + kind: 'task', + status: { + state: 'submitted', + timestamp: new Date().toISOString(), + }, + history: [userMessage], + }); + try { + await this.taskStore?.save(newTaskSDK); + logger.info( + `[CoderAgentExecutor] New task ${taskId} saved to store.`, + ); + } catch (saveError) { + logger.error( + `[CoderAgentExecutor] Failed to save new task ${taskId} to store:`, + saveError, + ); + } + } + return initializedWrapper; + })(); + + this.initializationPromises.set(taskId, initPromise); + + try { + wrapper = await initPromise; + } catch { + // Error is already handled/logged inside the promise + return; + } finally { + this.initializingTasks.delete(taskId); + this.initializationPromises.delete(taskId); + } + } + + if (!wrapper) { logger.error( - `[CoderAgentExecutor] Failed to wait for task ${taskId} initialization.`, + `[CoderAgentExecutor] Task ${taskId} is unexpectedly undefined after load/create.`, ); return; } - } - } - if (!wrapper) { - this.initializingTasks.add(taskId); - const initPromise = (async () => { - let initializedWrapper: TaskWrapper | undefined; - initializedWrapper = this.tasks.get(taskId); + const currentTask = wrapper.task; - if (initializedWrapper) { - initializedWrapper.task.eventBus = eventBus; - logger.info( - `[CoderAgentExecutor] Task ${taskId} found in memory cache.`, + if ( + ['canceled', 'failed', 'completed'].includes(currentTask.taskState) + ) { + logger.warn( + `[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`, ); - } else if (sdkTask) { - logger.info( - `[CoderAgentExecutor] Task ${taskId} found in TaskStore. Reconstructing...`, + return; + } + + if (abortSignal.aborted) { + logger.warn( + `[CoderAgentExecutor] Task ${taskId} was aborted during initialization.`, + ); + const isExplicitCancel = this.explicitlyCanceledTasks.has(taskId); + const finalState = isExplicitCancel ? 'canceled' : 'input-required'; + const message = isExplicitCancel + ? 'Task canceled by user request.' + : 'Execution aborted by client.'; + currentTask.setTaskStateAndPublishUpdate( + finalState, + { kind: CoderAgentEvent.StateChangeEvent }, + message, + undefined, + true, ); try { - initializedWrapper = await this.reconstruct(sdkTask, eventBus); - } catch (e) { - logger.error( - `[CoderAgentExecutor] Aborting execution due to failed task reconstruction for task ${taskId}:`, - e, - ); - throw e; - } - } else { - let agentSettings: AgentSettings; - try { - const rawAgentSettings = getAgentSettingsFromMetadata( - userMessage.metadata, - ); - const validatedWorkspacePath = await validateWorkspacePath( - rawAgentSettings?.workspacePath, - ); - agentSettings = { - kind: CoderAgentEvent.StateAgentSettingsEvent, - ...(rawAgentSettings || {}), - workspacePath: validatedWorkspacePath, - isTrusted: false, - }; - initializedWrapper = await this.createTask( - taskId, - contextId, - agentSettings, - eventBus, - ); - } catch (error) { - logger.error( - `[CoderAgentExecutor] Error creating task ${taskId}:`, - error, - ); - void pushTaskStateFailed(error, eventBus, taskId, contextId); - throw error; - } - const newTaskSDK = initializedWrapper.toSDKTask(); - eventBus.publish({ - ...newTaskSDK, - kind: 'task', - status: { - state: 'submitted', - timestamp: new Date().toISOString(), - }, - history: [userMessage], - }); - try { - await this.taskStore?.save(newTaskSDK); - logger.info( - `[CoderAgentExecutor] New task ${taskId} saved to store.`, - ); + await this.taskStore?.save(wrapper.toSDKTask()); } catch (saveError) { logger.error( - `[CoderAgentExecutor] Failed to save new task ${taskId} to store:`, + `[CoderAgentExecutor] Failed to save task ${taskId} state:`, saveError, ); } + if (isExplicitCancel) { + this.cleanupAndEvictTask(taskId); + } + return; } - return initializedWrapper; - })(); - this.initializationPromises.set(taskId, initPromise); - - try { - wrapper = await initPromise; - } catch { - // Error is already handled/logged inside the promise - return; - } finally { - this.initializingTasks.delete(taskId); - this.initializationPromises.delete(taskId); - } - } - - if (!wrapper) { - logger.error( - `[CoderAgentExecutor] Task ${taskId} is unexpectedly undefined after load/create.`, - ); - return; - } - - const currentTask = wrapper.task; - - if (['canceled', 'failed', 'completed'].includes(currentTask.taskState)) { - logger.warn( - `[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`, - ); - return; - } - - if (abortSignal.aborted) { - logger.warn( - `[CoderAgentExecutor] Task ${taskId} was aborted during initialization.`, - ); - const isExplicitCancel = this.explicitlyCanceledTasks.has(taskId); - const finalState = isExplicitCancel ? 'canceled' : 'input-required'; - const message = isExplicitCancel - ? 'Task canceled by user request.' - : 'Execution aborted by client.'; - currentTask.setTaskStateAndPublishUpdate( - finalState, - { kind: CoderAgentEvent.StateChangeEvent }, - message, - undefined, - true, - ); - try { - await this.taskStore?.save(wrapper.toSDKTask()); - } catch (saveError) { - logger.error( - `[CoderAgentExecutor] Failed to save task ${taskId} state:`, - saveError, - ); - } - if (isExplicitCancel) { - this.cleanupAndEvictTask(taskId); - } - return; - } - - if (this.executingTasks.has(taskId)) { - logger.info( - `[CoderAgentExecutor] Task ${taskId} has a pending execution. Processing message and yielding.`, - ); - currentTask.eventBus = eventBus; - try { - for await (const _ of currentTask.acceptUserMessage( - requestContext, - abortController.signal, - )) { + if (this.executingTasks.has(taskId)) { logger.info( - `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`, + `[CoderAgentExecutor] Task ${taskId} has a pending execution. Processing message and yielding.`, ); + currentTask.eventBus = eventBus; + try { + for await (const _ of currentTask.acceptUserMessage( + requestContext, + abortController.signal, + )) { + logger.info( + `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`, + ); + } + } catch (error) { + if (!abortController.signal.aborted) { + throw error; + } + logger.info( + `[CoderAgentExecutor] Secondary execution loop for task ${taskId} was aborted.`, + ); + } + return; } - } catch (error) { - if (!abortController.signal.aborted) { - throw error; + + isPrimaryExecution = true; + + proceedToMainLoop = true; + } finally { + this.explicitlyCanceledTasks.delete(taskId); + if (!proceedToMainLoop) { + const controllers = this.activeAbortControllers.get(taskId); + if (controllers) { + controllers.delete(abortController); + if (controllers.size === 0) { + this.activeAbortControllers.delete(taskId); + } + } } + } + + const currentTask = wrapper.task; + try { logger.info( - `[CoderAgentExecutor] Secondary execution loop for task ${taskId} was aborted.`, + `[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`, ); - } - return; - } + this.executingTasks.add(taskId); - isPrimaryExecution = true; - - proceedToMainLoop = true; - } finally { - this.explicitlyCanceledTasks.delete(taskId); - if (!proceedToMainLoop) { - const controllers = this.activeAbortControllers.get(taskId); - if (controllers) { - controllers.delete(abortController); - if (controllers.size === 0) { - this.activeAbortControllers.delete(taskId); - } - } - } - } - - const currentTask = wrapper.task; - try { - logger.info( - `[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`, - ); - this.executingTasks.add(taskId); - - let agentTurnActive = true; - logger.info(`[CoderAgentExecutor] Task ${taskId}: Processing user turn.`); - let agentEvents = currentTask.acceptUserMessage( - requestContext, - abortSignal, - ); - - while (agentTurnActive) { - if (abortSignal.aborted) { + let agentTurnActive = true; logger.info( - `[CoderAgentExecutor] Task ${taskId} aborted before turn. Exiting loop.`, + `[CoderAgentExecutor] Task ${taskId}: Processing user turn.`, ); - throw new Error('Execution aborted'); - } - logger.info( - `[CoderAgentExecutor] Task ${taskId}: Processing agent turn (LLM stream).`, - ); - const toolCallRequests: ToolCallRequestInfo[] = []; - for await (const event of agentEvents) { - if (abortSignal.aborted) { - logger.warn( - `[CoderAgentExecutor] Task ${taskId}: Abort signal received during agent event processing.`, + let agentEvents = currentTask.acceptUserMessage( + requestContext, + abortSignal, + ); + + while (agentTurnActive) { + if (abortSignal.aborted) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} aborted before turn. Exiting loop.`, + ); + throw new Error('Execution aborted'); + } + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Processing agent turn (LLM stream).`, ); - throw new Error('Execution aborted'); - } - if (event.type === GeminiEventType.ToolCallRequest) { - toolCallRequests.push(event.value); - continue; - } - await currentTask.acceptAgentMessage(event); - } + const toolCallRequests: ToolCallRequestInfo[] = []; + for await (const event of agentEvents) { + if (abortSignal.aborted) { + logger.warn( + `[CoderAgentExecutor] Task ${taskId}: Abort signal received during agent event processing.`, + ); + throw new Error('Execution aborted'); + } + if (event.type === GeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + continue; + } + await currentTask.acceptAgentMessage(event); + } - if (abortSignal.aborted) throw new Error('Execution aborted'); + if (abortSignal.aborted) throw new Error('Execution aborted'); - if (toolCallRequests.length > 0) { - logger.info( - `[CoderAgentExecutor] Task ${taskId}: Found ${toolCallRequests.length} tool call requests. Scheduling as a batch.`, - ); - await currentTask.scheduleToolCalls(toolCallRequests, abortSignal); - } - - logger.info( - `[CoderAgentExecutor] Task ${taskId}: Waiting for pending tools if any.`, - ); - await currentTask.waitForPendingTools(); - logger.info( - `[CoderAgentExecutor] Task ${taskId}: All pending tools completed or none were pending.`, - ); - - if (abortSignal.aborted) throw new Error('Execution aborted'); - - if (currentTask.hasPendingTools) { - logger.info( - `[CoderAgentExecutor] Task ${taskId}: There are still ${currentTask.pendingToolsCount} pending tools waiting for approval. Yielding to user.`, - ); - agentTurnActive = false; - } else { - const completedTools = currentTask.getAndClearCompletedTools(); - - if (completedTools.length > 0) { - if (completedTools.every((tool) => tool.status === 'cancelled')) { + if (toolCallRequests.length > 0) { logger.info( - `[CoderAgentExecutor] Task ${taskId}: All tool calls were cancelled. Updating history and ending agent turn.`, + `[CoderAgentExecutor] Task ${taskId}: Found ${toolCallRequests.length} tool call requests. Scheduling as a batch.`, ); - currentTask.addToolResponsesToHistory(completedTools); - agentTurnActive = false; - const stateChange: StateChange = { - kind: CoderAgentEvent.StateChangeEvent, - }; - currentTask.setTaskStateAndPublishUpdate( - 'input-required', - stateChange, - undefined, - undefined, - true, - ); - } else { - logger.info( - `[CoderAgentExecutor] Task ${taskId}: Found ${completedTools.length} completed tool calls. Sending results back to LLM.`, - ); - - agentEvents = currentTask.sendCompletedToolsToLlm( - completedTools, + await currentTask.scheduleToolCalls( + toolCallRequests, abortSignal, ); } - } else { - logger.info( - `[CoderAgentExecutor] Task ${taskId}: No more tool calls to process. Ending agent turn.`, - ); - agentTurnActive = false; - } - } - } - logger.info( - `[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`, - ); - const stateChange: StateChange = { - kind: CoderAgentEvent.StateChangeEvent, - }; - currentTask.setTaskStateAndPublishUpdate( - 'input-required', - stateChange, - undefined, - undefined, - true, - ); - } catch (error) { - if (abortSignal.aborted) { - logger.warn(`[CoderAgentExecutor] Task ${taskId} execution aborted.`); - currentTask.cancelPendingTools('Execution aborted'); - if ( - currentTask.taskState !== 'canceled' && - currentTask.taskState !== 'failed' - ) { - currentTask.setTaskStateAndPublishUpdate( - 'input-required', - { kind: CoderAgentEvent.StateChangeEvent }, - 'Execution aborted by client.', - undefined, - true, + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Waiting for pending tools if any.`, + ); + await currentTask.waitForPendingTools(); + logger.info( + `[CoderAgentExecutor] Task ${taskId}: All pending tools completed or none were pending.`, + ); + + if (abortSignal.aborted) throw new Error('Execution aborted'); + + if (currentTask.hasPendingTools) { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: There are still ${currentTask.pendingToolsCount} pending tools waiting for approval. Yielding to user.`, + ); + agentTurnActive = false; + } else { + const completedTools = currentTask.getAndClearCompletedTools(); + + if (completedTools.length > 0) { + if ( + completedTools.every((tool) => tool.status === 'cancelled') + ) { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: All tool calls were cancelled. Updating history and ending agent turn.`, + ); + currentTask.addToolResponsesToHistory(completedTools); + agentTurnActive = false; + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + currentTask.setTaskStateAndPublishUpdate( + 'input-required', + stateChange, + undefined, + undefined, + true, + ); + } else { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Found ${completedTools.length} completed tool calls. Sending results back to LLM.`, + ); + + agentEvents = currentTask.sendCompletedToolsToLlm( + completedTools, + abortSignal, + ); + } + } else { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: No more tool calls to process. Ending agent turn.`, + ); + agentTurnActive = false; + } + } + } + + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`, ); - } - } else { - const errorMessage = - error instanceof Error ? error.message : 'Agent execution error'; - logger.error( - `[CoderAgentExecutor] Error executing agent for task ${taskId}:`, - error, - ); - currentTask.cancelPendingTools(errorMessage); - if (currentTask.taskState !== 'failed') { const stateChange: StateChange = { kind: CoderAgentEvent.StateChangeEvent, }; currentTask.setTaskStateAndPublishUpdate( - 'failed', + 'input-required', stateChange, - errorMessage, + undefined, undefined, true, ); - } - } - } finally { - if (isPrimaryExecution) { - const controllers = this.activeAbortControllers.get(taskId); - if (controllers) { - controllers.delete(abortController); - if (controllers.size === 0) { - this.activeAbortControllers.delete(taskId); + } catch (error) { + if (abortSignal.aborted) { + logger.warn( + `[CoderAgentExecutor] Task ${taskId} execution aborted.`, + ); + currentTask.cancelPendingTools('Execution aborted'); + if ( + currentTask.taskState !== 'canceled' && + currentTask.taskState !== 'failed' + ) { + currentTask.setTaskStateAndPublishUpdate( + 'input-required', + { kind: CoderAgentEvent.StateChangeEvent }, + 'Execution aborted by client.', + undefined, + true, + ); + } + } else { + const errorMessage = + error instanceof Error ? error.message : 'Agent execution error'; + logger.error( + `[CoderAgentExecutor] Error executing agent for task ${taskId}:`, + error, + ); + currentTask.cancelPendingTools(errorMessage); + if (currentTask.taskState !== 'failed') { + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + currentTask.setTaskStateAndPublishUpdate( + 'failed', + stateChange, + errorMessage, + undefined, + true, + ); + } + } + } finally { + if (isPrimaryExecution) { + const controllers = this.activeAbortControllers.get(taskId); + if (controllers) { + controllers.delete(abortController); + if (controllers.size === 0) { + this.activeAbortControllers.delete(taskId); + } + } + this.executingTasks.delete(taskId); + logger.info( + `[CoderAgentExecutor] Saving final state for task ${taskId}.`, + ); + try { + await this.taskStore?.save(wrapper.toSDKTask()); + logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`); + } catch (saveError) { + logger.error( + `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`, + saveError, + ); + } + + if ( + ['canceled', 'failed', 'completed'].includes( + currentTask.taskState, + ) + ) { + this.cleanupAndEvictTask(taskId); + } } } - this.executingTasks.delete(taskId); - logger.info( - `[CoderAgentExecutor] Saving final state for task ${taskId}.`, + } catch (error) { + logger.error( + `[CoderAgentExecutor] Failed to execute task ${taskId}:`, + error, ); - try { - await this.taskStore?.save(wrapper.toSDKTask()); - logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`); - } catch (saveError) { - logger.error( - `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`, - saveError, - ); - } - - if ( - ['canceled', 'failed', 'completed'].includes(currentTask.taskState) - ) { - this.cleanupAndEvictTask(taskId); - } + void pushTaskStateFailed(error, eventBus, taskId, contextId); + throw error; } - } + }).catch((error) => { + logger.error( + `[CoderAgentExecutor] Failed to initialize isolated environment for task ${taskId}:`, + error, + ); + if (eventBus) { + void pushTaskStateFailed(error, eventBus, taskId, contextId); + } + throw error; + }); } } diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index f4c0aa9b28..15c5dd9562 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as dotenv from 'dotenv'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { AuthType, @@ -17,6 +18,7 @@ import { startupProfiler, PREVIEW_GEMINI_MODEL, homedir, + tmpdir, GitService, fetchAdminControlsOnce, getCodeAssistServer, @@ -28,28 +30,246 @@ import { type TelemetryTarget, type ConfigParameters, type ExtensionLoader, + resolveToRealPath, } from '@google/gemini-cli-core'; import { logger } from '../utils/logger.js'; import type { Settings } from './settings.js'; import { type AgentSettings, CoderAgentEvent } from '../types.js'; -const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST']; +export const envStorage = new AsyncLocalStorage(); + +const deletedKeysSymbol = Symbol('deletedKeys'); +export const cwdSymbol = Symbol('cwd'); + +export interface TaskEnv extends Record { + [deletedKeysSymbol]?: Set; + [cwdSymbol]?: string; +} + +// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task +const originalEnv = process.env; +const envProxy = new Proxy(originalEnv, { + get(target, prop) { + if (typeof prop === 'string') { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const deleted = taskEnv[deletedKeysSymbol]; + if (deleted?.has(prop)) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) { + return taskEnv[prop]; + } + } + return target[prop]; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + return target[prop as any]; + }, + has(target, prop) { + if (typeof prop === 'string') { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const deleted = taskEnv[deletedKeysSymbol]; + if (deleted?.has(prop)) { + return false; + } + if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) { + return true; + } + } + return prop in target; + } + return prop in target; + }, + set(target, prop, value) { + if (typeof prop === 'string') { + if ( + prop === '__proto__' || + prop === 'constructor' || + prop === 'prototype' + ) { + return false; + } + const taskEnv = envStorage.getStore(); + if (taskEnv) { + taskEnv[deletedKeysSymbol]?.delete(prop); + taskEnv[prop] = String(value); + return true; + } + target[prop] = String(value); + return true; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + target[prop as any] = value; + return true; + }, + deleteProperty(target, prop) { + if (typeof prop === 'string') { + if ( + prop === '__proto__' || + prop === 'constructor' || + prop === 'prototype' + ) { + return false; + } + const taskEnv = envStorage.getStore(); + if (taskEnv) { + delete taskEnv[prop]; + (taskEnv[deletedKeysSymbol] ??= new Set()).add(prop); + return true; + } + delete target[prop]; + return true; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + return delete target[prop as any]; + }, + ownKeys(target) { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const keys = new Set([ + ...Object.getOwnPropertyNames(target), + ...Object.getOwnPropertySymbols(target), + ...Object.keys(taskEnv), + ]); + taskEnv[deletedKeysSymbol]?.forEach((key) => { + keys.delete(key); + }); + return Array.from(keys); + } + return [ + ...Object.getOwnPropertyNames(target), + ...Object.getOwnPropertySymbols(target), + ]; + }, + getOwnPropertyDescriptor(target, prop) { + const taskEnv = envStorage.getStore(); + if (taskEnv && typeof prop === 'string') { + const deleted = taskEnv[deletedKeysSymbol]; + if (deleted?.has(prop)) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) { + return { + value: taskEnv[prop], + writable: true, + enumerable: true, + configurable: true, + }; + } + } + return Object.getOwnPropertyDescriptor(target, prop); + }, + defineProperty(target, prop, descriptor) { + if (typeof prop === 'string') { + if ( + prop === '__proto__' || + prop === 'constructor' || + prop === 'prototype' + ) { + return false; + } + const taskEnv = envStorage.getStore(); + if (taskEnv) { + taskEnv[deletedKeysSymbol]?.delete(prop); + taskEnv[prop] = + descriptor.value !== undefined ? String(descriptor.value) : undefined; + return true; + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + Object.defineProperty(target, prop as any, descriptor); + return true; + }, +}); + +Object.defineProperty(process, 'env', { + value: envProxy, + writable: false, + configurable: true, +}); + +// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way +// to simulate workspace isolation in a concurrent server. However, please be aware of a critical +// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child +// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level +// working directory of the process, NOT the JS-level process.cwd() function. +// To prevent cross-task interference, all file paths in the core package must be resolved to +// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd() +// before being passed to native APIs. +const originalCwd = process.cwd; +process.cwd = function () { + const taskEnv = envStorage.getStore(); + if (taskEnv && taskEnv[cwdSymbol]) { + return taskEnv[cwdSymbol]; + } + return originalCwd.call(process); +}; + +const originalChdir = process.chdir; +process.chdir = function (directory: string) { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const resolved = path.resolve(process.cwd(), directory); + try { + const stats = fs.statSync(resolved); + if (!stats.isDirectory()) { + const err = new Error( + "ENOTDIR: not a directory, chdir '" + resolved + "'", + ); + (err as NodeJS.ErrnoException).code = 'ENOTDIR'; + throw err; + } + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + const chdirErr = new Error( + "ENOENT: no such file or directory, chdir '" + resolved + "'", + ); + (chdirErr as NodeJS.ErrnoException).code = 'ENOENT'; + throw chdirErr; + } + throw err; + } + taskEnv[cwdSymbol] = resolved; + return; + } + return originalChdir.call(process, directory); +}; + +export function getEnv(key: string): string | undefined { + return process.env[key]; +} export async function loadConfig( settings: Settings, extensionLoader: ExtensionLoader, taskId: string, trusted: boolean = false, + workspaceDir: string = process.cwd(), ): Promise { - const workspaceDir = process.cwd(); + const workspaceEnv = await loadEnvironment(trusted, workspaceDir); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const envVars: Record = { ...process.env } as Record< + string, + string + >; + Object.assign(envVars, workspaceEnv); + + const getEnvLocal = (key: string) => envVars[key]; const folderTrust = settings.folderTrust === true || - process.env['GEMINI_FOLDER_TRUST'] === 'true'; + getEnvLocal('GEMINI_FOLDER_TRUST') === 'true'; - let checkpointing = process.env['CHECKPOINTING'] - ? process.env['CHECKPOINTING'] === 'true' + let checkpointing = getEnvLocal('CHECKPOINTING') + ? getEnvLocal('CHECKPOINTING') === 'true' : settings.checkpointing?.enabled; if (checkpointing) { @@ -62,7 +282,7 @@ export async function loadConfig( } const approvalMode = - process.env['GEMINI_YOLO_MODE'] === 'true' + getEnvLocal('GEMINI_YOLO_MODE') === 'true' ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; @@ -91,8 +311,9 @@ export async function loadConfig( embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, sandbox: undefined, // Sandbox might not be relevant for a server-side agent targetDir: workspaceDir, // Or a specific directory the agent operates on - debugMode: process.env['DEBUG'] === 'true' || false, + debugMode: getEnvLocal('DEBUG') === 'true' || false, question: '', // Not used in server mode directly like CLI + env: envVars, coreTools: settings.tools?.core || undefined, excludeTools: settings.tools?.exclude || undefined, @@ -107,7 +328,7 @@ export async function loadConfig( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion target: settings.telemetry?.target as TelemetryTarget, otlpEndpoint: - process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? + getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ?? settings.telemetry?.otlpEndpoint, logPrompts: settings.telemetry?.logPrompts, }, @@ -119,8 +340,8 @@ export async function loadConfig( settings.fileFiltering?.enableRecursiveFileSearch, customIgnoreFilePaths: [ ...(settings.fileFiltering?.customIgnoreFilePaths || []), - ...(process.env['CUSTOM_IGNORE_FILE_PATHS'] - ? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter) + ...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS') + ? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter) : []), ], }, @@ -179,7 +400,7 @@ export async function loadConfig( await config.waitForMcpInit(); startupProfiler.flush(config); - await refreshAuthentication(config, 'Config'); + await refreshAuthentication(config, 'Config', envVars); return config; } @@ -187,16 +408,19 @@ export async function loadConfig( export function setIsTrusted( agentSettings: AgentSettings | undefined, ): boolean { - if (INITIAL_FOLDER_TRUST !== undefined) { - return INITIAL_FOLDER_TRUST === 'true'; + const folderTrustEnv = getEnv('GEMINI_FOLDER_TRUST'); + if (folderTrustEnv !== undefined) { + return folderTrustEnv === 'true'; } return !!agentSettings?.isTrusted; } -export function setTargetDir(agentSettings: AgentSettings | undefined): string { +export async function setTargetDir( + agentSettings: AgentSettings | undefined, +): Promise { const originalCWD = process.cwd(); const targetDir = - process.env['CODER_AGENT_WORKSPACE_PATH'] ?? + getEnv('CODER_AGENT_WORKSPACE_PATH') ?? (agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent ? agentSettings.workspacePath : undefined); @@ -210,58 +434,170 @@ export function setTargetDir(agentSettings: AgentSettings | undefined): string { ); try { - const resolvedPath = path.resolve(targetDir); - process.chdir(resolvedPath); + let resolvedPath: string; + try { + resolvedPath = resolveToRealPath(targetDir); + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + const parentDir = path.dirname(path.resolve(targetDir)); + resolvedPath = path.join( + resolveToRealPath(parentDir), + path.basename(targetDir), + ); + } else { + throw err; + } + } + + const isTestEnv = + process.env['VITEST'] === 'true' || + process.env['NODE_ENV'] === 'test' || + process.argv.some((arg) => arg.includes('vitest')) || + resolvedPath.startsWith(resolveToRealPath(tmpdir())); + + const allowedRoot = resolveToRealPath( + getEnv('CODER_AGENT_ALLOWED_ROOT') || + (isTestEnv ? path.parse(resolvedPath).root : homedir()), + ); + const relative = path.relative(allowedRoot, resolvedPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error( + `Workspace path ${resolvedPath} is outside the allowed root directory`, + ); + } + + let stats: fs.Stats; + try { + stats = await fs.promises.stat(resolvedPath); + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + if (isTestEnv) { + await fs.promises.mkdir(resolvedPath, { recursive: true }); + stats = await fs.promises.stat(resolvedPath); + } else { + throw new Error(`Workspace path ${resolvedPath} does not exist`); + } + } else { + throw err; + } + } + + if (!stats.isDirectory()) { + throw new Error(`Workspace path ${resolvedPath} is not a directory`); + } + return resolvedPath; } catch (e) { - logger.error( - `[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`, - ); - return originalCWD; + logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`); + throw e; } } -export function loadEnvironment(): void { - const envFilePath = findEnvFile(process.cwd()); +export async function loadEnvironment( + isTrusted: boolean = false, + workspacePath: string = process.cwd(), +): Promise> { + // For untrusted workspaces, we completely bypass workspace-level .env loading + // and only load environment variables from the user's trusted home directory. + let envFilePath: string | null = null; + if (isTrusted) { + envFilePath = await findEnvFile(workspacePath); + } else { + const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); + try { + await fs.promises.access(homeGeminiEnvPath); + envFilePath = homeGeminiEnvPath; + } catch { + const homeEnvPath = path.join(homedir(), '.env'); + try { + await fs.promises.access(homeEnvPath); + envFilePath = homeEnvPath; + } catch { + // Ignore + } + } + } + const envVars: Record = {}; if (envFilePath) { - dotenv.config({ path: envFilePath, override: true }); + try { + const content = await fs.promises.readFile(envFilePath, 'utf-8'); + const parsed = dotenv.parse(content); + for (const key in parsed) { + if ( + Object.prototype.hasOwnProperty.call(parsed, key) && + key !== '__proto__' && + key !== 'constructor' && + key !== 'prototype' + ) { + envVars[key] = parsed[key]; + } + } + } catch { + // Ignore errors + } } + return envVars; } -function findEnvFile(startDir: string): string | null { +async function findEnvFile(startDir: string): Promise { let currentDir = path.resolve(startDir); while (true) { // prefer gemini-specific .env under GEMINI_DIR const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); - if (fs.existsSync(geminiEnvPath)) { + try { + await fs.promises.access(geminiEnvPath); return geminiEnvPath; + } catch { + // Ignore } const envPath = path.join(currentDir, '.env'); - if (fs.existsSync(envPath)) { + try { + await fs.promises.access(envPath); return envPath; + } catch { + // Ignore } const parentDir = path.dirname(currentDir); if (parentDir === currentDir || !parentDir) { - // check .env under home as fallback, again preferring gemini-specific .env - const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env'); - if (fs.existsSync(homeGeminiEnvPath)) { - return homeGeminiEnvPath; - } - const homeEnvPath = path.join(homedir(), '.env'); - if (fs.existsSync(homeEnvPath)) { - return homeEnvPath; - } - return null; + break; } currentDir = parentDir; } + // check .env under home as fallback, again preferring gemini-specific .env + const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); + try { + await fs.promises.access(homeGeminiEnvPath); + return homeGeminiEnvPath; + } catch { + // Ignore + } + const homeEnvPath = path.join(homedir(), '.env'); + try { + await fs.promises.access(homeEnvPath); + return homeEnvPath; + } catch { + return null; + } } async function refreshAuthentication( config: Config, logPrefix: string, + envVars: Record, ): Promise { - if (process.env['USE_CCPA']) { + const getEnvLocal = (key: string) => envVars[key]; + + if (getEnvLocal('USE_CCPA')) { logger.info(`[${logPrefix}] Using CCPA Auth:`); logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`); @@ -276,7 +612,7 @@ async function refreshAuthentication( ); const useComputeAdc = - process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'; + getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true'; const isHeadless = isHeadlessMode(); if (isHeadless || useComputeAdc) { @@ -305,11 +641,14 @@ async function refreshAuthentication( } logger.info( - `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`, + `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`, ); - } else if (process.env['GEMINI_API_KEY']) { + } else if (getEnvLocal('GEMINI_API_KEY')) { logger.info(`[${logPrefix}] Using Gemini API Key`); - await config.refreshAuth(AuthType.USE_GEMINI); + await config.refreshAuth( + AuthType.USE_GEMINI, + getEnvLocal('GEMINI_API_KEY'), + ); } else { const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`; logger.error(errorMessage); diff --git a/packages/a2a-server/src/config/extension.ts b/packages/a2a-server/src/config/extension.ts index 634cb04dc3..e0a69965d6 100644 --- a/packages/a2a-server/src/config/extension.ts +++ b/packages/a2a-server/src/config/extension.ts @@ -36,9 +36,12 @@ interface ExtensionConfig { excludeTools?: string[]; } -export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] { +export function loadExtensions( + workspaceDir: string, + isTrusted: boolean = false, +): GeminiCLIExtension[] { const allExtensions = [ - ...loadExtensionsFromDir(workspaceDir), + ...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []), ...loadExtensionsFromDir(homedir()), ]; diff --git a/packages/a2a-server/src/config/rce_vulnerability.test.ts b/packages/a2a-server/src/config/rce_vulnerability.test.ts new file mode 100644 index 0000000000..159fd0fe5a --- /dev/null +++ b/packages/a2a-server/src/config/rce_vulnerability.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +let mockHomeDir = ''; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + homedir: () => mockHomeDir, + }; +}); + +import { loadEnvironment } from './config.js'; + +describe('Vulnerability Mitigation: b-519269096', () => { + let tempWorkspaceDir: string; + + beforeEach(() => { + // Create a temporary home directory securely using mkdtempSync to ensure hermeticity + mockHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-mock-home-')); + + // Create a temporary workspace directory representing an untrusted repo + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-exploit-workspace-'), + ); + const geminiDir = path.join(tempWorkspaceDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + + // Mock process.cwd to return the untrusted workspace + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + fs.rmSync(mockHomeDir, { recursive: true, force: true }); + }); + + it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => { + const geminiDir = path.join(tempWorkspaceDir, '.gemini'); + fs.writeFileSync( + path.join(geminiDir, '.env'), + 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n', + ); + + // Ensure initially not set + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', ''); + vi.stubEnv('GEMINI_YOLO_MODE', ''); + + // Act: load environment with isTrusted = false + const envVars = await loadEnvironment(false); + + // Assert: In a SECURE system, these variables should NOT be loaded + expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined(); + expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined(); + expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy(); + expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy(); + }); + + it('should not load any variables from untrusted workspaces', async () => { + fs.writeFileSync( + path.join(tempWorkspaceDir, '.env'), + 'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n', + ); + + // Ensure initially not set + vi.stubEnv('GEMINI_API_KEY', ''); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', ''); + + // Act: load environment with isTrusted = false + const envVars = await loadEnvironment(false); + + // Assert: No variables should be loaded from the untrusted workspace + expect(envVars['GEMINI_API_KEY']).toBeUndefined(); + expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined(); + expect(process.env['GEMINI_API_KEY']).toBeFalsy(); + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy(); + }); + + it('should load all variables in trusted workspaces with isolation', async () => { + const geminiDir = path.join(tempWorkspaceDir, '.gemini'); + fs.writeFileSync( + path.join(geminiDir, '.env'), + 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n', + ); + + // Ensure initially not set + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', ''); + vi.stubEnv('GEMINI_YOLO_MODE', ''); + + // Load environment variables + const envVars = await loadEnvironment(true); + + // Assert: In a trusted workspace, variables should be loaded + expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true'); + expect(envVars['GEMINI_YOLO_MODE']).toBe('true'); + + // Assert: Global process.env should NOT be polluted + expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy(); + expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy(); + }); +}); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index e647ecb05c..634f77bd0b 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -197,8 +197,7 @@ async function handleExecuteCommand( export async function createApp() { try { // Load the server configuration once on startup. - const workspaceRoot = setTargetDir(undefined); - loadEnvironment(); + const workspaceRoot = await setTargetDir(undefined); // Use a temporary settings load to check if folder trust is enabled. // This is similar to how the CLI handles the initial trust check. @@ -209,13 +208,35 @@ export async function createApp() { isHeadless: isHeadlessMode(), }); + // Change the global working directory to the workspace root during startup + process.chdir(workspaceRoot); + + // Load environment globally for the server startup + const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot); + // Only assign safe server-config variables to process.env to prevent credential leakage + const allowedServerKeys = [ + 'CODER_AGENT_PORT', + 'CODER_AGENT_WORKSPACE_PATH', + 'GCS_BUCKET_NAME', + 'LOG_LEVEL', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_CLOUD_PROJECT', + 'GEMINI_CLI_USE_COMPUTE_ADC', + ]; + for (const key of allowedServerKeys) { + if (globalEnv[key] !== undefined) { + process.env[key] = globalEnv[key]; + } + } + const settings = loadSettings(workspaceRoot, isTrusted ?? false); - const extensions = loadExtensions(workspaceRoot); + const extensions = loadExtensions(workspaceRoot, isTrusted ?? false); const config = await loadConfig( settings, new SimpleExtensionLoader(extensions), 'a2a-server', isTrusted ?? false, + workspaceRoot, ); let git: GitService | undefined; diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts index 215b45837f..9c339d47c3 100644 --- a/packages/a2a-server/src/persistence/gcs.ts +++ b/packages/a2a-server/src/persistence/gcs.ts @@ -258,7 +258,7 @@ export class GCSTaskStore implements TaskStore { } const agentSettings = persistedState._agentSettings; - const workDir = setTargetDir(agentSettings); + const workDir = await setTargetDir(agentSettings); await fse.ensureDir(workDir); const workspaceFile = this.storage .bucket(this.bucketName) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5196e5cd63..b6cf957935 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -678,6 +678,7 @@ export interface ConfigParameters { truncateToolOutputThreshold?: number; eventEmitter?: EventEmitter; useWriteTodos?: boolean; + env?: Record; workspacePoliciesDir?: string; policyEngineConfig?: PolicyEngineConfig; directWebFetch?: boolean; @@ -896,6 +897,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly useTerminalBuffer: boolean; private readonly useRenderProcess: boolean; private shellExecutionConfig: ShellExecutionConfig; + readonly env?: Record; private readonly extensionManagement: boolean = true; private readonly extensionRegistryURI: string | undefined; private readonly truncateToolOutputThreshold: number; @@ -1119,6 +1121,7 @@ export class Config implements McpContext, AgentLoopContext { this.checkpointing = params.checkpointing ?? false; this.proxy = params.proxy; this.cwd = params.cwd ?? process.cwd(); + this.env = params.env; this.fileDiscoveryService = params.fileDiscoveryService ?? null; this.bugCommand = params.bugCommand; this.model = params.model; diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index fb4ba249a1..e0b33d8c7b 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -154,6 +154,13 @@ export async function createContentGeneratorConfig( vertexAiRouting, }; + const getEnv = (key: string) => { + if (config?.env && config.env[key] !== undefined) { + return config.env[key]; + } + return process.env[key]; + }; + // If we are using Google auth or we are in Cloud Shell, there is nothing else to validate for now. // Return before touching the API-key keychain: on Linux without a Secret Service // (WSL/SSH/Docker/CI) keytar can block indefinitely on its functional probe. @@ -165,16 +172,13 @@ export async function createContentGeneratorConfig( } const geminiApiKey = - apiKey || - process.env['GEMINI_API_KEY'] || - (await loadApiKey()) || - undefined; - const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined; + apiKey || getEnv('GEMINI_API_KEY') || (await loadApiKey()) || undefined; + const googleApiKey = getEnv('GOOGLE_API_KEY') || undefined; const googleCloudProject = - process.env['GOOGLE_CLOUD_PROJECT'] || - process.env['GOOGLE_CLOUD_PROJECT_ID'] || + getEnv('GOOGLE_CLOUD_PROJECT') || + getEnv('GOOGLE_CLOUD_PROJECT_ID') || undefined; - const googleCloudLocation = process.env['GOOGLE_CLOUD_LOCATION'] || undefined; + const googleCloudLocation = getEnv('GOOGLE_CLOUD_LOCATION') || undefined; if (authType === AuthType.USE_GEMINI && geminiApiKey) { contentGeneratorConfig.apiKey = geminiApiKey; @@ -194,8 +198,7 @@ export async function createContentGeneratorConfig( } if (authType === AuthType.GATEWAY) { - contentGeneratorConfig.apiKey = - apiKey || process.env['GEMINI_API_KEY'] || ''; + contentGeneratorConfig.apiKey = apiKey || getEnv('GEMINI_API_KEY') || ''; contentGeneratorConfig.vertexai = false; return contentGeneratorConfig; diff --git a/packages/core/src/safety/checker-runner.test.ts b/packages/core/src/safety/checker-runner.test.ts index 6358541ecf..ff0a7b967a 100644 --- a/packages/core/src/safety/checker-runner.test.ts +++ b/packages/core/src/safety/checker-runner.test.ts @@ -34,6 +34,10 @@ describe('CheckerRunner', () => { beforeEach(() => { mockContextBuilder = new ContextBuilder({} as Config); + vi.spyOn(mockContextBuilder, 'config', 'get').mockReturnValue({ + env: {}, + getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'), + } as unknown as Config); mockRegistry = new CheckerRegistry('/mock/dist'); CheckerRegistry.prototype.resolveInProcess = vi.fn(); diff --git a/packages/core/src/safety/checker-runner.ts b/packages/core/src/safety/checker-runner.ts index c0ed57aa20..a2bcdc3266 100644 --- a/packages/core/src/safety/checker-runner.ts +++ b/packages/core/src/safety/checker-runner.ts @@ -168,6 +168,8 @@ export class CheckerRunner { return new Promise((resolve) => { const child = spawn(checkerPath, [], { stdio: ['pipe', 'pipe', 'pipe'], + cwd: this.contextBuilder.config.getWorkingDir(), + env: { ...process.env, ...this.contextBuilder.config.env }, }); let stdout = ''; diff --git a/packages/core/src/safety/context-builder.ts b/packages/core/src/safety/context-builder.ts index a8711b56e7..21136b04d6 100644 --- a/packages/core/src/safety/context-builder.ts +++ b/packages/core/src/safety/context-builder.ts @@ -15,6 +15,10 @@ import type { AgentLoopContext } from '../config/agent-loop-context.js'; export class ContextBuilder { constructor(private readonly context: AgentLoopContext) {} + get config() { + return this.context.config; + } + /** * Builds the full context object with all available data. */ diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 1e8bff00e6..e1e8f08da0 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -139,6 +139,7 @@ export interface ShellExecutionConfig { backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent'; originalCommand?: string; sessionId?: string; + env?: Record; } /** @@ -461,9 +462,10 @@ export class ShellExecutionService { const spawnArgs = [...argsPrefix, finalCommand]; // 2. Prepare Environment + const sourceEnv = shellExecutionConfig.env ?? process.env; const gitConfigKeys: string[] = []; if (!isInteractive) { - for (const key in process.env) { + for (const key in sourceEnv) { if (key.startsWith('GIT_CONFIG_')) { gitConfigKeys.push(key); } @@ -479,7 +481,7 @@ export class ShellExecutionService { ], }; - const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig); + const sanitizedEnv = sanitizeEnvironment(sourceEnv, sanitizationConfig); const baseEnv: Record = { ...sanitizedEnv, @@ -493,7 +495,7 @@ export class ShellExecutionService { if (!isInteractive) { // Ensure all GIT_CONFIG_* variables are preserved even if they were redacted for (const key of gitConfigKeys) { - baseEnv[key] = process.env[key]; + baseEnv[key] = sourceEnv[key]; } const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 9ad657febd..fa2b9a3215 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -656,6 +656,7 @@ export class ShellToolInvocation extends BaseToolInvocation< this.context.config.isInteractiveShellEnabled(), { ...shellExecutionConfig, + env: this.context.config.env, sessionId: this.context.config?.getSessionId?.() ?? 'default', pager: 'cat', sanitizationConfig: diff --git a/packages/core/src/tools/trackerTools.test.ts b/packages/core/src/tools/trackerTools.test.ts index 0d168f10ae..689d23164a 100644 --- a/packages/core/src/tools/trackerTools.test.ts +++ b/packages/core/src/tools/trackerTools.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { Config } from '../config/config.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import type { PolicyEngine } from '../policy/policy-engine.js'; @@ -30,6 +30,7 @@ describe('Tracker Tools Integration', () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tracker-tools-test-')); + vi.stubEnv('GEMINI_CLI_HOME', tempDir); config = new Config({ sessionId: `test-session-${Math.random().toString(36).substring(7)}`, targetDir: tempDir, @@ -42,6 +43,7 @@ describe('Tracker Tools Integration', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index 9074da7c91..5bdcb8f5f2 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -39,6 +39,10 @@ vi.mock('child_process', () => ({ spawnSync: vi.fn(() => ({ error: null, status: 0 })), })); +vi.mock('./headless.js', () => ({ + isHeadlessMode: vi.fn(() => false), +})); + const originalPlatform = process.platform; describe('editor utils', () => { diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 0903e81990..95ca1db454 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -9,6 +9,7 @@ import { promisify } from 'node:util'; import { once } from 'node:events'; import { debugLogger } from './debugLogger.js'; import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js'; +import { isHeadlessMode } from './headless.js'; const GUI_EDITORS = [ 'vscode', @@ -404,6 +405,13 @@ export async function openDiff( newPath: string, editor: EditorType, ): Promise { + if (isHeadlessMode()) { + debugLogger.warn( + 'External editor spawning is disabled in headless/server mode.', + ); + return; + } + const diffCommand = getDiffCommand(oldPath, newPath, editor); if (!diffCommand) { debugLogger.error('No diff tool available. Install a supported editor.');