fix(a2a-server): enforce workspace trust and task isolation to prevent RCE (#28470)

This commit is contained in:
luisfelipe-alt
2026-07-21 09:38:09 -07:00
committed by GitHub
parent acae7124bd
commit c776c665b0
18 changed files with 1121 additions and 489 deletions
@@ -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');
@@ -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<string, string>, cb: () => unknown) => cb(),
},
cwdSymbol: Symbol('cwd'),
}));
vi.mock('../config/settings.js', () => ({
File diff suppressed because it is too large Load Diff
+381 -42
View File
@@ -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<TaskEnv>();
const deletedKeysSymbol = Symbol('deletedKeys');
export const cwdSymbol = Symbol('cwd');
export interface TaskEnv extends Record<string, string | undefined> {
[deletedKeysSymbol]?: Set<string>;
[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<string | symbol>([
...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<Config> {
const workspaceDir = process.cwd();
const workspaceEnv = await loadEnvironment(trusted, workspaceDir);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const envVars: Record<string, string> = { ...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<string> {
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<Record<string, string>> {
// 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<string, string> = {};
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<string | null> {
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<string, string>,
): Promise<void> {
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);
+5 -2
View File
@@ -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()),
];
@@ -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<typeof import('@google/gemini-cli-core')>();
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();
});
});
+24 -3
View File
@@ -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;
+1 -1
View File
@@ -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)
+3
View File
@@ -678,6 +678,7 @@ export interface ConfigParameters {
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
env?: Record<string, string>;
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<string, string>;
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;
+13 -10
View File
@@ -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;
@@ -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();
@@ -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 = '';
@@ -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.
*/
@@ -139,6 +139,7 @@ export interface ShellExecutionConfig {
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
originalCommand?: string;
sessionId?: string;
env?: Record<string, string>;
}
/**
@@ -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<string, string | undefined> = {
...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);
+1
View File
@@ -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:
+3 -1
View File
@@ -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 });
});
+4
View File
@@ -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', () => {
+8
View File
@@ -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<void> {
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.');