Merge branch 'main' into fix/policy-utils-followup-20361

This commit is contained in:
Spencer
2026-03-10 19:41:44 -04:00
committed by GitHub
47 changed files with 2559 additions and 1432 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.34.0-nightly.20260304.28af4e127",
"version": "0.34.0-nightly.20260310.4653b126f",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google/genai": "1.41.0",
"@google/genai": "1.30.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
@@ -61,7 +61,7 @@
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^12.0.0",
"google-auth-library": "^10.5.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Automation overlay utilities for visual indication during browser automation.
*
* Provides functions to inject and remove a pulsating blue border overlay
* that indicates when the browser is under AI agent control.
*
* Uses the Web Animations API instead of injected <style> tags so the
* animation works on sites with strict Content Security Policies (e.g. google.com).
*
* The script strings are passed to chrome-devtools-mcp's evaluate_script tool
* which expects a plain function expression (NOT an IIFE).
*/
import type { BrowserManager } from './browserManager.js';
import { debugLogger } from '../../utils/debugLogger.js';
const OVERLAY_ELEMENT_ID = '__gemini_automation_overlay';
/**
* Builds the JavaScript function string that injects the automation overlay.
*
* Returns a plain arrow-function expression (no trailing invocation) because
* chrome-devtools-mcp's evaluate_script tool invokes it internally.
*
* Avoids nested template literals by using string concatenation for cssText.
*/
function buildInjectionScript(): string {
return `() => {
const id = '${OVERLAY_ELEMENT_ID}';
const existing = document.getElementById(id);
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = id;
overlay.setAttribute('aria-hidden', 'true');
overlay.setAttribute('role', 'presentation');
Object.assign(overlay.style, {
position: 'fixed',
top: '0',
left: '0',
right: '0',
bottom: '0',
zIndex: '2147483647',
pointerEvents: 'none',
border: '6px solid rgba(66, 133, 244, 1.0)',
});
document.documentElement.appendChild(overlay);
try {
overlay.animate([
{ borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' },
{ borderColor: 'rgba(66,133,244,1.0)', boxShadow: 'inset 0 0 16px rgba(66,133,244,0.5)' },
{ borderColor: 'rgba(66,133,244,0.3)', boxShadow: 'inset 0 0 8px rgba(66,133,244,0.15)' }
], { duration: 2000, iterations: Infinity, easing: 'ease-in-out' });
} catch (e) {
// Silently ignore animation errors, as they can happen on sites with strict CSP.
// The border itself is the most important visual indicator.
}
return 'overlay-injected';
}`;
}
/**
* Builds the JavaScript function string that removes the automation overlay.
*/
function buildRemovalScript(): string {
return `() => {
const el = document.getElementById('${OVERLAY_ELEMENT_ID}');
if (el) el.remove();
return 'overlay-removed';
}`;
}
/**
* Injects the automation overlay into the current page.
*/
export async function injectAutomationOverlay(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
debugLogger.log('Injecting automation overlay...');
const result = await browserManager.callTool(
'evaluate_script',
{ function: buildInjectionScript() },
signal,
);
if (result.isError) {
debugLogger.warn('Failed to inject automation overlay:', result);
} else {
debugLogger.log('Automation overlay injected successfully');
}
} catch (error) {
debugLogger.warn('Error injecting automation overlay:', error);
}
}
/**
* Removes the automation overlay from the current page.
*/
export async function removeAutomationOverlay(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
debugLogger.log('Removing automation overlay...');
const result = await browserManager.callTool(
'evaluate_script',
{ function: buildRemovalScript() },
signal,
);
if (result.isError) {
debugLogger.warn('Failed to remove automation overlay:', result);
} else {
debugLogger.log('Automation overlay removed successfully');
}
} catch (error) {
debugLogger.warn('Error removing automation overlay:', error);
}
}
@@ -9,6 +9,7 @@ import {
createBrowserAgentDefinition,
cleanupBrowserAgent,
} from './browserAgentFactory.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
@@ -35,6 +36,10 @@ vi.mock('./browserManager.js', () => ({
BrowserManager: vi.fn(() => mockBrowserManager),
}));
vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../utils/debugLogger.js', () => ({
debugLogger: {
log: vi.fn(),
@@ -55,6 +60,8 @@ describe('browserAgentFactory', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();
// Reset mock implementations
mockBrowserManager.ensureConnection.mockResolvedValue(undefined);
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
@@ -99,6 +106,28 @@ describe('browserAgentFactory', () => {
expect(mockBrowserManager.ensureConnection).toHaveBeenCalled();
});
it('should inject automation overlay when not in headless mode', async () => {
await createBrowserAgentDefinition(mockConfig, mockMessageBus);
expect(injectAutomationOverlay).toHaveBeenCalledWith(mockBrowserManager);
});
it('should not inject automation overlay when in headless mode', async () => {
const headlessConfig = makeFakeConfig({
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
},
},
});
await createBrowserAgentDefinition(headlessConfig, mockMessageBus);
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
it('should return agent definition with discovered tools', async () => {
const { definition } = await createBrowserAgentDefinition(
mockConfig,
@@ -27,6 +27,7 @@ import {
} from './browserAgentDefinition.js';
import { createMcpDeclarativeTools } from './mcpToolWrapper.js';
import { createAnalyzeScreenshotTool } from './analyzeScreenshot.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { debugLogger } from '../../utils/debugLogger.js';
/**
@@ -61,6 +62,15 @@ export async function createBrowserAgentDefinition(
printOutput('Browser connected with isolated MCP client.');
}
// Inject automation overlay if not in headless mode
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig?.customConfig?.headless) {
if (printOutput) {
printOutput('Injecting automation overlay...');
}
await injectAutomationOverlay(browserManager);
}
// Create declarative tools from dynamically discovered MCP tools
// These tools dispatch to browserManager's isolated client
const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus);
@@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserManager } from './browserManager.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import { injectAutomationOverlay } from './automationOverlay.js';
// Mock the MCP SDK
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
@@ -42,6 +43,10 @@ vi.mock('../../utils/debugLogger.js', () => ({
},
}));
vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
@@ -50,6 +55,7 @@ describe('BrowserManager', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();
// Setup mock config
mockConfig = makeFakeConfig({
@@ -411,4 +417,81 @@ describe('BrowserManager', () => {
expect(client.close).toHaveBeenCalled();
});
});
describe('overlay re-injection in callTool', () => {
it('should re-inject overlay after click in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: '1_2' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after navigate_page in non-headless mode', async () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('navigate_page', { url: 'https://example.com' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'click_at',
'new_page',
'press_key',
'handle_dialog',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).toHaveBeenCalledTimes(1);
}
});
it('should NOT re-inject overlay after read-only tools', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'take_snapshot',
'take_screenshot',
'get_console_message',
'fill',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).not.toHaveBeenCalled();
}
});
it('should NOT re-inject overlay when headless is true', async () => {
const headlessConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { headless: true },
},
});
const manager = new BrowserManager(headlessConfig);
await manager.callTool('click', { uid: '1_2' });
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
it('should NOT re-inject overlay when tool returns an error result', async () => {
vi.mocked(Client).mockImplementation(
() =>
({
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
listTools: vi.fn().mockResolvedValue({ tools: [] }),
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Element not found' }],
isError: true,
}),
}) as unknown as InstanceType<typeof Client>,
);
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: 'bad' });
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
});
@@ -24,6 +24,7 @@ import { debugLogger } from '../../utils/debugLogger.js';
import type { Config } from '../../config/config.js';
import { Storage } from '../../config/storage.js';
import * as path from 'node:path';
import { injectAutomationOverlay } from './automationOverlay.js';
// Pin chrome-devtools-mcp version for reproducibility.
const CHROME_DEVTOOLS_MCP_VERSION = '0.17.1';
@@ -34,6 +35,27 @@ const BROWSER_PROFILE_DIR = 'cli-browser-profile';
// Default timeout for MCP operations
const MCP_TIMEOUT_MS = 60_000;
/**
* Tools that can cause a full-page navigation (explicitly or implicitly).
*
* When any of these completes successfully, the current page DOM is replaced
* and the injected automation overlay is lost. BrowserManager re-injects the
* overlay after every successful call to one of these tools.
*
* Note: chrome-devtools-mcp is a pure request/response server and emits no
* MCP notifications, so listening for page-load events via the protocol is
* not possible. Intercepting at callTool() is the equivalent mechanism.
*/
const POTENTIALLY_NAVIGATING_TOOLS = new Set([
'click', // clicking a link navigates
'click_at', // coordinate click can also follow a link
'navigate_page',
'new_page',
'select_page', // switching pages can lose the overlay
'press_key', // Enter on a focused link/form triggers navigation
'handle_dialog', // confirming beforeunload can trigger navigation
]);
/**
* Content item from an MCP tool call response.
* Can be text or image (for take_screenshot).
@@ -70,7 +92,16 @@ export class BrowserManager {
private mcpTransport: StdioClientTransport | undefined;
private discoveredTools: McpTool[] = [];
constructor(private config: Config) {}
/**
* Whether to inject the automation overlay.
* Always false in headless mode (no visible window to decorate).
*/
private readonly shouldInjectOverlay: boolean;
constructor(private config: Config) {
const browserConfig = config.getBrowserAgentConfig();
this.shouldInjectOverlay = !browserConfig?.customConfig?.headless;
}
/**
* Gets the raw MCP SDK Client for direct tool calls.
@@ -120,28 +151,49 @@ export class BrowserManager {
{ timeout: MCP_TIMEOUT_MS },
);
let result: McpToolCallResult;
// If no signal, just await directly
if (!signal) {
return this.toResult(await callPromise);
}
// Race the call against the abort signal
let onAbort: (() => void) | undefined;
try {
const result = await Promise.race([
callPromise,
new Promise<never>((_resolve, reject) => {
onAbort = () =>
reject(signal.reason ?? new Error('Operation cancelled'));
signal.addEventListener('abort', onAbort, { once: true });
}),
]);
return this.toResult(result);
} finally {
if (onAbort) {
signal.removeEventListener('abort', onAbort);
result = this.toResult(await callPromise);
} else {
// Race the call against the abort signal
let onAbort: (() => void) | undefined;
try {
const raw = await Promise.race([
callPromise,
new Promise<never>((_resolve, reject) => {
onAbort = () =>
reject(signal.reason ?? new Error('Operation cancelled'));
signal.addEventListener('abort', onAbort, { once: true });
}),
]);
result = this.toResult(raw);
} finally {
if (onAbort) {
signal.removeEventListener('abort', onAbort);
}
}
}
// Re-inject the automation overlay after any tool that can cause a
// full-page navigation (including implicit navigations from clicking links).
// chrome-devtools-mcp emits no MCP notifications, so callTool() is the
// only interception point we have — equivalent to a page-load listener.
if (
this.shouldInjectOverlay &&
!result.isError &&
POTENTIALLY_NAVIGATING_TOOLS.has(toolName) &&
!signal?.aborted
) {
try {
await injectAutomationOverlay(this, signal);
} catch {
// Never let overlay failures interrupt the tool result
}
}
return result;
}
/**
@@ -39,8 +39,8 @@ class McpToolInvocation extends BaseToolInvocation<
ToolResult
> {
constructor(
private readonly browserManager: BrowserManager,
private readonly toolName: string,
protected readonly browserManager: BrowserManager,
protected readonly toolName: string,
params: Record<string, unknown>,
messageBus: MessageBus,
) {
@@ -280,7 +280,7 @@ class McpDeclarativeTool extends DeclarativeTool<
ToolResult
> {
constructor(
private readonly browserManager: BrowserManager,
protected readonly browserManager: BrowserManager,
name: string,
description: string,
parameterSchema: unknown,
@@ -14,6 +14,7 @@ import {
type ToolCallConfirmationDetails,
type PolicyUpdateOptions,
} from '../../tools/tools.js';
import { makeFakeConfig } from '../../test-utils/config.js';
interface TestableConfirmation {
getConfirmationDetails(
@@ -29,6 +30,7 @@ describe('mcpToolWrapper Confirmation', () => {
let mockMessageBus: MessageBus;
beforeEach(() => {
makeFakeConfig(); // ensure config module is loaded
mockBrowserManager = {
getDiscoveredTools: vi
.fn()
+7
View File
@@ -550,6 +550,7 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
extensionRegistryURI?: string;
truncateToolOutputThreshold?: number;
eventEmitter?: EventEmitter;
useWriteTodos?: boolean;
@@ -738,6 +739,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly extensionRegistryURI: string | undefined;
private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0;
private initialized = false;
@@ -969,6 +971,7 @@ export class Config implements McpContext, AgentLoopContext {
this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.extensionRegistryURI = params.extensionRegistryURI;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this._sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -1840,6 +1843,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.extensionsEnabled;
}
getExtensionRegistryURI(): string | undefined {
return this.extensionRegistryURI;
}
getMcpClientManager(): McpClientManager | undefined {
return this.mcpClientManager;
}
@@ -851,7 +851,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
# Hook Context
@@ -973,7 +973,7 @@ Use the following guidelines to optimize your search and read patterns.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
- **Explain Before Acting:** Never call tools in silence. You MUST provide a concise, one-sentence explanation of your intent or strategy immediately before executing tool calls. This is essential for transparency, especially when confirming a request or answering a question. Silence is only acceptable for repetitive, low-level discovery operations (e.g., sequential file reads) where narration would be noisy.
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).
# Hook Context
+3
View File
@@ -219,5 +219,8 @@ export * from './agents/types.js';
export * from './utils/stdio.js';
export * from './utils/terminal.js';
// Export voice utilities
export * from './voice/responseFormatter.js';
// Export types from @google/genai
export type { Content, Part, FunctionCall } from '@google/genai';
+1 -1
View File
@@ -573,7 +573,7 @@ function mandateConflictResolution(hasHierarchicalMemory: boolean): string {
function mandateContinueWork(interactive: boolean): string {
if (interactive) return '';
return `
- **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.`;
- **Non-Interactive Environment:** You are running in a headless/CI environment and cannot interact with the user. Do not ask the user questions or request additional information, as the session will terminate. Use your best judgment to complete the task. If a tool fails because it requires user interaction, do not retry it indefinitely; instead, explain the limitation and suggest how the user can provide the required data (e.g., via environment variables).`;
}
function workflowStepResearch(options: PrimaryWorkflowsOptions): string {
@@ -1703,4 +1703,95 @@ describe('ShellExecutionService environment variables', () => {
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
it('should include headless git and gh environment variables in non-interactive mode and append git config safely', async () => {
vi.resetModules();
vi.stubEnv('GIT_CONFIG_COUNT', '2');
vi.stubEnv('GIT_CONFIG_KEY_0', 'core.editor');
vi.stubEnv('GIT_CONFIG_VALUE_0', 'vim');
vi.stubEnv('GIT_CONFIG_KEY_1', 'pull.rebase');
vi.stubEnv('GIT_CONFIG_VALUE_1', 'true');
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
mockGetPty.mockResolvedValue(null); // Force child_process fallback
await ShellExecutionService.execute(
'test-cp-headless-git',
'/',
vi.fn(),
new AbortController().signal,
false, // non-interactive
shellExecutionConfig,
);
expect(mockCpSpawn).toHaveBeenCalled();
const cpEnv = mockCpSpawn.mock.calls[0][2].env;
expect(cpEnv).toHaveProperty('GIT_TERMINAL_PROMPT', '0');
expect(cpEnv).toHaveProperty('GIT_ASKPASS', '');
expect(cpEnv).toHaveProperty('SSH_ASKPASS', '');
expect(cpEnv).toHaveProperty('GH_PROMPT_DISABLED', '1');
expect(cpEnv).toHaveProperty('GCM_INTERACTIVE', 'never');
expect(cpEnv).toHaveProperty('DISPLAY', '');
expect(cpEnv).toHaveProperty('DBUS_SESSION_BUS_ADDRESS', '');
// Existing values should be preserved
expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_0', 'core.editor');
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_0', 'vim');
expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_1', 'pull.rebase');
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_1', 'true');
// The new credential.helper override should be appended at index 2
expect(cpEnv).toHaveProperty('GIT_CONFIG_COUNT', '3');
expect(cpEnv).toHaveProperty('GIT_CONFIG_KEY_2', 'credential.helper');
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', '');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
vi.unstubAllEnvs();
});
it('should NOT include headless git and gh environment variables in interactive fallback mode', async () => {
vi.resetModules();
vi.stubEnv('GIT_TERMINAL_PROMPT', undefined);
vi.stubEnv('GIT_ASKPASS', undefined);
vi.stubEnv('SSH_ASKPASS', undefined);
vi.stubEnv('GH_PROMPT_DISABLED', undefined);
vi.stubEnv('GCM_INTERACTIVE', undefined);
vi.stubEnv('GIT_CONFIG_COUNT', undefined);
const { ShellExecutionService } = await import(
'./shellExecutionService.js'
);
mockGetPty.mockResolvedValue(null); // Force child_process fallback
await ShellExecutionService.execute(
'test-cp-interactive-fallback',
'/',
vi.fn(),
new AbortController().signal,
true, // isInteractive (shouldUseNodePty)
shellExecutionConfig,
);
expect(mockCpSpawn).toHaveBeenCalled();
const cpEnv = mockCpSpawn.mock.calls[0][2].env;
expect(cpEnv).not.toHaveProperty('GIT_TERMINAL_PROMPT');
expect(cpEnv).not.toHaveProperty('GIT_ASKPASS');
expect(cpEnv).not.toHaveProperty('SSH_ASKPASS');
expect(cpEnv).not.toHaveProperty('GH_PROMPT_DISABLED');
expect(cpEnv).not.toHaveProperty('GCM_INTERACTIVE');
expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
vi.unstubAllEnvs();
});
});
@@ -252,6 +252,7 @@ export class ShellExecutionService {
onOutputEvent,
abortSignal,
shellExecutionConfig.sanitizationConfig,
shouldUseNodePty,
);
}
@@ -298,6 +299,7 @@ export class ShellExecutionService {
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
sanitizationConfig: EnvironmentSanitizationConfig,
isInteractive: boolean,
): ShellExecutionHandle {
try {
const isWindows = os.platform() === 'win32';
@@ -305,20 +307,56 @@ export class ShellExecutionService {
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const spawnArgs = [...argsPrefix, guardedCommand];
// Specifically allow GIT_CONFIG_* variables to pass through sanitization
// in non-interactive mode so we can safely append our overrides.
const gitConfigKeys = !isInteractive
? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_'))
: [];
const sanitizedEnv = sanitizeEnvironment(process.env, {
...sanitizationConfig,
allowedEnvironmentVariables: [
...(sanitizationConfig.allowedEnvironmentVariables || []),
...gitConfigKeys,
],
});
const env: NodeJS.ProcessEnv = {
...sanitizedEnv,
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
TERM: 'xterm-256color',
PAGER: 'cat',
GIT_PAGER: 'cat',
};
if (!isInteractive) {
const gitConfigCount = parseInt(
sanitizedEnv['GIT_CONFIG_COUNT'] || '0',
10,
);
Object.assign(env, {
// Disable interactive prompts and session-linked credential helpers
// in non-interactive mode to prevent hangs in detached process groups.
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
SSH_ASKPASS: '',
GH_PROMPT_DISABLED: '1',
GCM_INTERACTIVE: 'never',
DISPLAY: '',
DBUS_SESSION_BUS_ADDRESS: '',
GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(),
[`GIT_CONFIG_KEY_${gitConfigCount}`]: 'credential.helper',
[`GIT_CONFIG_VALUE_${gitConfigCount}`]: '',
});
}
const child = cpSpawn(executable, spawnArgs, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: isWindows ? false : undefined,
shell: false,
detached: !isWindows,
env: {
...sanitizeEnvironment(process.env, sanitizationConfig),
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
TERM: 'xterm-256color',
PAGER: 'cat',
GIT_PAGER: 'cat',
},
env,
});
const state = {
@@ -0,0 +1,288 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatForSpeech } from './responseFormatter.js';
describe('formatForSpeech', () => {
describe('edge cases', () => {
it('should return empty string for empty input', () => {
expect(formatForSpeech('')).toBe('');
});
it('should return plain text unchanged', () => {
expect(formatForSpeech('Hello world')).toBe('Hello world');
});
});
describe('ANSI escape codes', () => {
it('should strip color codes', () => {
expect(formatForSpeech('\x1b[31mError\x1b[0m')).toBe('Error');
});
it('should strip bold/dim codes', () => {
expect(formatForSpeech('\x1b[1mBold\x1b[22m text')).toBe('Bold text');
});
it('should strip cursor movement codes', () => {
expect(formatForSpeech('line1\x1b[2Kline2')).toBe('line1line2');
});
});
describe('markdown stripping', () => {
it('should strip bold markers **text**', () => {
expect(formatForSpeech('**Error**: something went wrong')).toBe(
'Error: something went wrong',
);
});
it('should strip bold markers __text__', () => {
expect(formatForSpeech('__Error__: something')).toBe('Error: something');
});
it('should strip italic markers *text*', () => {
expect(formatForSpeech('*note*: pay attention')).toBe(
'note: pay attention',
);
});
it('should strip inline code backticks', () => {
expect(formatForSpeech('Run `npm install` first')).toBe(
'Run npm install first',
);
});
it('should strip blockquote prefix', () => {
expect(formatForSpeech('> This is a quote')).toBe('This is a quote');
});
it('should strip heading markers', () => {
expect(formatForSpeech('# Results\n## Details')).toBe('Results\nDetails');
});
it('should replace markdown links with link text', () => {
expect(formatForSpeech('[Gemini API](https://ai.google.dev)')).toBe(
'Gemini API',
);
});
it('should strip unordered list markers', () => {
expect(formatForSpeech('- item one\n- item two')).toBe(
'item one\nitem two',
);
});
it('should strip ordered list markers', () => {
expect(formatForSpeech('1. first\n2. second')).toBe('first\nsecond');
});
});
describe('fenced code blocks', () => {
it('should unwrap a plain code block', () => {
expect(formatForSpeech('```\nconsole.log("hi")\n```')).toBe(
'console.log("hi")',
);
});
it('should unwrap a language-tagged code block', () => {
expect(formatForSpeech('```typescript\nconst x = 1;\n```')).toBe(
'const x = 1;',
);
});
it('should summarise a JSON object code block above threshold', () => {
const json = JSON.stringify({ status: 'ok', count: 42, items: [] });
// Pass jsonThreshold lower than the json string length (38 chars)
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``, {
jsonThreshold: 10,
});
expect(result).toBe('(JSON object with 3 keys)');
});
it('should summarise a JSON array code block above threshold', () => {
const json = JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
// Pass jsonThreshold lower than the json string length (23 chars)
const result = formatForSpeech(`\`\`\`\n${json}\n\`\`\``, {
jsonThreshold: 10,
});
expect(result).toBe('(JSON array with 10 items)');
});
it('should summarise a large JSON object using default threshold', () => {
// Build a JSON object whose stringified form exceeds the default 80-char threshold
const big = {
status: 'success',
count: 42,
items: ['alpha', 'beta', 'gamma'],
meta: { page: 1, totalPages: 10 },
timestamp: '2026-03-03T00:00:00Z',
};
const json = JSON.stringify(big);
expect(json.length).toBeGreaterThan(80);
const result = formatForSpeech(`\`\`\`json\n${json}\n\`\`\``);
expect(result).toBe('(JSON object with 5 keys)');
});
it('should not summarise a tiny JSON value', () => {
// Below the default 80-char threshold → keep as-is
const result = formatForSpeech('```json\n{"a":1}\n```', {
jsonThreshold: 80,
});
expect(result).toBe('{"a":1}');
});
});
describe('path abbreviation', () => {
it('should abbreviate a deep Unix path (default depth 3)', () => {
const result = formatForSpeech(
'at /home/user/project/packages/core/src/tools/file.ts',
);
expect(result).toContain('\u2026/src/tools/file.ts');
expect(result).not.toContain('/home/user/project');
});
it('should convert :line suffix to "line N"', () => {
const result = formatForSpeech(
'Error at /home/user/project/src/tools/file.ts:142',
);
expect(result).toContain('line 142');
});
it('should drop column from :line:col suffix', () => {
const result = formatForSpeech(
'Error at /home/user/project/src/tools/file.ts:142:7',
);
expect(result).toContain('line 142');
expect(result).not.toContain(':7');
});
it('should respect custom pathDepth option', () => {
const result = formatForSpeech(
'/home/user/project/packages/core/src/file.ts',
{ pathDepth: 2 },
);
expect(result).toContain('\u2026/src/file.ts');
});
it('should not abbreviate a short path within depth', () => {
const result = formatForSpeech('/src/file.ts', { pathDepth: 3 });
// Only 2 segments — no abbreviation needed
expect(result).toBe('/src/file.ts');
});
it('should abbreviate a Windows path on a non-C drive', () => {
const result = formatForSpeech(
'D:\\Users\\project\\packages\\core\\src\\file.ts',
{ pathDepth: 3 },
);
expect(result).toContain('\u2026/core/src/file.ts');
expect(result).not.toContain('D:\\Users\\project');
});
it('should convert :line on a Windows path on a non-C drive', () => {
const result = formatForSpeech(
'Error at D:\\Users\\project\\src\\tools\\file.ts:55',
);
expect(result).toContain('line 55');
expect(result).not.toContain('D:\\Users\\project');
});
it('should abbreviate a Unix path containing a scoped npm package segment', () => {
const result = formatForSpeech(
'at /home/user/project/node_modules/@google/gemini-cli-core/src/index.ts:12:3',
{ pathDepth: 5 },
);
expect(result).toContain('line 12');
expect(result).not.toContain(':3');
expect(result).toContain('@google');
});
});
describe('stack trace collapsing', () => {
it('should collapse a multi-frame stack trace', () => {
const trace = [
'Error: ENOENT',
' at Object.open (/project/src/file.ts:10:5)',
' at Module._load (/project/node_modules/loader.js:20:3)',
' at Function.Module._load (/project/node_modules/loader.js:30:3)',
].join('\n');
const result = formatForSpeech(trace);
expect(result).toContain('and 2 more frames');
expect(result).not.toContain('Module._load');
});
it('should not collapse a single stack frame', () => {
const trace =
'Error: ENOENT\n at Object.open (/project/src/file.ts:10:5)';
const result = formatForSpeech(trace);
expect(result).not.toContain('more frames');
});
it('should preserve surrounding text when collapsing a stack trace', () => {
const input = [
'Operation failed.',
' at Object.open (/project/src/file.ts:10:5)',
' at Module._load (/project/node_modules/loader.js:20:3)',
' at Function.load (/project/node_modules/loader.js:30:3)',
'Please try again.',
].join('\n');
const result = formatForSpeech(input);
expect(result).toContain('Operation failed.');
expect(result).toContain('Please try again.');
expect(result).toContain('and 2 more frames');
});
});
describe('truncation', () => {
it('should truncate output longer than maxLength', () => {
const long = 'word '.repeat(200);
const result = formatForSpeech(long, { maxLength: 50 });
expect(result.length).toBeLessThanOrEqual(
50 + '\u2026 (1000 chars total)'.length,
);
expect(result).toContain('\u2026');
expect(result).toContain('chars total');
});
it('should not truncate output within maxLength', () => {
const short = 'Hello world';
expect(formatForSpeech(short, { maxLength: 500 })).toBe('Hello world');
});
});
describe('whitespace normalisation', () => {
it('should collapse more than two consecutive blank lines', () => {
const result = formatForSpeech('para1\n\n\n\n\npara2');
expect(result).toBe('para1\n\npara2');
});
it('should trim leading and trailing whitespace', () => {
expect(formatForSpeech(' hello ')).toBe('hello');
});
});
describe('real-world examples', () => {
it('should clean an ENOENT error with markdown and path', () => {
const input =
'**Error**: `ENOENT: no such file or directory`\n> at /home/user/project/packages/core/src/tools/file-utils.ts:142:7';
const result = formatForSpeech(input);
expect(result).not.toContain('**');
expect(result).not.toContain('`');
expect(result).not.toContain('>');
expect(result).toContain('Error');
expect(result).toContain('ENOENT');
expect(result).toContain('line 142');
});
it('should clean a heading + list response', () => {
const input = '# Results\n- item one\n- item two\n- item three';
const result = formatForSpeech(input);
expect(result).toBe('Results\nitem one\nitem two\nitem three');
});
});
});
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Options for formatForSpeech().
*/
export interface FormatForSpeechOptions {
/**
* Maximum output length in characters before truncating.
* @default 500
*/
maxLength?: number;
/**
* Number of trailing path segments to keep when abbreviating absolute paths.
* @default 3
*/
pathDepth?: number;
/**
* Maximum number of characters in a JSON value before summarising it.
* @default 80
*/
jsonThreshold?: number;
}
// ANSI escape sequences (CSI, OSC, etc.)
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b(?:\[[0-9;]*[mGKHF]|\][^\x07\x1b]*\x07|[()][AB012])/g;
// Fenced code blocks ```lang\n...\n```
const CODE_FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
// Inline code `...`
const INLINE_CODE_RE = /`([^`]+)`/g;
// Bold/italic markers **text**, *text*, __text__, _text_
// Exclude newlines so the pattern cannot span multiple lines and accidentally
// consume list markers that haven't been stripped yet.
const BOLD_ITALIC_RE = /\*{1,2}([^*\n]+)\*{1,2}|_{1,2}([^_\n]+)_{1,2}/g;
// Blockquote prefix "> "
const BLOCKQUOTE_RE = /^>\s?/gm;
// ATX headings # heading
const HEADING_RE = /^#{1,6}\s+/gm;
// Markdown links [text](url)
const LINK_RE = /\[([^\]]+)\]\([^)]+\)/g;
// Markdown list markers "- " or "* " or "N. " at line start
const LIST_MARKER_RE = /^[ \t]*(?:[-*]|\d+\.)\s+/gm;
// Two or more consecutive stack-trace frames (Node.js style " at …" lines).
// Matching blocks of ≥2 lets us replace each group in-place, preserving any
// text that follows the trace rather than appending it to the end.
const STACK_BLOCK_RE = /(?:^[ \t]+at [^\n]+(?:\n|$)){2,}/gm;
// Absolute Unix paths optionally ending with :line or :line:col
// Hyphen placed at start of char class to avoid useless-escape lint error
const UNIX_PATH_RE =
/(?:^|(?<=\s|[(`"']))(\/[-\w.@]+(?:\/[-\w.@]+)*)(:\d+(?::\d+)?)?/g;
// Absolute Windows paths C:\... or C:/... (any drive letter)
const WIN_PATH_RE =
/(?:^|(?<=\s|[(`"']))([A-Za-z]:[/\\][-\w. ]+(?:[/\\][-\w. ]+)*)(:\d+(?::\d+)?)?/g;
/**
* Abbreviates an absolute path to at most `depth` trailing segments,
* prefixed with "…". Optionally converts `:line` suffix to `line N`.
*/
function abbreviatePath(
full: string,
suffix: string | undefined,
depth: number,
): string {
const segments = full.split(/[/\\]/).filter(Boolean);
const kept = segments.length > depth ? segments.slice(-depth) : segments;
const abbreviated =
segments.length > depth ? `\u2026/${kept.join('/')}` : full;
if (!suffix) return abbreviated;
// Convert ":142" → " line 142", ":142:7" → " line 142"
const lineNum = suffix.split(':').filter(Boolean)[0];
return `${abbreviated} line ${lineNum}`;
}
/**
* Summarises a JSON string as "(JSON object with N keys)" or
* "(JSON array with N items)", falling back to the original if parsing fails.
*/
function summariseJson(jsonStr: string): string {
try {
const parsed: unknown = JSON.parse(jsonStr);
if (Array.isArray(parsed)) {
return `(JSON array with ${parsed.length} item${parsed.length === 1 ? '' : 's'})`;
}
if (parsed !== null && typeof parsed === 'object') {
const keys = Object.keys(parsed).length;
return `(JSON object with ${keys} key${keys === 1 ? '' : 's'})`;
}
} catch {
// not valid JSON — leave as-is
}
return jsonStr;
}
/**
* Transforms a markdown/ANSI-formatted string into speech-ready plain text.
*
* Transformations applied (in order):
* 1. Strip ANSI escape codes
* 2. Collapse fenced code blocks to their content (or a JSON summary)
* 3. Collapse stack traces to first frame + count
* 4. Strip markdown syntax (bold, italic, blockquotes, headings, links, lists, inline code)
* 5. Abbreviate deep absolute paths
* 6. Normalise whitespace
* 7. Truncate to maxLength
*/
export function formatForSpeech(
text: string,
options?: FormatForSpeechOptions,
): string {
const maxLength = options?.maxLength ?? 500;
const pathDepth = options?.pathDepth ?? 3;
const jsonThreshold = options?.jsonThreshold ?? 80;
if (!text) return '';
let out = text;
// 1. Strip ANSI escape codes
out = out.replace(ANSI_RE, '');
// 2. Fenced code blocks — try to summarise JSON content, else keep text
out = out.replace(CODE_FENCE_RE, (_match, body: string) => {
const trimmed = body.trim();
if (trimmed.length > jsonThreshold) {
const summary = summariseJson(trimmed);
if (summary !== trimmed) return summary;
}
return trimmed;
});
// 3. Collapse stack traces: replace each contiguous block of ≥2 frames
// in-place so that any text after the trace is preserved in order.
out = out.replace(STACK_BLOCK_RE, (block) => {
const lines = block
.trim()
.split('\n')
.map((l) => l.trim());
const rest = lines.length - 1;
return `${lines[0]} (and ${rest} more frame${rest === 1 ? '' : 's'})\n`;
});
// 4. Strip markdown syntax
out = out
.replace(INLINE_CODE_RE, '$1')
.replace(BOLD_ITALIC_RE, (_m, g1?: string, g2?: string) => g1 ?? g2 ?? '')
.replace(BLOCKQUOTE_RE, '')
.replace(HEADING_RE, '')
.replace(LINK_RE, '$1')
.replace(LIST_MARKER_RE, '');
// 5. Abbreviate absolute paths
// Windows paths first to avoid the leading letter being caught by Unix RE
out = out.replace(WIN_PATH_RE, (_m, full: string, suffix?: string) =>
abbreviatePath(full, suffix, pathDepth),
);
out = out.replace(UNIX_PATH_RE, (_m, full: string, suffix?: string) =>
abbreviatePath(full, suffix, pathDepth),
);
// 6. Normalise whitespace: collapse multiple blank lines, trim
out = out.replace(/\n{3,}/g, '\n\n').trim();
// 7. Truncate
if (out.length > maxLength) {
const total = out.length;
out = out.slice(0, maxLength).trimEnd() + `\u2026 (${total} chars total)`;
}
return out;
}