mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 03:54:43 -07:00
Merge branch 'main' into memory_usage3
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -20,7 +20,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"vendor"
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -31,7 +32,6 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.211.0",
|
||||
@@ -59,6 +59,7 @@
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
"execa": "^9.6.1",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
"fdir": "^6.4.6",
|
||||
"fzf": "^0.5.2",
|
||||
|
||||
@@ -3552,6 +3552,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
registry.registerTool(new RipGrepTool(this, this.messageBus)),
|
||||
);
|
||||
} else {
|
||||
debugLogger.warn(`Ripgrep is not available. Falling back to GrepTool.`);
|
||||
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
|
||||
maybeRegister(GrepTool, () =>
|
||||
registry.registerTool(new GrepTool(this, this.messageBus)),
|
||||
|
||||
@@ -519,4 +519,70 @@ describe('GeminiChat Network Retries', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry on OpenSSL 3.x SSL error during stream iteration (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
|
||||
// OpenSSL 3.x produces a different error code format than OpenSSL 1.x
|
||||
const sslError = new Error(
|
||||
'request to https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent failed',
|
||||
) as NodeJS.ErrnoException & { type?: string };
|
||||
sslError.type = 'system';
|
||||
sslError.errno =
|
||||
'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC' as unknown as number;
|
||||
sslError.code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'Partial response...' }] } },
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
throw sslError;
|
||||
})(),
|
||||
)
|
||||
.mockImplementationOnce(async () =>
|
||||
(async function* () {
|
||||
yield {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'Complete response after retry' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse;
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
{ model: 'test-model' },
|
||||
'test message',
|
||||
'prompt-id-ssl3-mid-stream',
|
||||
new AbortController().signal,
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
|
||||
const events: StreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
|
||||
expect(retryEvent).toBeDefined();
|
||||
|
||||
const successChunk = events.find(
|
||||
(e) =>
|
||||
e.type === StreamEventType.CHUNK &&
|
||||
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
|
||||
'Complete response after retry',
|
||||
);
|
||||
expect(successChunk).toBeDefined();
|
||||
|
||||
expect(mockLogNetworkRetryAttempt).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
error_type: 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1762,6 +1762,39 @@ describe('PolicyEngine', () => {
|
||||
});
|
||||
|
||||
describe('shell command parsing failure', () => {
|
||||
it('should return ALLOW in YOLO mode for dangerous commands due to heuristics override', async () => {
|
||||
// Create an engine with YOLO mode and a sandbox manager that flags a command as dangerous
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 999,
|
||||
modes: [ApprovalMode.YOLO],
|
||||
},
|
||||
];
|
||||
|
||||
const mockSandboxManager = new NoopSandboxManager();
|
||||
mockSandboxManager.isDangerousCommand = vi.fn().mockReturnValue(true);
|
||||
mockSandboxManager.isKnownSafeCommand = vi.fn().mockReturnValue(false);
|
||||
|
||||
engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
sandboxManager: mockSandboxManager,
|
||||
});
|
||||
|
||||
const result = await engine.check(
|
||||
{
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'powershell echo "dangerous"' },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Even though the command is flagged as dangerous, YOLO mode should preserve the ALLOW decision
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
|
||||
const { splitCommands } = await import('../utils/shell-utils.js');
|
||||
const rules: PolicyRule[] = [
|
||||
|
||||
@@ -312,6 +312,13 @@ export class PolicyEngine {
|
||||
const parsedArgs = parsedObjArgs.map(extractStringFromParseEntry);
|
||||
|
||||
if (this.sandboxManager.isDangerousCommand(parsedArgs)) {
|
||||
if (this.approvalMode === ApprovalMode.YOLO) {
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as dangerous, but YOLO mode is active. Preserving decision: ${command}`,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`,
|
||||
);
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
canUseRipgrep,
|
||||
RipGrepTool,
|
||||
ensureRgPath,
|
||||
type RipGrepToolParams,
|
||||
getRipgrepPath,
|
||||
} from './ripGrep.js';
|
||||
import type { GrepResult } from './tools.js';
|
||||
import path from 'node:path';
|
||||
@@ -25,18 +18,21 @@ import { isSubpath } from '../utils/paths.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
|
||||
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import EventEmitter from 'node:events';
|
||||
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
// Mock dependencies for canUseRipgrep
|
||||
vi.mock('@joshua.litt/get-ripgrep', () => ({
|
||||
downloadRipGrep: vi.fn(),
|
||||
}));
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
|
||||
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
fileExists: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock child_process for ripgrep calls
|
||||
vi.mock('child_process', () => ({
|
||||
@@ -44,161 +40,42 @@ vi.mock('child_process', () => ({
|
||||
}));
|
||||
|
||||
const mockSpawn = vi.mocked(spawn);
|
||||
const downloadRipGrepMock = vi.mocked(downloadRipGrep);
|
||||
const originalGetGlobalBinDir = Storage.getGlobalBinDir.bind(Storage);
|
||||
const storageSpy = vi.spyOn(Storage, 'getGlobalBinDir');
|
||||
|
||||
function getRipgrepBinaryName() {
|
||||
return process.platform === 'win32' ? 'rg.exe' : 'rg';
|
||||
}
|
||||
|
||||
describe('canUseRipgrep', () => {
|
||||
let tempRootDir: string;
|
||||
let binDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempRootDir, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
beforeEach(() => {
|
||||
vi.mocked(fileExists).mockReset();
|
||||
});
|
||||
|
||||
it('should return true if ripgrep already exists', async () => {
|
||||
const existingPath = path.join(binDir, getRipgrepBinaryName());
|
||||
await fs.writeFile(existingPath, '');
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
const result = await canUseRipgrep();
|
||||
expect(result).toBe(true);
|
||||
expect(downloadRipGrepMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should download ripgrep and return true if it does not exist initially', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(async () => {
|
||||
await fs.writeFile(expectedPath, '');
|
||||
});
|
||||
|
||||
it('should return false if file does not exist', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false if download fails and file does not exist', async () => {
|
||||
const result = await canUseRipgrep();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it('should propagate errors from downloadRipGrep', async () => {
|
||||
const error = new Error('Download failed');
|
||||
downloadRipGrepMock.mockRejectedValue(error);
|
||||
|
||||
await expect(canUseRipgrep()).rejects.toThrow(error);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it('should only download once when called concurrently', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
fs.writeFile(expectedPath, '')
|
||||
.then(() => resolve())
|
||||
.catch(reject);
|
||||
}, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
const firstCall = ensureRgPath();
|
||||
const secondCall = ensureRgPath();
|
||||
|
||||
const [pathOne, pathTwo] = await Promise.all([firstCall, secondCall]);
|
||||
|
||||
expect(pathOne).toBe(expectedPath);
|
||||
expect(pathTwo).toBe(expectedPath);
|
||||
expect(downloadRipGrepMock).toHaveBeenCalledTimes(1);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRgPath', () => {
|
||||
let tempRootDir: string;
|
||||
let binDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempRootDir, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
beforeEach(() => {
|
||||
vi.mocked(fileExists).mockReset();
|
||||
});
|
||||
|
||||
it('should return rg path if ripgrep already exists', async () => {
|
||||
const existingPath = path.join(binDir, getRipgrepBinaryName());
|
||||
await fs.writeFile(existingPath, '');
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(existingPath);
|
||||
expect(downloadRipGrep).not.toHaveBeenCalled();
|
||||
expect(rgPath).toBe(await getRipgrepPath());
|
||||
});
|
||||
|
||||
it('should return rg path if ripgrep is downloaded successfully', async () => {
|
||||
const expectedPath = path.join(binDir, getRipgrepBinaryName());
|
||||
|
||||
downloadRipGrepMock.mockImplementation(async () => {
|
||||
await fs.writeFile(expectedPath, '');
|
||||
});
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(expectedPath);
|
||||
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
|
||||
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
|
||||
it('should throw an error if ripgrep cannot be used', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
await expect(ensureRgPath()).rejects.toThrow(
|
||||
/Cannot find bundled ripgrep binary/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep cannot be used after download attempt', async () => {
|
||||
await expect(ensureRgPath()).rejects.toThrow('Cannot use ripgrep.');
|
||||
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should propagate errors from downloadRipGrep', async () => {
|
||||
const error = new Error('Download failed');
|
||||
downloadRipGrepMock.mockRejectedValue(error);
|
||||
|
||||
await expect(ensureRgPath()).rejects.toThrow(error);
|
||||
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
'should detect ripgrep when only rg.exe exists on Windows',
|
||||
async () => {
|
||||
const expectedRgExePath = path.join(binDir, 'rg.exe');
|
||||
await fs.writeFile(expectedRgExePath, '');
|
||||
|
||||
const rgPath = await ensureRgPath();
|
||||
expect(rgPath).toBe(expectedRgExePath);
|
||||
expect(downloadRipGrep).not.toHaveBeenCalled();
|
||||
await expect(fs.access(expectedRgExePath)).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Helper function to create mock spawn implementations
|
||||
@@ -247,9 +124,6 @@ function createMockSpawn(
|
||||
|
||||
describe('RipGrepTool', () => {
|
||||
let tempRootDir: string;
|
||||
let tempBinRoot: string;
|
||||
let binDir: string;
|
||||
let ripgrepBinaryPath: string;
|
||||
let grepTool: RipGrepTool;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
@@ -266,19 +140,12 @@ describe('RipGrepTool', () => {
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
mockSpawn.mockReset();
|
||||
mockSpawn.mockImplementation(createMockSpawn());
|
||||
tempBinRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
|
||||
binDir = path.join(tempBinRoot, 'bin');
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
const binaryName = process.platform === 'win32' ? 'rg.exe' : 'rg';
|
||||
ripgrepBinaryPath = path.join(binDir, binaryName);
|
||||
await fs.writeFile(ripgrepBinaryPath, '');
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
|
||||
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
@@ -335,9 +202,7 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
|
||||
await fs.rm(tempRootDir, { recursive: true, force: true });
|
||||
await fs.rm(tempBinRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('validateToolParams', () => {
|
||||
@@ -834,16 +699,16 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep is not available', async () => {
|
||||
await fs.rm(ripgrepBinaryPath, { force: true });
|
||||
downloadRipGrepMock.mockResolvedValue(undefined);
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
|
||||
const params: RipGrepToolParams = { pattern: 'world' };
|
||||
const invocation = grepTool.build(params);
|
||||
|
||||
expect(await invocation.execute({ abortSignal })).toStrictEqual({
|
||||
llmContent: 'Error during grep search operation: Cannot use ripgrep.',
|
||||
returnDisplay: 'Error: Cannot use ripgrep.',
|
||||
});
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
expect(result.llmContent).toContain('Cannot find bundled ripgrep binary');
|
||||
|
||||
// restore the mock for subsequent tests
|
||||
vi.mocked(fileExists).mockResolvedValue(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2080,6 +1945,68 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
storageSpy.mockRestore();
|
||||
describe('getRipgrepPath', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OS/Architecture Resolution', () => {
|
||||
it.each([
|
||||
{ platform: 'darwin', arch: 'arm64', expectedBin: 'rg-darwin-arm64' },
|
||||
{ platform: 'darwin', arch: 'x64', expectedBin: 'rg-darwin-x64' },
|
||||
{ platform: 'linux', arch: 'arm64', expectedBin: 'rg-linux-arm64' },
|
||||
{ platform: 'linux', arch: 'x64', expectedBin: 'rg-linux-x64' },
|
||||
{ platform: 'win32', arch: 'x64', expectedBin: 'rg-win32-x64.exe' },
|
||||
])(
|
||||
'should map $platform $arch to $expectedBin',
|
||||
async ({ platform, arch, expectedBin }) => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue(platform as NodeJS.Platform);
|
||||
vi.spyOn(os, 'arch').mockReturnValue(arch);
|
||||
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
|
||||
checkPath.endsWith(expectedBin),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath?.endsWith(expectedBin)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Path Fallback Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
vi.spyOn(os, 'arch').mockReturnValue('x64');
|
||||
});
|
||||
|
||||
it('should resolve the SEA (flattened) path first', async () => {
|
||||
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
|
||||
checkPath.includes(path.normalize('tools/vendor/ripgrep')),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath).toContain(path.normalize('tools/vendor/ripgrep'));
|
||||
});
|
||||
|
||||
it('should fall back to the Dev path if SEA path is missing', async () => {
|
||||
vi.mocked(fileExists).mockImplementation(
|
||||
async (checkPath) =>
|
||||
checkPath.includes(path.normalize('core/vendor/ripgrep')) &&
|
||||
!checkPath.includes('tools'),
|
||||
);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).not.toBeNull();
|
||||
expect(resolvedPath).toContain(path.normalize('core/vendor/ripgrep'));
|
||||
expect(resolvedPath).not.toContain('tools');
|
||||
});
|
||||
|
||||
it('should return null if binary is missing from both paths', async () => {
|
||||
vi.mocked(fileExists).mockResolvedValue(false);
|
||||
|
||||
const resolvedPath = await getRipgrepPath();
|
||||
expect(resolvedPath).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
@@ -22,7 +23,6 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { fileExists } from '../utils/fileUtils.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { GREP_TOOL_NAME } from './tool-names.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
@@ -39,73 +39,48 @@ import { RIP_GREP_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { type GrepMatch, formatGrepResults } from './grep-utils.js';
|
||||
|
||||
function getRgCandidateFilenames(): readonly string[] {
|
||||
return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
|
||||
}
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function resolveExistingRgPath(): Promise<string | null> {
|
||||
const binDir = Storage.getGlobalBinDir();
|
||||
for (const fileName of getRgCandidateFilenames()) {
|
||||
const candidatePath = path.join(binDir, fileName);
|
||||
if (await fileExists(candidatePath)) {
|
||||
return candidatePath;
|
||||
export async function getRipgrepPath(): Promise<string | null> {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
// Map to the correct bundled binary
|
||||
const binName = `rg-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`;
|
||||
|
||||
const candidatePaths = [
|
||||
// 1. SEA runtime layout: everything is flattened into the root dir
|
||||
path.resolve(__dirname, 'vendor/ripgrep', binName),
|
||||
// 2. Dev/Dist layout: packages/core/dist/tools/ripGrep.js -> packages/core/vendor/ripgrep
|
||||
path.resolve(__dirname, '../../vendor/ripgrep', binName),
|
||||
];
|
||||
|
||||
for (const candidate of candidatePaths) {
|
||||
if (await fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
let ripgrepAcquisitionPromise: Promise<string | null> | null = null;
|
||||
/**
|
||||
* Ensures a ripgrep binary is available.
|
||||
*
|
||||
* NOTE:
|
||||
* - The Gemini CLI currently prefers a managed ripgrep binary downloaded
|
||||
* into its global bin directory.
|
||||
* - Even if ripgrep is available on the system PATH, it is intentionally
|
||||
* not used at this time.
|
||||
*
|
||||
* Preference for system-installed ripgrep is blocked on:
|
||||
* - checksum verification of external binaries
|
||||
* - internalization of the get-ripgrep dependency
|
||||
*
|
||||
* See:
|
||||
* - feat(core): Prefer rg in system path (#11847)
|
||||
* - Move get-ripgrep to third_party (#12099)
|
||||
*/
|
||||
async function ensureRipgrepAvailable(): Promise<string | null> {
|
||||
const existingPath = await resolveExistingRgPath();
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
if (!ripgrepAcquisitionPromise) {
|
||||
ripgrepAcquisitionPromise = (async () => {
|
||||
try {
|
||||
await downloadRipGrep(Storage.getGlobalBinDir());
|
||||
return await resolveExistingRgPath();
|
||||
} finally {
|
||||
ripgrepAcquisitionPromise = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return ripgrepAcquisitionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if `rg` exists, if not then attempt to download it.
|
||||
* Checks if `rg` exists in the bundled vendor directory.
|
||||
*/
|
||||
export async function canUseRipgrep(): Promise<boolean> {
|
||||
return (await ensureRipgrepAvailable()) !== null;
|
||||
const binPath = await getRipgrepPath();
|
||||
return binPath !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures `rg` is downloaded, or throws.
|
||||
* Ensures `rg` is available, or throws.
|
||||
*/
|
||||
export async function ensureRgPath(): Promise<string> {
|
||||
const downloadedPath = await ensureRipgrepAvailable();
|
||||
if (downloadedPath) {
|
||||
return downloadedPath;
|
||||
const binPath = await getRipgrepPath();
|
||||
if (binPath !== null) {
|
||||
return binPath;
|
||||
}
|
||||
throw new Error('Cannot use ripgrep.');
|
||||
throw new Error(`Cannot find bundled ripgrep binary.`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '../utils/shell-utils.js';
|
||||
import { SHELL_TOOL_NAME } from './tool-names.js';
|
||||
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { getShellDefinition } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
@@ -252,6 +253,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
abortSignal: AbortSignal,
|
||||
forcedDecision?: ForcedToolDecision,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
if (this.context.config.getApprovalMode() === ApprovalMode.YOLO) {
|
||||
return super.shouldConfirmExecute(abortSignal, forcedDecision);
|
||||
}
|
||||
|
||||
if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
|
||||
return this.getConfirmationDetails(abortSignal);
|
||||
}
|
||||
|
||||
@@ -511,6 +511,40 @@ describe('retryWithBackoff', () => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on OpenSSL 3.x SSL error code (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on unknown SSL BAD_RECORD_MAC variant via substring fallback', async () => {
|
||||
const error = new Error('SSL error');
|
||||
(error as any).code = 'ERR_SSL_SOME_FUTURE_BAD_RECORD_MAC';
|
||||
const mockFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(promise).resolves.toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on gaxios-style SSL error with code property', async () => {
|
||||
// This matches the exact structure from issue #17318
|
||||
const error = new Error(
|
||||
|
||||
@@ -53,14 +53,30 @@ const RETRYABLE_NETWORK_CODES = [
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
// SSL/TLS transient errors
|
||||
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC',
|
||||
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||
'ERR_SSL_BAD_RECORD_MAC',
|
||||
'EPROTO', // Generic protocol error (often SSL-related)
|
||||
];
|
||||
|
||||
// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased
|
||||
// OpenSSL reason string with spaces replaced by underscores (see
|
||||
// TLSWrap::ClearOut in node/src/crypto/crypto_tls.cc). The reason string
|
||||
// format varies by OpenSSL version (e.g. ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC
|
||||
// on OpenSSL 1.x, ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC on OpenSSL 3.x), so
|
||||
// match the stable suffix instead of enumerating every variant.
|
||||
const RETRYABLE_SSL_ERROR_PATTERN = /^ERR_SSL_.*BAD_RECORD_MAC/i;
|
||||
|
||||
/**
|
||||
* Returns true if the error code should be retried: either an exact match
|
||||
* against RETRYABLE_NETWORK_CODES, or an SSL BAD_RECORD_MAC variant (the
|
||||
* OpenSSL reason-string portion of the code varies across OpenSSL versions).
|
||||
*/
|
||||
function isRetryableSslErrorCode(code: string): boolean {
|
||||
return (
|
||||
RETRYABLE_NETWORK_CODES.includes(code) ||
|
||||
RETRYABLE_SSL_ERROR_PATTERN.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
function getNetworkErrorCode(error: unknown): string | undefined {
|
||||
const getCode = (obj: unknown): string | undefined => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
@@ -112,7 +128,7 @@ export function getRetryErrorType(error: unknown): string {
|
||||
}
|
||||
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
@@ -153,7 +169,7 @@ export function isRetryableError(
|
||||
): boolean {
|
||||
// Check for common network error codes
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
if (errorCode && isRetryableSslErrorCode(errorCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.39.0-nightly.20260408.e77b22e63",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user