mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
sync gemini-cli 0.1.17
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -189,4 +189,81 @@ describe('bfsFileSearch', () => {
|
||||
expect(result.sort()).toEqual([target1, target2].sort());
|
||||
});
|
||||
});
|
||||
|
||||
it('should perform parallel directory scanning efficiently (performance test)', async () => {
|
||||
// Create a more complex directory structure for performance testing
|
||||
console.log('\n🚀 Testing Parallel BFS Performance...');
|
||||
|
||||
// Create 50 directories with multiple levels for faster test execution
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await createEmptyDir(`dir${i}`);
|
||||
await createEmptyDir(`dir${i}`, 'subdir1');
|
||||
await createEmptyDir(`dir${i}`, 'subdir2');
|
||||
await createEmptyDir(`dir${i}`, 'subdir1', 'deep');
|
||||
if (i < 10) {
|
||||
// Add target files in some directories
|
||||
await createTestFile('content', `dir${i}`, 'GEMINI.md');
|
||||
await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md');
|
||||
}
|
||||
}
|
||||
|
||||
// Run multiple iterations to ensure consistency
|
||||
const iterations = 3;
|
||||
const durations: number[] = [];
|
||||
let foundFiles = 0;
|
||||
let firstResultSorted: string[] | undefined;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const searchStartTime = performance.now();
|
||||
const result = await bfsFileSearch(testRootDir, {
|
||||
fileName: 'GEMINI.md',
|
||||
maxDirs: 200,
|
||||
debug: false,
|
||||
});
|
||||
const duration = performance.now() - searchStartTime;
|
||||
durations.push(duration);
|
||||
|
||||
// Verify consistency: all iterations should find the exact same files
|
||||
if (firstResultSorted === undefined) {
|
||||
foundFiles = result.length;
|
||||
firstResultSorted = result.sort();
|
||||
} else {
|
||||
expect(result.sort()).toEqual(firstResultSorted);
|
||||
}
|
||||
|
||||
console.log(`📊 Iteration ${i + 1}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const maxDuration = Math.max(...durations);
|
||||
const minDuration = Math.min(...durations);
|
||||
|
||||
console.log(`📊 Average Duration: ${avgDuration.toFixed(2)}ms`);
|
||||
console.log(
|
||||
`📊 Min/Max Duration: ${minDuration.toFixed(2)}ms / ${maxDuration.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`📁 Found ${foundFiles} GEMINI.md files`);
|
||||
console.log(
|
||||
`🏎️ Processing ~${Math.round(200 / (avgDuration / 1000))} dirs/second`,
|
||||
);
|
||||
|
||||
// Verify we found the expected files
|
||||
expect(foundFiles).toBe(20); // 10 dirs * 2 files each
|
||||
|
||||
// Performance expectation: check consistency rather than absolute time
|
||||
const variance = maxDuration - minDuration;
|
||||
const consistencyRatio = variance / avgDuration;
|
||||
|
||||
// Ensure reasonable performance (generous limit for CI environments)
|
||||
expect(avgDuration).toBeLessThan(2000); // Very generous limit
|
||||
|
||||
// Ensure consistency across runs (variance should not be too high)
|
||||
// More tolerant in CI environments where performance can be variable
|
||||
const maxConsistencyRatio = process.env.CI ? 3.0 : 1.5;
|
||||
expect(consistencyRatio).toBeLessThan(maxConsistencyRatio); // Max variance should be reasonable
|
||||
|
||||
console.log(
|
||||
`✅ Performance test passed: avg=${avgDuration.toFixed(2)}ms, consistency=${(consistencyRatio * 100).toFixed(1)}% (threshold: ${(maxConsistencyRatio * 100).toFixed(0)}%)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Dirent } from 'fs';
|
||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { FileFilteringOptions } from '../config/config.js';
|
||||
// Simple console logger for now.
|
||||
@@ -47,45 +46,76 @@ export async function bfsFileSearch(
|
||||
const queue: string[] = [rootDir];
|
||||
const visited = new Set<string>();
|
||||
let scannedDirCount = 0;
|
||||
let queueHead = 0; // Pointer-based queue head to avoid expensive splice operations
|
||||
|
||||
while (queue.length > 0 && scannedDirCount < maxDirs) {
|
||||
const currentDir = queue.shift()!;
|
||||
if (visited.has(currentDir)) {
|
||||
continue;
|
||||
// Convert ignoreDirs array to Set for O(1) lookup performance
|
||||
const ignoreDirsSet = new Set(ignoreDirs);
|
||||
|
||||
// Process directories in parallel batches for maximum performance
|
||||
const PARALLEL_BATCH_SIZE = 15; // Parallel processing batch size for optimal performance
|
||||
|
||||
while (queueHead < queue.length && scannedDirCount < maxDirs) {
|
||||
// Fill batch with unvisited directories up to the desired size
|
||||
const batchSize = Math.min(PARALLEL_BATCH_SIZE, maxDirs - scannedDirCount);
|
||||
const currentBatch = [];
|
||||
while (currentBatch.length < batchSize && queueHead < queue.length) {
|
||||
const currentDir = queue[queueHead];
|
||||
queueHead++;
|
||||
if (!visited.has(currentDir)) {
|
||||
visited.add(currentDir);
|
||||
currentBatch.push(currentDir);
|
||||
}
|
||||
}
|
||||
visited.add(currentDir);
|
||||
scannedDirCount++;
|
||||
scannedDirCount += currentBatch.length;
|
||||
|
||||
if (currentBatch.length === 0) continue;
|
||||
|
||||
if (debug) {
|
||||
logger.debug(`Scanning [${scannedDirCount}/${maxDirs}]: ${currentDir}`);
|
||||
logger.debug(
|
||||
`Scanning [${scannedDirCount}/${maxDirs}]: batch of ${currentBatch.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Ignore errors for directories we can't read (e.g., permissions)
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (
|
||||
fileService?.shouldIgnoreFile(fullPath, {
|
||||
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
|
||||
respectGeminiIgnore:
|
||||
options.fileFilteringOptions?.respectGeminiIgnore,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoreDirs.includes(entry.name)) {
|
||||
queue.push(fullPath);
|
||||
// Read directories in parallel instead of one by one
|
||||
const readPromises = currentBatch.map(async (currentDir) => {
|
||||
try {
|
||||
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
return { currentDir, entries };
|
||||
} catch (error) {
|
||||
// Warn user that a directory could not be read, as this affects search results.
|
||||
const message = (error as Error)?.message ?? 'Unknown error';
|
||||
console.warn(
|
||||
`[WARN] Skipping unreadable directory: ${currentDir} (${message})`,
|
||||
);
|
||||
if (debug) {
|
||||
logger.debug(`Full error for ${currentDir}:`, error);
|
||||
}
|
||||
return { currentDir, entries: [] };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(readPromises);
|
||||
|
||||
for (const { currentDir, entries } of results) {
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (
|
||||
fileService?.shouldIgnoreFile(fullPath, {
|
||||
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
|
||||
respectGeminiIgnore:
|
||||
options.fileFilteringOptions?.respectGeminiIgnore,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoreDirsSet.has(entry.name)) {
|
||||
queue.push(fullPath);
|
||||
}
|
||||
} else if (entry.isFile() && entry.name === fileName) {
|
||||
foundFiles.push(fullPath);
|
||||
}
|
||||
} else if (entry.isFile() && entry.name === fileName) {
|
||||
foundFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@ import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { ReadManyFilesTool } from '../tools/read-many-files.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { LruCache } from './LruCache.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
|
||||
import {
|
||||
isFunctionResponse,
|
||||
isFunctionCall,
|
||||
} from '../utils/messageInspectors.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const EditModel = DEFAULT_GEMINI_FLASH_MODEL;
|
||||
const EditModel = DEFAULT_GEMINI_FLASH_LITE_MODEL;
|
||||
const EditConfig: GenerateContentConfig = {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('editor utils', () => {
|
||||
{ editor: 'vim', commands: ['vim'], win32Commands: ['vim'] },
|
||||
{ editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] },
|
||||
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
|
||||
{ editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] },
|
||||
];
|
||||
|
||||
for (const { editor, commands, win32Commands } of testCases) {
|
||||
@@ -297,6 +298,14 @@ describe('editor utils', () => {
|
||||
});
|
||||
}
|
||||
|
||||
it('should return the correct command for emacs', () => {
|
||||
const command = getDiffCommand('old.txt', 'new.txt', 'emacs');
|
||||
expect(command).toEqual({
|
||||
command: 'emacs',
|
||||
args: ['--eval', '(ediff "old.txt" "new.txt")'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for an unsupported editor', () => {
|
||||
// @ts-expect-error Testing unsupported editor
|
||||
const command = getDiffCommand('old.txt', 'new.txt', 'foobar');
|
||||
@@ -372,7 +381,7 @@ describe('editor utils', () => {
|
||||
});
|
||||
}
|
||||
|
||||
const execSyncEditors: EditorType[] = ['vim', 'neovim'];
|
||||
const execSyncEditors: EditorType[] = ['vim', 'neovim', 'emacs'];
|
||||
for (const editor of execSyncEditors) {
|
||||
it(`should call execSync for ${editor} on non-windows`, async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
@@ -425,6 +434,15 @@ describe('editor utils', () => {
|
||||
expect(allowEditorTypeInSandbox('vim')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow emacs in sandbox mode', () => {
|
||||
process.env.SANDBOX = 'sandbox';
|
||||
expect(allowEditorTypeInSandbox('emacs')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow emacs when not in sandbox mode', () => {
|
||||
expect(allowEditorTypeInSandbox('emacs')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow neovim in sandbox mode', () => {
|
||||
process.env.SANDBOX = 'sandbox';
|
||||
expect(allowEditorTypeInSandbox('neovim')).toBe(true);
|
||||
@@ -490,6 +508,12 @@ describe('editor utils', () => {
|
||||
expect(isEditorAvailable('vim')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for emacs when installed and in sandbox mode', () => {
|
||||
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/emacs'));
|
||||
process.env.SANDBOX = 'sandbox';
|
||||
expect(isEditorAvailable('emacs')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for neovim when installed and in sandbox mode', () => {
|
||||
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/nvim'));
|
||||
process.env.SANDBOX = 'sandbox';
|
||||
|
||||
@@ -13,7 +13,8 @@ export type EditorType =
|
||||
| 'cursor'
|
||||
| 'vim'
|
||||
| 'neovim'
|
||||
| 'zed';
|
||||
| 'zed'
|
||||
| 'emacs';
|
||||
|
||||
function isValidEditorType(editor: string): editor is EditorType {
|
||||
return [
|
||||
@@ -24,6 +25,7 @@ function isValidEditorType(editor: string): editor is EditorType {
|
||||
'vim',
|
||||
'neovim',
|
||||
'zed',
|
||||
'emacs',
|
||||
].includes(editor);
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ const editorCommands: Record<
|
||||
vim: { win32: ['vim'], default: ['vim'] },
|
||||
neovim: { win32: ['nvim'], default: ['nvim'] },
|
||||
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
|
||||
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
|
||||
};
|
||||
|
||||
export function checkHasEditorType(editor: EditorType): boolean {
|
||||
@@ -73,6 +76,7 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean {
|
||||
if (['vscode', 'vscodium', 'windsurf', 'cursor', 'zed'].includes(editor)) {
|
||||
return notUsingSandbox;
|
||||
}
|
||||
// For terminal-based editors like vim and emacs, allow in sandbox.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -141,6 +145,11 @@ export function getDiffCommand(
|
||||
newPath,
|
||||
],
|
||||
};
|
||||
case 'emacs':
|
||||
return {
|
||||
command: 'emacs',
|
||||
args: ['--eval', `(ediff "${oldPath}" "${newPath}")`],
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -190,6 +199,7 @@ export async function openDiff(
|
||||
});
|
||||
|
||||
case 'vim':
|
||||
case 'emacs':
|
||||
case 'neovim': {
|
||||
// Use execSync for terminal-based editors
|
||||
const command =
|
||||
|
||||
@@ -420,7 +420,7 @@ describe('fileUtils', () => {
|
||||
expect(result.llmContent).toContain(
|
||||
'[File content truncated: showing lines 6-10 of 20 total lines. Use offset/limit parameters to view more.]',
|
||||
);
|
||||
expect(result.returnDisplay).toBe('(truncated)');
|
||||
expect(result.returnDisplay).toBe('Read lines 6-10 of 20 from test.txt');
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.originalLineCount).toBe(20);
|
||||
expect(result.linesShown).toEqual([6, 10]);
|
||||
@@ -465,9 +465,72 @@ describe('fileUtils', () => {
|
||||
expect(result.llmContent).toContain(
|
||||
'[File content partially truncated: some lines exceeded maximum length of 2000 characters.]',
|
||||
);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read all 3 lines from test.txt (some lines were shortened)',
|
||||
);
|
||||
expect(result.isTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it('should truncate when line count exceeds the limit', async () => {
|
||||
const lines = Array.from({ length: 11 }, (_, i) => `Line ${i + 1}`);
|
||||
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n'));
|
||||
|
||||
// Read 5 lines, but there are 11 total
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
0,
|
||||
5,
|
||||
);
|
||||
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.returnDisplay).toBe('Read lines 1-5 of 11 from test.txt');
|
||||
});
|
||||
|
||||
it('should truncate when a line length exceeds the character limit', async () => {
|
||||
const longLine = 'b'.repeat(2500);
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`);
|
||||
lines.push(longLine); // Total 11 lines
|
||||
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n'));
|
||||
|
||||
// Read all 11 lines, including the long one
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
0,
|
||||
11,
|
||||
);
|
||||
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read all 11 lines from test.txt (some lines were shortened)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should truncate both line count and line length when both exceed limits', async () => {
|
||||
const linesWithLongInMiddle = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
);
|
||||
linesWithLongInMiddle[4] = 'c'.repeat(2500);
|
||||
actualNodeFs.writeFileSync(
|
||||
testTextFilePath,
|
||||
linesWithLongInMiddle.join('\n'),
|
||||
);
|
||||
|
||||
// Read 10 lines out of 20, including the long line
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
0,
|
||||
10,
|
||||
);
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read lines 1-10 of 20 from test.txt (some lines were shortened)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an error if the file size exceeds 20MB', async () => {
|
||||
// Create a file just over 20MB
|
||||
const twentyOneMB = 21 * 1024 * 1024;
|
||||
|
||||
@@ -310,9 +310,22 @@ export async function processSingleFileContent(
|
||||
}
|
||||
llmTextContent += formattedLines.join('\n');
|
||||
|
||||
// By default, return nothing to streamline the common case of a successful read_file.
|
||||
let returnDisplay = '';
|
||||
if (contentRangeTruncated) {
|
||||
returnDisplay = `Read lines ${
|
||||
actualStartLine + 1
|
||||
}-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`;
|
||||
if (linesWereTruncatedInLength) {
|
||||
returnDisplay += ' (some lines were shortened)';
|
||||
}
|
||||
} else if (linesWereTruncatedInLength) {
|
||||
returnDisplay = `Read all ${originalLineCount} lines from ${relativePathForDisplay} (some lines were shortened)`;
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: llmTextContent,
|
||||
returnDisplay: isTruncated ? '(truncated)' : '',
|
||||
returnDisplay,
|
||||
isTruncated,
|
||||
originalLineCount,
|
||||
linesShown: [actualStartLine + 1, endLine],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Config } from '../config/config.js';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
setSimulate429,
|
||||
disableSimulationAfterFallback,
|
||||
@@ -16,17 +17,25 @@ import {
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { retryWithBackoff } from './retry.js';
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('Flash Fallback Integration', () => {
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats);
|
||||
config = new Config({
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/test',
|
||||
debugMode: false,
|
||||
cwd: '/test',
|
||||
model: 'gemini-2.5-pro',
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
});
|
||||
|
||||
// Reset simulation state for each test
|
||||
|
||||
@@ -305,10 +305,12 @@ Subdir memory
|
||||
false,
|
||||
new FileDiscoveryService(projectRoot),
|
||||
[],
|
||||
'tree',
|
||||
{
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
200, // maxDirs parameter
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -334,6 +336,7 @@ My code memory
|
||||
true,
|
||||
new FileDiscoveryService(projectRoot),
|
||||
[],
|
||||
'tree', // importFormat
|
||||
{
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
|
||||
@@ -43,7 +43,7 @@ async function findProjectRoot(startDir: string): Promise<string | null> {
|
||||
while (true) {
|
||||
const gitPath = path.join(currentDir, '.git');
|
||||
try {
|
||||
const stats = await fs.stat(gitPath);
|
||||
const stats = await fs.lstat(gitPath);
|
||||
if (stats.isDirectory()) {
|
||||
return currentDir;
|
||||
}
|
||||
@@ -94,7 +94,6 @@ async function getGeminiMdFilePathsInternal(
|
||||
const geminiMdFilenames = getAllGeminiMdFilenames();
|
||||
|
||||
for (const geminiMdFilename of geminiMdFilenames) {
|
||||
const resolvedCwd = path.resolve(currentWorkingDirectory);
|
||||
const resolvedHome = path.resolve(userHomePath);
|
||||
const globalMemoryPath = path.join(
|
||||
resolvedHome,
|
||||
@@ -102,12 +101,7 @@ async function getGeminiMdFilePathsInternal(
|
||||
geminiMdFilename,
|
||||
);
|
||||
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
`Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`,
|
||||
);
|
||||
if (debugMode) logger.debug(`User home directory: ${resolvedHome}`);
|
||||
|
||||
// This part that finds the global file always runs.
|
||||
try {
|
||||
await fs.access(globalMemoryPath, fsSync.constants.R_OK);
|
||||
allPaths.add(globalMemoryPath);
|
||||
@@ -116,102 +110,71 @@ async function getGeminiMdFilePathsInternal(
|
||||
`Found readable global ${geminiMdFilename}: ${globalMemoryPath}`,
|
||||
);
|
||||
} catch {
|
||||
// It's okay if it's not found.
|
||||
}
|
||||
|
||||
// FIX: Only perform the workspace search (upward and downward scans)
|
||||
// if a valid currentWorkingDirectory is provided.
|
||||
if (currentWorkingDirectory) {
|
||||
const resolvedCwd = path.resolve(currentWorkingDirectory);
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
`Global ${geminiMdFilename} not found or not readable: ${globalMemoryPath}`,
|
||||
`Searching for ${geminiMdFilename} starting from CWD: ${resolvedCwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
const projectRoot = await findProjectRoot(resolvedCwd);
|
||||
if (debugMode)
|
||||
logger.debug(`Determined project root: ${projectRoot ?? 'None'}`);
|
||||
const projectRoot = await findProjectRoot(resolvedCwd);
|
||||
if (debugMode)
|
||||
logger.debug(`Determined project root: ${projectRoot ?? 'None'}`);
|
||||
|
||||
const upwardPaths: string[] = [];
|
||||
let currentDir = resolvedCwd;
|
||||
// Determine the directory that signifies the top of the project or user-specific space.
|
||||
const ultimateStopDir = projectRoot
|
||||
? path.dirname(projectRoot)
|
||||
: path.dirname(resolvedHome);
|
||||
const upwardPaths: string[] = [];
|
||||
let currentDir = resolvedCwd;
|
||||
const ultimateStopDir = projectRoot
|
||||
? path.dirname(projectRoot)
|
||||
: path.dirname(resolvedHome);
|
||||
|
||||
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
||||
// Loop until filesystem root or currentDir is empty
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`Checking for ${geminiMdFilename} in (upward scan): ${currentDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Skip the global .gemini directory itself during upward scan from CWD,
|
||||
// as global is handled separately and explicitly first.
|
||||
if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) {
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`Upward scan reached global config dir path, stopping upward search here: ${currentDir}`,
|
||||
);
|
||||
while (currentDir && currentDir !== path.dirname(currentDir)) {
|
||||
if (currentDir === path.join(resolvedHome, GEMINI_CONFIG_DIR)) {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const potentialPath = path.join(currentDir, geminiMdFilename);
|
||||
try {
|
||||
await fs.access(potentialPath, fsSync.constants.R_OK);
|
||||
// Add to upwardPaths only if it's not the already added globalMemoryPath
|
||||
if (potentialPath !== globalMemoryPath) {
|
||||
upwardPaths.unshift(potentialPath);
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`Found readable upward ${geminiMdFilename}: ${potentialPath}`,
|
||||
);
|
||||
const potentialPath = path.join(currentDir, geminiMdFilename);
|
||||
try {
|
||||
await fs.access(potentialPath, fsSync.constants.R_OK);
|
||||
if (potentialPath !== globalMemoryPath) {
|
||||
upwardPaths.unshift(potentialPath);
|
||||
}
|
||||
} catch {
|
||||
// Not found, continue.
|
||||
}
|
||||
} catch {
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`Upward ${geminiMdFilename} not found or not readable in: ${currentDir}`,
|
||||
);
|
||||
|
||||
if (currentDir === ultimateStopDir) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
upwardPaths.forEach((p) => allPaths.add(p));
|
||||
|
||||
// Stop condition: if currentDir is the ultimateStopDir, break after this iteration.
|
||||
if (currentDir === ultimateStopDir) {
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
`Reached ultimate stop directory for upward scan: ${currentDir}`,
|
||||
);
|
||||
break;
|
||||
const mergedOptions = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...fileFilteringOptions,
|
||||
};
|
||||
|
||||
const downwardPaths = await bfsFileSearch(resolvedCwd, {
|
||||
fileName: geminiMdFilename,
|
||||
maxDirs,
|
||||
debug: debugMode,
|
||||
fileService,
|
||||
fileFilteringOptions: mergedOptions,
|
||||
});
|
||||
downwardPaths.sort();
|
||||
for (const dPath of downwardPaths) {
|
||||
allPaths.add(dPath);
|
||||
}
|
||||
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
upwardPaths.forEach((p) => allPaths.add(p));
|
||||
|
||||
// Merge options with memory defaults, with options taking precedence
|
||||
const mergedOptions = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...fileFilteringOptions,
|
||||
};
|
||||
|
||||
const downwardPaths = await bfsFileSearch(resolvedCwd, {
|
||||
fileName: geminiMdFilename,
|
||||
maxDirs,
|
||||
debug: debugMode,
|
||||
fileService,
|
||||
fileFilteringOptions: mergedOptions, // Pass merged options as fileFilter
|
||||
});
|
||||
downwardPaths.sort(); // Sort for consistent ordering, though hierarchy might be more complex
|
||||
if (debugMode && downwardPaths.length > 0)
|
||||
logger.debug(
|
||||
`Found downward ${geminiMdFilename} files (sorted): ${JSON.stringify(
|
||||
downwardPaths,
|
||||
)}`,
|
||||
);
|
||||
// Add downward paths only if they haven't been included already (e.g. from upward scan)
|
||||
for (const dPath of downwardPaths) {
|
||||
allPaths.add(dPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Add extension context file paths
|
||||
// Add extension context file paths.
|
||||
for (const extensionPath of extensionContextFilePaths) {
|
||||
allPaths.add(extensionPath);
|
||||
}
|
||||
@@ -230,6 +193,7 @@ async function getGeminiMdFilePathsInternal(
|
||||
async function readGeminiMdFiles(
|
||||
filePaths: string[],
|
||||
debugMode: boolean,
|
||||
importFormat: 'flat' | 'tree' = 'tree',
|
||||
): Promise<GeminiFileContent[]> {
|
||||
const results: GeminiFileContent[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
@@ -237,16 +201,19 @@ async function readGeminiMdFiles(
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
// Process imports in the content
|
||||
const processedContent = await processImports(
|
||||
const processedResult = await processImports(
|
||||
content,
|
||||
path.dirname(filePath),
|
||||
debugMode,
|
||||
undefined,
|
||||
undefined,
|
||||
importFormat,
|
||||
);
|
||||
|
||||
results.push({ filePath, content: processedContent });
|
||||
results.push({ filePath, content: processedResult.content });
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
`Successfully read and processed imports: ${filePath} (Length: ${processedContent.length})`,
|
||||
`Successfully read and processed imports: ${filePath} (Length: ${processedResult.content.length})`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST;
|
||||
@@ -293,12 +260,13 @@ export async function loadServerHierarchicalMemory(
|
||||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
importFormat: 'flat' | 'tree' = 'tree',
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
maxDirs: number = 200,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
if (debugMode)
|
||||
logger.debug(
|
||||
`Loading server hierarchical memory for CWD: ${currentWorkingDirectory}`,
|
||||
`Loading server hierarchical memory for CWD: ${currentWorkingDirectory} (importFormat: ${importFormat})`,
|
||||
);
|
||||
|
||||
// For the server, homedir() refers to the server process's home.
|
||||
@@ -317,7 +285,11 @@ export async function loadServerHierarchicalMemory(
|
||||
if (debugMode) logger.debug('No GEMINI.md files found in hierarchy.');
|
||||
return { memoryContent: '', fileCount: 0 };
|
||||
}
|
||||
const contentsWithPaths = await readGeminiMdFiles(filePaths, debugMode);
|
||||
const contentsWithPaths = await readGeminiMdFiles(
|
||||
filePaths,
|
||||
debugMode,
|
||||
importFormat,
|
||||
);
|
||||
// Pass CWD for relative path display in concatenated content
|
||||
const combinedInstructions = concatenateInstructions(
|
||||
contentsWithPaths,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { marked } from 'marked';
|
||||
|
||||
// Simple console logger for import processing
|
||||
const logger = {
|
||||
@@ -29,15 +30,176 @@ interface ImportState {
|
||||
currentFile?: string; // Track the current file being processed
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface representing a file in the import tree
|
||||
*/
|
||||
export interface MemoryFile {
|
||||
path: string;
|
||||
imports?: MemoryFile[]; // Direct imports, in the order they were imported
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing imports
|
||||
*/
|
||||
export interface ProcessImportsResult {
|
||||
content: string;
|
||||
importTree: MemoryFile;
|
||||
}
|
||||
|
||||
// Helper to find the project root (looks for .git directory)
|
||||
async function findProjectRoot(startDir: string): Promise<string> {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
const gitPath = path.join(currentDir, '.git');
|
||||
try {
|
||||
const stats = await fs.lstat(gitPath);
|
||||
if (stats.isDirectory()) {
|
||||
return currentDir;
|
||||
}
|
||||
} catch {
|
||||
// .git not found, continue to parent
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
// Reached filesystem root
|
||||
break;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
// Fallback to startDir if .git not found
|
||||
return path.resolve(startDir);
|
||||
}
|
||||
|
||||
// Add a type guard for error objects
|
||||
function hasMessage(err: unknown): err is { message: string } {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'message' in err &&
|
||||
typeof (err as { message: unknown }).message === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to find all code block and inline code regions using marked
|
||||
/**
|
||||
* Finds all import statements in content without using regex
|
||||
* @returns Array of {start, _end, path} objects for each import found
|
||||
*/
|
||||
function findImports(
|
||||
content: string,
|
||||
): Array<{ start: number; _end: number; path: string }> {
|
||||
const imports: Array<{ start: number; _end: number; path: string }> = [];
|
||||
let i = 0;
|
||||
const len = content.length;
|
||||
|
||||
while (i < len) {
|
||||
// Find next @ symbol
|
||||
i = content.indexOf('@', i);
|
||||
if (i === -1) break;
|
||||
|
||||
// Check if it's a word boundary (not part of another word)
|
||||
if (i > 0 && !isWhitespace(content[i - 1])) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the end of the import path (whitespace or newline)
|
||||
let j = i + 1;
|
||||
while (
|
||||
j < len &&
|
||||
!isWhitespace(content[j]) &&
|
||||
content[j] !== '\n' &&
|
||||
content[j] !== '\r'
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
|
||||
// Extract the path (everything after @)
|
||||
const importPath = content.slice(i + 1, j);
|
||||
|
||||
// Basic validation (starts with ./ or / or letter)
|
||||
if (
|
||||
importPath.length > 0 &&
|
||||
(importPath[0] === '.' ||
|
||||
importPath[0] === '/' ||
|
||||
isLetter(importPath[0]))
|
||||
) {
|
||||
imports.push({
|
||||
start: i,
|
||||
_end: j,
|
||||
path: importPath,
|
||||
});
|
||||
}
|
||||
|
||||
i = j + 1;
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
function isWhitespace(char: string): boolean {
|
||||
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
|
||||
}
|
||||
|
||||
function isLetter(char: string): boolean {
|
||||
const code = char.charCodeAt(0);
|
||||
return (
|
||||
(code >= 65 && code <= 90) || // A-Z
|
||||
(code >= 97 && code <= 122)
|
||||
); // a-z
|
||||
}
|
||||
|
||||
function findCodeRegions(content: string): Array<[number, number]> {
|
||||
const regions: Array<[number, number]> = [];
|
||||
const tokens = marked.lexer(content);
|
||||
|
||||
// Map from raw content to a queue of its start indices in the original content.
|
||||
const rawContentIndices = new Map<string, number[]>();
|
||||
|
||||
function walk(token: { type: string; raw: string; tokens?: unknown[] }) {
|
||||
if (token.type === 'code' || token.type === 'codespan') {
|
||||
if (!rawContentIndices.has(token.raw)) {
|
||||
const indices: number[] = [];
|
||||
let lastIndex = -1;
|
||||
while ((lastIndex = content.indexOf(token.raw, lastIndex + 1)) !== -1) {
|
||||
indices.push(lastIndex);
|
||||
}
|
||||
rawContentIndices.set(token.raw, indices);
|
||||
}
|
||||
|
||||
const indices = rawContentIndices.get(token.raw);
|
||||
if (indices && indices.length > 0) {
|
||||
// Assume tokens are processed in order of appearance.
|
||||
// Dequeue the next available index for this raw content.
|
||||
const idx = indices.shift()!;
|
||||
regions.push([idx, idx + token.raw.length]);
|
||||
}
|
||||
}
|
||||
|
||||
if ('tokens' in token && token.tokens) {
|
||||
for (const child of token.tokens) {
|
||||
walk(child as { type: string; raw: string; tokens?: unknown[] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
walk(token);
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes import statements in GEMINI.md content
|
||||
* Supports @path/to/file.md syntax for importing content from other files
|
||||
*
|
||||
* Supports @path/to/file syntax for importing content from other files
|
||||
* @param content - The content to process for imports
|
||||
* @param basePath - The directory path where the current file is located
|
||||
* @param debugMode - Whether to enable debug logging
|
||||
* @param importState - State tracking for circular import prevention
|
||||
* @returns Processed content with imports resolved
|
||||
* @param projectRoot - The project root directory for allowed directories
|
||||
* @param importFormat - The format of the import tree
|
||||
* @returns Processed content with imports resolved and import tree
|
||||
*/
|
||||
export async function processImports(
|
||||
content: string,
|
||||
@@ -45,156 +207,198 @@ export async function processImports(
|
||||
debugMode: boolean = false,
|
||||
importState: ImportState = {
|
||||
processedFiles: new Set(),
|
||||
maxDepth: 10,
|
||||
maxDepth: 5,
|
||||
currentDepth: 0,
|
||||
},
|
||||
): Promise<string> {
|
||||
projectRoot?: string,
|
||||
importFormat: 'flat' | 'tree' = 'tree',
|
||||
): Promise<ProcessImportsResult> {
|
||||
if (!projectRoot) {
|
||||
projectRoot = await findProjectRoot(basePath);
|
||||
}
|
||||
|
||||
if (importState.currentDepth >= importState.maxDepth) {
|
||||
if (debugMode) {
|
||||
logger.warn(
|
||||
`Maximum import depth (${importState.maxDepth}) reached. Stopping import processing.`,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
return {
|
||||
content,
|
||||
importTree: { path: importState.currentFile || 'unknown' },
|
||||
};
|
||||
}
|
||||
|
||||
// Regex to match @path/to/file imports (supports any file extension)
|
||||
// Supports both @path/to/file.md and @./path/to/file.md syntax
|
||||
const importRegex = /@([./]?[^\s\n]+\.[^\s\n]+)/g;
|
||||
// --- FLAT FORMAT LOGIC ---
|
||||
if (importFormat === 'flat') {
|
||||
// Use a queue to process files in order of first encounter, and a set to avoid duplicates
|
||||
const flatFiles: Array<{ path: string; content: string }> = [];
|
||||
// Track processed files across the entire operation
|
||||
const processedFiles = new Set<string>();
|
||||
|
||||
let processedContent = content;
|
||||
let match: RegExpExecArray | null;
|
||||
// Helper to recursively process imports
|
||||
async function processFlat(
|
||||
fileContent: string,
|
||||
fileBasePath: string,
|
||||
filePath: string,
|
||||
depth: number,
|
||||
) {
|
||||
// Normalize the file path to ensure consistent comparison
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
|
||||
// Process all imports in the content
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
const importPath = match[1];
|
||||
// Skip if already processed
|
||||
if (processedFiles.has(normalizedPath)) return;
|
||||
|
||||
// Validate import path to prevent path traversal attacks
|
||||
if (!validateImportPath(importPath, basePath, [basePath])) {
|
||||
processedContent = processedContent.replace(
|
||||
match[0],
|
||||
`<!-- Import failed: ${importPath} - Path traversal attempt -->`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Mark as processed before processing to prevent infinite recursion
|
||||
processedFiles.add(normalizedPath);
|
||||
|
||||
// Check if the import is for a non-md file and warn
|
||||
if (!importPath.endsWith('.md')) {
|
||||
logger.warn(
|
||||
`Import processor only supports .md files. Attempting to import non-md file: ${importPath}. This will fail.`,
|
||||
);
|
||||
// Replace the import with a warning comment
|
||||
processedContent = processedContent.replace(
|
||||
match[0],
|
||||
`<!-- Import failed: ${importPath} - Only .md files are supported -->`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Add this file to the flat list
|
||||
flatFiles.push({ path: normalizedPath, content: fileContent });
|
||||
|
||||
const fullPath = path.resolve(basePath, importPath);
|
||||
// Find imports in this file
|
||||
const codeRegions = findCodeRegions(fileContent);
|
||||
const imports = findImports(fileContent);
|
||||
|
||||
if (debugMode) {
|
||||
logger.debug(`Processing import: ${importPath} -> ${fullPath}`);
|
||||
}
|
||||
// Process imports in reverse order to handle indices correctly
|
||||
for (let i = imports.length - 1; i >= 0; i--) {
|
||||
const { start, _end, path: importPath } = imports[i];
|
||||
|
||||
// Check for circular imports - if we're already processing this file
|
||||
if (importState.currentFile === fullPath) {
|
||||
if (debugMode) {
|
||||
logger.warn(`Circular import detected: ${importPath}`);
|
||||
}
|
||||
// Replace the import with a warning comment
|
||||
processedContent = processedContent.replace(
|
||||
match[0],
|
||||
`<!-- Circular import detected: ${importPath} -->`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've already processed this file in this import chain
|
||||
if (importState.processedFiles.has(fullPath)) {
|
||||
if (debugMode) {
|
||||
logger.warn(`File already processed in this chain: ${importPath}`);
|
||||
}
|
||||
// Replace the import with a warning comment
|
||||
processedContent = processedContent.replace(
|
||||
match[0],
|
||||
`<!-- File already processed: ${importPath} -->`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for potential circular imports by looking at the import chain
|
||||
if (importState.currentFile) {
|
||||
const currentFileDir = path.dirname(importState.currentFile);
|
||||
const potentialCircularPath = path.resolve(currentFileDir, importPath);
|
||||
if (potentialCircularPath === importState.currentFile) {
|
||||
if (debugMode) {
|
||||
logger.warn(`Circular import detected: ${importPath}`);
|
||||
// Skip if inside a code region
|
||||
if (
|
||||
codeRegions.some(
|
||||
([regionStart, regionEnd]) =>
|
||||
start >= regionStart && start < regionEnd,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate import path
|
||||
if (
|
||||
!validateImportPath(importPath, fileBasePath, [projectRoot || ''])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = path.resolve(fileBasePath, importPath);
|
||||
const normalizedFullPath = path.normalize(fullPath);
|
||||
|
||||
// Skip if already processed
|
||||
if (processedFiles.has(normalizedFullPath)) continue;
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
const importedContent = await fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
// Process the imported file
|
||||
await processFlat(
|
||||
importedContent,
|
||||
path.dirname(fullPath),
|
||||
normalizedFullPath,
|
||||
depth + 1,
|
||||
);
|
||||
} catch (error) {
|
||||
if (debugMode) {
|
||||
logger.warn(
|
||||
`Failed to import ${fullPath}: ${hasMessage(error) ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
// Continue with other imports even if one fails
|
||||
}
|
||||
// Replace the import with a warning comment
|
||||
processedContent = processedContent.replace(
|
||||
match[0],
|
||||
`<!-- Circular import detected: ${importPath} -->`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Start with the root file (current file)
|
||||
const rootPath = path.normalize(
|
||||
importState.currentFile || path.resolve(basePath),
|
||||
);
|
||||
await processFlat(content, basePath, rootPath, 0);
|
||||
|
||||
// Concatenate all unique files in order, Claude-style
|
||||
const flatContent = flatFiles
|
||||
.map(
|
||||
(f) =>
|
||||
`--- File: ${f.path} ---\n${f.content.trim()}\n--- End of File: ${f.path} ---`,
|
||||
)
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: flatContent,
|
||||
importTree: { path: rootPath }, // Tree not meaningful in flat mode
|
||||
};
|
||||
}
|
||||
|
||||
// --- TREE FORMAT LOGIC (existing) ---
|
||||
const codeRegions = findCodeRegions(content);
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
const imports: MemoryFile[] = [];
|
||||
const importsList = findImports(content);
|
||||
|
||||
for (const { start, _end, path: importPath } of importsList) {
|
||||
// Add content before this import
|
||||
result += content.substring(lastIndex, start);
|
||||
lastIndex = _end;
|
||||
|
||||
// Skip if inside a code region
|
||||
if (codeRegions.some(([s, e]) => start >= s && start < e)) {
|
||||
result += `@${importPath}`;
|
||||
continue;
|
||||
}
|
||||
// Validate import path to prevent path traversal attacks
|
||||
if (!validateImportPath(importPath, basePath, [projectRoot || ''])) {
|
||||
result += `<!-- Import failed: ${importPath} - Path traversal attempt -->`;
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.resolve(basePath, importPath);
|
||||
if (importState.processedFiles.has(fullPath)) {
|
||||
result += `<!-- File already processed: ${importPath} -->`;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Check if the file exists
|
||||
await fs.access(fullPath);
|
||||
|
||||
// Read the imported file content
|
||||
const importedContent = await fs.readFile(fullPath, 'utf-8');
|
||||
|
||||
if (debugMode) {
|
||||
logger.debug(`Successfully read imported file: ${fullPath}`);
|
||||
}
|
||||
|
||||
// Recursively process imports in the imported content
|
||||
const processedImportedContent = await processImports(
|
||||
importedContent,
|
||||
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||
// Mark this file as processed for this import chain
|
||||
const newImportState: ImportState = {
|
||||
...importState,
|
||||
processedFiles: new Set(importState.processedFiles),
|
||||
currentDepth: importState.currentDepth + 1,
|
||||
currentFile: fullPath,
|
||||
};
|
||||
newImportState.processedFiles.add(fullPath);
|
||||
const imported = await processImports(
|
||||
fileContent,
|
||||
path.dirname(fullPath),
|
||||
debugMode,
|
||||
{
|
||||
...importState,
|
||||
processedFiles: new Set([...importState.processedFiles, fullPath]),
|
||||
currentDepth: importState.currentDepth + 1,
|
||||
currentFile: fullPath, // Set the current file being processed
|
||||
},
|
||||
newImportState,
|
||||
projectRoot,
|
||||
importFormat,
|
||||
);
|
||||
|
||||
// Replace the import statement with the processed content
|
||||
processedContent = processedContent.replace(
|
||||
match[0],
|
||||
`<!-- Imported from: ${importPath} -->\n${processedImportedContent}\n<!-- End of import from: ${importPath} -->`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (debugMode) {
|
||||
logger.error(`Failed to import ${importPath}: ${errorMessage}`);
|
||||
result += `<!-- Imported from: ${importPath} -->\n${imported.content}\n<!-- End of import from: ${importPath} -->`;
|
||||
imports.push(imported.importTree);
|
||||
} catch (err: unknown) {
|
||||
let message = 'Unknown error';
|
||||
if (hasMessage(err)) {
|
||||
message = err.message;
|
||||
} else if (typeof err === 'string') {
|
||||
message = err;
|
||||
}
|
||||
|
||||
// Replace the import with an error comment
|
||||
processedContent = processedContent.replace(
|
||||
match[0],
|
||||
`<!-- Import failed: ${importPath} - ${errorMessage} -->`,
|
||||
);
|
||||
logger.error(`Failed to import ${importPath}: ${message}`);
|
||||
result += `<!-- Import failed: ${importPath} - ${message} -->`;
|
||||
}
|
||||
}
|
||||
// Add any remaining content after the last match
|
||||
result += content.substring(lastIndex);
|
||||
|
||||
return processedContent;
|
||||
return {
|
||||
content: result,
|
||||
importTree: {
|
||||
path: importState.currentFile || 'unknown',
|
||||
imports: imports.length > 0 ? imports : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates import paths to ensure they are safe and within allowed directories
|
||||
*
|
||||
* @param importPath - The import path to validate
|
||||
* @param basePath - The base directory for resolving relative paths
|
||||
* @param allowedDirectories - Array of allowed directory paths
|
||||
* @returns Whether the import path is valid
|
||||
*/
|
||||
export function validateImportPath(
|
||||
importPath: string,
|
||||
basePath: string,
|
||||
@@ -209,6 +413,8 @@ export function validateImportPath(
|
||||
|
||||
return allowedDirectories.some((allowedDir) => {
|
||||
const normalizedAllowedDir = path.resolve(allowedDir);
|
||||
return resolvedPath.startsWith(normalizedAllowedDir);
|
||||
const isSamePath = resolvedPath === normalizedAllowedDir;
|
||||
const isSubPath = resolvedPath.startsWith(normalizedAllowedDir + path.sep);
|
||||
return isSamePath || isSubPath;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest';
|
||||
import { Content, GoogleGenAI, Models } from '@google/genai';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { checkNextSpeaker, NextSpeakerResponse } from './nextSpeakerChecker.js';
|
||||
@@ -248,6 +248,6 @@ describe('checkNextSpeaker', () => {
|
||||
expect(mockGeminiClient.generateJson).toHaveBeenCalled();
|
||||
const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock
|
||||
.calls[0];
|
||||
expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_MODEL);
|
||||
expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Content, SchemaUnion, Type } from '@google/genai';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import { GeminiChat } from '../core/geminiChat.js';
|
||||
import { isFunctionResponse } from './messageInspectors.js';
|
||||
@@ -14,27 +14,7 @@ const CHECK_PROMPT = `Analyze *only* the content and structure of your immediate
|
||||
**Decision Rules (apply in order):**
|
||||
1. **Model Continues:** If your last response explicitly states an immediate next action *you* intend to take (e.g., "Next, I will...", "Now I'll process...", "Moving on to analyze...", indicates an intended tool call that didn't execute), OR if the response seems clearly incomplete (cut off mid-thought without a natural conclusion), then the **'model'** should speak next.
|
||||
2. **Question to User:** If your last response ends with a direct question specifically addressed *to the user*, then the **'user'** should speak next.
|
||||
3. **Waiting for User:** If your last response completed a thought, statement, or task *and* does not meet the criteria for Rule 1 (Model Continues) or Rule 2 (Question to User), it implies a pause expecting user input or reaction. In this case, the **'user'** should speak next.
|
||||
**Output Format:**
|
||||
Respond *only* in JSON format according to the following schema. Do not include any text outside the JSON structure.
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation justifying the 'next_speaker' choice based *strictly* on the applicable rule and the content/structure of the preceding turn."
|
||||
},
|
||||
"next_speaker": {
|
||||
"type": "string",
|
||||
"enum": ["user", "model"],
|
||||
"description": "Who should speak next based *only* on the preceding turn and the decision rules."
|
||||
}
|
||||
},
|
||||
"required": ["next_speaker", "reasoning"]
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
3. **Waiting for User:** If your last response completed a thought, statement, or task *and* does not meet the criteria for Rule 1 (Model Continues) or Rule 2 (Question to User), it implies a pause expecting user input or reaction. In this case, the **'user'** should speak next.`;
|
||||
|
||||
const RESPONSE_SCHEMA: SchemaUnion = {
|
||||
type: Type.OBJECT,
|
||||
@@ -132,7 +112,7 @@ export async function checkNextSpeaker(
|
||||
contents,
|
||||
RESPONSE_SCHEMA,
|
||||
abortSignal,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
)) as unknown as NextSpeakerResponse;
|
||||
|
||||
if (
|
||||
|
||||
214
packages/core/src/utils/paths.test.ts
Normal file
214
packages/core/src/utils/paths.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { escapePath, unescapePath } from './paths.js';
|
||||
|
||||
describe('escapePath', () => {
|
||||
it('should escape spaces', () => {
|
||||
expect(escapePath('my file.txt')).toBe('my\\ file.txt');
|
||||
});
|
||||
|
||||
it('should escape tabs', () => {
|
||||
expect(escapePath('file\twith\ttabs.txt')).toBe('file\\\twith\\\ttabs.txt');
|
||||
});
|
||||
|
||||
it('should escape parentheses', () => {
|
||||
expect(escapePath('file(1).txt')).toBe('file\\(1\\).txt');
|
||||
});
|
||||
|
||||
it('should escape square brackets', () => {
|
||||
expect(escapePath('file[backup].txt')).toBe('file\\[backup\\].txt');
|
||||
});
|
||||
|
||||
it('should escape curly braces', () => {
|
||||
expect(escapePath('file{temp}.txt')).toBe('file\\{temp\\}.txt');
|
||||
});
|
||||
|
||||
it('should escape semicolons', () => {
|
||||
expect(escapePath('file;name.txt')).toBe('file\\;name.txt');
|
||||
});
|
||||
|
||||
it('should escape ampersands', () => {
|
||||
expect(escapePath('file&name.txt')).toBe('file\\&name.txt');
|
||||
});
|
||||
|
||||
it('should escape pipes', () => {
|
||||
expect(escapePath('file|name.txt')).toBe('file\\|name.txt');
|
||||
});
|
||||
|
||||
it('should escape asterisks', () => {
|
||||
expect(escapePath('file*.txt')).toBe('file\\*.txt');
|
||||
});
|
||||
|
||||
it('should escape question marks', () => {
|
||||
expect(escapePath('file?.txt')).toBe('file\\?.txt');
|
||||
});
|
||||
|
||||
it('should escape dollar signs', () => {
|
||||
expect(escapePath('file$name.txt')).toBe('file\\$name.txt');
|
||||
});
|
||||
|
||||
it('should escape backticks', () => {
|
||||
expect(escapePath('file`name.txt')).toBe('file\\`name.txt');
|
||||
});
|
||||
|
||||
it('should escape single quotes', () => {
|
||||
expect(escapePath("file'name.txt")).toBe("file\\'name.txt");
|
||||
});
|
||||
|
||||
it('should escape double quotes', () => {
|
||||
expect(escapePath('file"name.txt')).toBe('file\\"name.txt');
|
||||
});
|
||||
|
||||
it('should escape hash symbols', () => {
|
||||
expect(escapePath('file#name.txt')).toBe('file\\#name.txt');
|
||||
});
|
||||
|
||||
it('should escape exclamation marks', () => {
|
||||
expect(escapePath('file!name.txt')).toBe('file\\!name.txt');
|
||||
});
|
||||
|
||||
it('should escape tildes', () => {
|
||||
expect(escapePath('file~name.txt')).toBe('file\\~name.txt');
|
||||
});
|
||||
|
||||
it('should escape less than and greater than signs', () => {
|
||||
expect(escapePath('file<name>.txt')).toBe('file\\<name\\>.txt');
|
||||
});
|
||||
|
||||
it('should handle multiple special characters', () => {
|
||||
expect(escapePath('my file (backup) [v1.2].txt')).toBe(
|
||||
'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not double-escape already escaped characters', () => {
|
||||
expect(escapePath('my\\ file.txt')).toBe('my\\ file.txt');
|
||||
expect(escapePath('file\\(name\\).txt')).toBe('file\\(name\\).txt');
|
||||
});
|
||||
|
||||
it('should handle escaped backslashes correctly', () => {
|
||||
// Double backslash (escaped backslash) followed by space should escape the space
|
||||
expect(escapePath('path\\\\ file.txt')).toBe('path\\\\\\ file.txt');
|
||||
// Triple backslash (escaped backslash + escaping backslash) followed by space should not double-escape
|
||||
expect(escapePath('path\\\\\\ file.txt')).toBe('path\\\\\\ file.txt');
|
||||
// Quadruple backslash (two escaped backslashes) followed by space should escape the space
|
||||
expect(escapePath('path\\\\\\\\ file.txt')).toBe('path\\\\\\\\\\ file.txt');
|
||||
});
|
||||
|
||||
it('should handle complex escaped backslash scenarios', () => {
|
||||
// Escaped backslash before special character that needs escaping
|
||||
expect(escapePath('file\\\\(test).txt')).toBe('file\\\\\\(test\\).txt');
|
||||
// Multiple escaped backslashes
|
||||
expect(escapePath('path\\\\\\\\with space.txt')).toBe(
|
||||
'path\\\\\\\\with\\ space.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paths without special characters', () => {
|
||||
expect(escapePath('normalfile.txt')).toBe('normalfile.txt');
|
||||
expect(escapePath('path/to/normalfile.txt')).toBe('path/to/normalfile.txt');
|
||||
});
|
||||
|
||||
it('should handle complex real-world examples', () => {
|
||||
expect(escapePath('My Documents/Project (2024)/file [backup].txt')).toBe(
|
||||
'My\\ Documents/Project\\ \\(2024\\)/file\\ \\[backup\\].txt',
|
||||
);
|
||||
expect(escapePath('file with $special &chars!.txt')).toBe(
|
||||
'file\\ with\\ \\$special\\ \\&chars\\!.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(escapePath('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle paths with only special characters', () => {
|
||||
expect(escapePath(' ()[]{};&|*?$`\'"#!~<>')).toBe(
|
||||
'\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unescapePath', () => {
|
||||
it('should unescape spaces', () => {
|
||||
expect(unescapePath('my\\ file.txt')).toBe('my file.txt');
|
||||
});
|
||||
|
||||
it('should unescape tabs', () => {
|
||||
expect(unescapePath('file\\\twith\\\ttabs.txt')).toBe(
|
||||
'file\twith\ttabs.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should unescape parentheses', () => {
|
||||
expect(unescapePath('file\\(1\\).txt')).toBe('file(1).txt');
|
||||
});
|
||||
|
||||
it('should unescape square brackets', () => {
|
||||
expect(unescapePath('file\\[backup\\].txt')).toBe('file[backup].txt');
|
||||
});
|
||||
|
||||
it('should unescape curly braces', () => {
|
||||
expect(unescapePath('file\\{temp\\}.txt')).toBe('file{temp}.txt');
|
||||
});
|
||||
|
||||
it('should unescape multiple special characters', () => {
|
||||
expect(unescapePath('my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt')).toBe(
|
||||
'my file (backup) [v1.2].txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paths without escaped characters', () => {
|
||||
expect(unescapePath('normalfile.txt')).toBe('normalfile.txt');
|
||||
expect(unescapePath('path/to/normalfile.txt')).toBe(
|
||||
'path/to/normalfile.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all special characters', () => {
|
||||
expect(
|
||||
unescapePath(
|
||||
'\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>',
|
||||
),
|
||||
).toBe(' ()[]{};&|*?$`\'"#!~<>');
|
||||
});
|
||||
|
||||
it('should be the inverse of escapePath', () => {
|
||||
const testCases = [
|
||||
'my file.txt',
|
||||
'file(1).txt',
|
||||
'file[backup].txt',
|
||||
'My Documents/Project (2024)/file [backup].txt',
|
||||
'file with $special &chars!.txt',
|
||||
' ()[]{};&|*?$`\'"#!~<>',
|
||||
'file\twith\ttabs.txt',
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
expect(unescapePath(escapePath(testCase))).toBe(testCase);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(unescapePath('')).toBe('');
|
||||
});
|
||||
|
||||
it('should not affect backslashes not followed by special characters', () => {
|
||||
expect(unescapePath('file\\name.txt')).toBe('file\\name.txt');
|
||||
expect(unescapePath('path\\to\\file.txt')).toBe('path\\to\\file.txt');
|
||||
});
|
||||
|
||||
it('should handle escaped backslashes in unescaping', () => {
|
||||
// Should correctly unescape when there are escaped backslashes
|
||||
expect(unescapePath('path\\\\\\ file.txt')).toBe('path\\\\ file.txt');
|
||||
expect(unescapePath('path\\\\\\\\\\ file.txt')).toBe(
|
||||
'path\\\\\\\\ file.txt',
|
||||
);
|
||||
expect(unescapePath('file\\\\\\(test\\).txt')).toBe('file\\\\(test).txt');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,13 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const COMMANDS_DIR_NAME = 'commands';
|
||||
|
||||
/**
|
||||
* Special characters that need to be escaped in file paths for shell compatibility.
|
||||
* Includes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
|
||||
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
|
||||
*/
|
||||
export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/;
|
||||
|
||||
/**
|
||||
* Replaces the home directory with a tilde.
|
||||
* @param path - The path to tildeify.
|
||||
@@ -119,26 +126,43 @@ export function makeRelative(
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes spaces in a file path.
|
||||
* Escapes special characters in a file path like macOS terminal does.
|
||||
* Escapes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
|
||||
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
|
||||
*/
|
||||
export function escapePath(filePath: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < filePath.length; i++) {
|
||||
// Only escape spaces that are not already escaped.
|
||||
if (filePath[i] === ' ' && (i === 0 || filePath[i - 1] !== '\\')) {
|
||||
result += '\\ ';
|
||||
const char = filePath[i];
|
||||
|
||||
// Count consecutive backslashes before this character
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && filePath[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
|
||||
// Character is already escaped if there's an odd number of backslashes before it
|
||||
const isAlreadyEscaped = backslashCount % 2 === 1;
|
||||
|
||||
// Only escape if not already escaped
|
||||
if (!isAlreadyEscaped && SHELL_SPECIAL_CHARS.test(char)) {
|
||||
result += '\\' + char;
|
||||
} else {
|
||||
result += filePath[i];
|
||||
result += char;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescapes spaces in a file path.
|
||||
* Unescapes special characters in a file path.
|
||||
* Removes backslash escaping from shell metacharacters.
|
||||
*/
|
||||
export function unescapePath(filePath: string): string {
|
||||
return filePath.replace(/\\ /g, ' ');
|
||||
return filePath.replace(
|
||||
new RegExp(`\\\\([${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`, 'g'),
|
||||
'$1',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,14 +6,9 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { retryWithBackoff } from './retry.js';
|
||||
import { retryWithBackoff, HttpError } from './retry.js';
|
||||
import { setSimulate429 } from './testUtils.js';
|
||||
|
||||
// Define an interface for the error with a status property
|
||||
interface HttpError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// Helper to create a mock function that fails a certain number of times
|
||||
const createFailingFunction = (
|
||||
failures: number,
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
isGenericQuotaExceededError,
|
||||
} from './quotaErrorDetection.js';
|
||||
|
||||
export interface HttpError extends Error {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface RetryOptions {
|
||||
maxAttempts: number;
|
||||
initialDelayMs: number;
|
||||
|
||||
242
packages/core/src/utils/secure-browser-launcher.test.ts
Normal file
242
packages/core/src/utils/secure-browser-launcher.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { openBrowserSecurely } from './secure-browser-launcher.js';
|
||||
|
||||
// Create mock function using vi.hoisted
|
||||
const mockExecFile = vi.hoisted(() => vi.fn());
|
||||
|
||||
// Mock modules
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('node:util', () => ({
|
||||
promisify: () => mockExecFile,
|
||||
}));
|
||||
|
||||
describe('secure-browser-launcher', () => {
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExecFile.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
function setPlatform(platform: string) {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('should allow valid HTTP URLs', async () => {
|
||||
setPlatform('darwin');
|
||||
await openBrowserSecurely('http://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
['http://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow valid HTTPS URLs', async () => {
|
||||
setPlatform('darwin');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-HTTP(S) protocols', async () => {
|
||||
await expect(openBrowserSecurely('file:///etc/passwd')).rejects.toThrow(
|
||||
'Unsafe protocol',
|
||||
);
|
||||
await expect(openBrowserSecurely('javascript:alert(1)')).rejects.toThrow(
|
||||
'Unsafe protocol',
|
||||
);
|
||||
await expect(openBrowserSecurely('ftp://example.com')).rejects.toThrow(
|
||||
'Unsafe protocol',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid URLs', async () => {
|
||||
await expect(openBrowserSecurely('not-a-url')).rejects.toThrow(
|
||||
'Invalid URL',
|
||||
);
|
||||
await expect(openBrowserSecurely('')).rejects.toThrow('Invalid URL');
|
||||
});
|
||||
|
||||
it('should reject URLs with control characters', async () => {
|
||||
await expect(
|
||||
openBrowserSecurely('http://example.com\nmalicious-command'),
|
||||
).rejects.toThrow('invalid characters');
|
||||
await expect(
|
||||
openBrowserSecurely('http://example.com\rmalicious-command'),
|
||||
).rejects.toThrow('invalid characters');
|
||||
await expect(
|
||||
openBrowserSecurely('http://example.com\x00'),
|
||||
).rejects.toThrow('invalid characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command injection prevention', () => {
|
||||
it('should prevent PowerShell command injection on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
|
||||
// The POC from the vulnerability report
|
||||
const maliciousUrl =
|
||||
"http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))";
|
||||
|
||||
await openBrowserSecurely(maliciousUrl);
|
||||
|
||||
// Verify that execFile was called (not exec) and the URL is passed safely
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-WindowStyle',
|
||||
'Hidden',
|
||||
'-Command',
|
||||
`Start-Process '${maliciousUrl.replace(/'/g, "''")}'`,
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle URLs with special shell characters safely', async () => {
|
||||
setPlatform('darwin');
|
||||
|
||||
const urlsWithSpecialChars = [
|
||||
'http://example.com/path?param=value&other=$value',
|
||||
'http://example.com/path#fragment;command',
|
||||
'http://example.com/$(whoami)',
|
||||
'http://example.com/`command`',
|
||||
'http://example.com/|pipe',
|
||||
'http://example.com/>redirect',
|
||||
];
|
||||
|
||||
for (const url of urlsWithSpecialChars) {
|
||||
await openBrowserSecurely(url);
|
||||
// Verify the URL is passed as an argument, not interpreted by shell
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
[url],
|
||||
expect.any(Object),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should properly escape single quotes in URLs on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
|
||||
const urlWithSingleQuotes =
|
||||
"http://example.com/path?name=O'Brien&test='value'";
|
||||
await openBrowserSecurely(urlWithSingleQuotes);
|
||||
|
||||
// Verify that single quotes are escaped by doubling them
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-WindowStyle',
|
||||
'Hidden',
|
||||
'-Command',
|
||||
`Start-Process 'http://example.com/path?name=O''Brien&test=''value'''`,
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Platform-specific behavior', () => {
|
||||
it('should use correct command on macOS', async () => {
|
||||
setPlatform('darwin');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use PowerShell on Windows', async () => {
|
||||
setPlatform('win32');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining([
|
||||
'-Command',
|
||||
`Start-Process 'https://example.com'`,
|
||||
]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use xdg-open on Linux', async () => {
|
||||
setPlatform('linux');
|
||||
await openBrowserSecurely('https://example.com');
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'xdg-open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on unsupported platforms', async () => {
|
||||
setPlatform('aix');
|
||||
await expect(openBrowserSecurely('https://example.com')).rejects.toThrow(
|
||||
'Unsupported platform',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle browser launch failures gracefully', async () => {
|
||||
setPlatform('darwin');
|
||||
mockExecFile.mockRejectedValueOnce(new Error('Command not found'));
|
||||
|
||||
await expect(openBrowserSecurely('https://example.com')).rejects.toThrow(
|
||||
'Failed to open browser',
|
||||
);
|
||||
});
|
||||
|
||||
it('should try fallback browsers on Linux', async () => {
|
||||
setPlatform('linux');
|
||||
|
||||
// First call to xdg-open fails
|
||||
mockExecFile.mockRejectedValueOnce(new Error('Command not found'));
|
||||
// Second call to gnome-open succeeds
|
||||
mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' });
|
||||
|
||||
await openBrowserSecurely('https://example.com');
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
expect(mockExecFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xdg-open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockExecFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'gnome-open',
|
||||
['https://example.com'],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
188
packages/core/src/utils/secure-browser-launcher.ts
Normal file
188
packages/core/src/utils/secure-browser-launcher.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { platform } from 'node:os';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Validates that a URL is safe to open in a browser.
|
||||
* Only allows HTTP and HTTPS URLs to prevent command injection.
|
||||
*
|
||||
* @param url The URL to validate
|
||||
* @throws Error if the URL is invalid or uses an unsafe protocol
|
||||
*/
|
||||
function validateUrl(url: string): void {
|
||||
let parsedUrl: URL;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch (_error) {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
// Only allow HTTP and HTTPS protocols
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
throw new Error(
|
||||
`Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation: ensure no newlines or control characters
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\r\n\x00-\x1f]/.test(url)) {
|
||||
throw new Error('URL contains invalid characters');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a URL in the default browser using platform-specific commands.
|
||||
* This implementation avoids shell injection vulnerabilities by:
|
||||
* 1. Validating the URL to ensure it's HTTP/HTTPS only
|
||||
* 2. Using execFile instead of exec to avoid shell interpretation
|
||||
* 3. Passing the URL as an argument rather than constructing a command string
|
||||
*
|
||||
* @param url The URL to open
|
||||
* @throws Error if the URL is invalid or if opening the browser fails
|
||||
*/
|
||||
export async function openBrowserSecurely(url: string): Promise<void> {
|
||||
// Validate the URL first
|
||||
validateUrl(url);
|
||||
|
||||
const platformName = platform();
|
||||
let command: string;
|
||||
let args: string[];
|
||||
|
||||
switch (platformName) {
|
||||
case 'darwin':
|
||||
// macOS
|
||||
command = 'open';
|
||||
args = [url];
|
||||
break;
|
||||
|
||||
case 'win32':
|
||||
// Windows - use PowerShell with Start-Process
|
||||
// This avoids the cmd.exe shell which is vulnerable to injection
|
||||
command = 'powershell.exe';
|
||||
args = [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-WindowStyle',
|
||||
'Hidden',
|
||||
'-Command',
|
||||
`Start-Process '${url.replace(/'/g, "''")}'`,
|
||||
];
|
||||
break;
|
||||
|
||||
case 'linux':
|
||||
case 'freebsd':
|
||||
case 'openbsd':
|
||||
// Linux and BSD variants
|
||||
// Try xdg-open first, fall back to other options
|
||||
command = 'xdg-open';
|
||||
args = [url];
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platformName}`);
|
||||
}
|
||||
|
||||
const options: Record<string, unknown> = {
|
||||
// Don't inherit parent's environment to avoid potential issues
|
||||
env: {
|
||||
...process.env,
|
||||
// Ensure we're not in a shell that might interpret special characters
|
||||
SHELL: undefined,
|
||||
},
|
||||
// Detach the browser process so it doesn't block
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
};
|
||||
|
||||
try {
|
||||
await execFileAsync(command, args, options);
|
||||
} catch (error) {
|
||||
// For Linux, try fallback commands if xdg-open fails
|
||||
if (
|
||||
(platformName === 'linux' ||
|
||||
platformName === 'freebsd' ||
|
||||
platformName === 'openbsd') &&
|
||||
command === 'xdg-open'
|
||||
) {
|
||||
const fallbackCommands = [
|
||||
'gnome-open',
|
||||
'kde-open',
|
||||
'firefox',
|
||||
'chromium',
|
||||
'google-chrome',
|
||||
];
|
||||
|
||||
for (const fallbackCommand of fallbackCommands) {
|
||||
try {
|
||||
await execFileAsync(fallbackCommand, [url], options);
|
||||
return; // Success!
|
||||
} catch {
|
||||
// Try next command
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw the error if all attempts failed
|
||||
throw new Error(
|
||||
`Failed to open browser: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current environment should attempt to launch a browser.
|
||||
* This is the same logic as in browser.ts for consistency.
|
||||
*
|
||||
* @returns True if the tool should attempt to launch a browser
|
||||
*/
|
||||
export function shouldLaunchBrowser(): boolean {
|
||||
// A list of browser names that indicate we should not attempt to open a
|
||||
// web browser for the user.
|
||||
const browserBlocklist = ['www-browser'];
|
||||
const browserEnv = process.env.BROWSER;
|
||||
if (browserEnv && browserBlocklist.includes(browserEnv)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Common environment variables used in CI/CD or other non-interactive shells.
|
||||
if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The presence of SSH_CONNECTION indicates a remote session.
|
||||
// We should not attempt to launch a browser unless a display is explicitly available
|
||||
// (checked below for Linux).
|
||||
const isSSH = !!process.env.SSH_CONNECTION;
|
||||
|
||||
// On Linux, the presence of a display server is a strong indicator of a GUI.
|
||||
if (platform() === 'linux') {
|
||||
// These are environment variables that can indicate a running compositor on Linux.
|
||||
const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'];
|
||||
const hasDisplay = displayVariables.some((v) => !!process.env[v]);
|
||||
if (!hasDisplay) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser.
|
||||
// The Linux case is handled above (it's allowed if DISPLAY is set).
|
||||
if (isSSH && platform() !== 'linux') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For non-Linux OSes, we generally assume a GUI is available
|
||||
// unless other signals (like SSH) suggest otherwise.
|
||||
return true;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js';
|
||||
import { getResponseText, partToString } from './partUtils.js';
|
||||
|
||||
/**
|
||||
@@ -86,7 +86,7 @@ export async function summarizeToolOutput(
|
||||
contents,
|
||||
toolOutputSummarizerConfig,
|
||||
abortSignal,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
)) as unknown as GenerateContentResponse;
|
||||
return getResponseText(parsedResponse) || textToSummarize;
|
||||
} catch (error) {
|
||||
|
||||
283
packages/core/src/utils/workspaceContext.test.ts
Normal file
283
packages/core/src/utils/workspaceContext.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { WorkspaceContext } from './workspaceContext.js';
|
||||
|
||||
vi.mock('fs');
|
||||
|
||||
describe('WorkspaceContext', () => {
|
||||
let workspaceContext: WorkspaceContext;
|
||||
// Use path module to create platform-agnostic paths
|
||||
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
|
||||
const mockExistingDir = path.resolve(
|
||||
path.sep,
|
||||
'home',
|
||||
'user',
|
||||
'other-project',
|
||||
);
|
||||
const mockNonExistentDir = path.resolve(
|
||||
path.sep,
|
||||
'home',
|
||||
'user',
|
||||
'does-not-exist',
|
||||
);
|
||||
const mockSymlinkDir = path.resolve(path.sep, 'home', 'user', 'symlink');
|
||||
const mockRealPath = path.resolve(path.sep, 'home', 'user', 'real-directory');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Mock fs.existsSync
|
||||
vi.mocked(fs.existsSync).mockImplementation((path) => {
|
||||
const pathStr = path.toString();
|
||||
return (
|
||||
pathStr === mockCwd ||
|
||||
pathStr === mockExistingDir ||
|
||||
pathStr === mockSymlinkDir ||
|
||||
pathStr === mockRealPath
|
||||
);
|
||||
});
|
||||
|
||||
// Mock fs.statSync
|
||||
vi.mocked(fs.statSync).mockImplementation((path) => {
|
||||
const pathStr = path.toString();
|
||||
if (pathStr === mockNonExistentDir) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
} as fs.Stats;
|
||||
});
|
||||
|
||||
// Mock fs.realpathSync
|
||||
vi.mocked(fs.realpathSync).mockImplementation((path) => {
|
||||
const pathStr = path.toString();
|
||||
if (pathStr === mockSymlinkDir) {
|
||||
return mockRealPath;
|
||||
}
|
||||
return pathStr;
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with a single directory (cwd)', () => {
|
||||
workspaceContext = new WorkspaceContext(mockCwd);
|
||||
const directories = workspaceContext.getDirectories();
|
||||
expect(directories).toHaveLength(1);
|
||||
expect(directories[0]).toBe(mockCwd);
|
||||
});
|
||||
|
||||
it('should validate and resolve directories to absolute paths', () => {
|
||||
const absolutePath = path.join(mockCwd, 'subdir');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p === mockCwd || p === absolutePath,
|
||||
);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
|
||||
|
||||
workspaceContext = new WorkspaceContext(mockCwd, [absolutePath]);
|
||||
const directories = workspaceContext.getDirectories();
|
||||
expect(directories).toContain(absolutePath);
|
||||
});
|
||||
|
||||
it('should reject non-existent directories', () => {
|
||||
expect(() => {
|
||||
new WorkspaceContext(mockCwd, [mockNonExistentDir]);
|
||||
}).toThrow('Directory does not exist');
|
||||
});
|
||||
|
||||
it('should handle empty initialization', () => {
|
||||
workspaceContext = new WorkspaceContext(mockCwd, []);
|
||||
const directories = workspaceContext.getDirectories();
|
||||
expect(directories).toHaveLength(1);
|
||||
expect(directories[0]).toBe(mockCwd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding directories', () => {
|
||||
beforeEach(() => {
|
||||
workspaceContext = new WorkspaceContext(mockCwd);
|
||||
});
|
||||
|
||||
it('should add valid directories', () => {
|
||||
workspaceContext.addDirectory(mockExistingDir);
|
||||
const directories = workspaceContext.getDirectories();
|
||||
expect(directories).toHaveLength(2);
|
||||
expect(directories).toContain(mockExistingDir);
|
||||
});
|
||||
|
||||
it('should resolve relative paths to absolute', () => {
|
||||
// Since we can't mock path.resolve, we'll test with absolute paths
|
||||
workspaceContext.addDirectory(mockExistingDir);
|
||||
const directories = workspaceContext.getDirectories();
|
||||
expect(directories).toContain(mockExistingDir);
|
||||
});
|
||||
|
||||
it('should reject non-existent directories', () => {
|
||||
expect(() => {
|
||||
workspaceContext.addDirectory(mockNonExistentDir);
|
||||
}).toThrow('Directory does not exist');
|
||||
});
|
||||
|
||||
it('should prevent duplicate directories', () => {
|
||||
workspaceContext.addDirectory(mockExistingDir);
|
||||
workspaceContext.addDirectory(mockExistingDir);
|
||||
const directories = workspaceContext.getDirectories();
|
||||
expect(directories.filter((d) => d === mockExistingDir)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle symbolic links correctly', () => {
|
||||
workspaceContext.addDirectory(mockSymlinkDir);
|
||||
const directories = workspaceContext.getDirectories();
|
||||
expect(directories).toContain(mockRealPath);
|
||||
expect(directories).not.toContain(mockSymlinkDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('path validation', () => {
|
||||
beforeEach(() => {
|
||||
workspaceContext = new WorkspaceContext(mockCwd, [mockExistingDir]);
|
||||
});
|
||||
|
||||
it('should accept paths within workspace directories', () => {
|
||||
const validPath1 = path.join(mockCwd, 'src', 'file.ts');
|
||||
const validPath2 = path.join(mockExistingDir, 'lib', 'module.js');
|
||||
|
||||
expect(workspaceContext.isPathWithinWorkspace(validPath1)).toBe(true);
|
||||
expect(workspaceContext.isPathWithinWorkspace(validPath2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject paths outside workspace', () => {
|
||||
const invalidPath = path.resolve(
|
||||
path.dirname(mockCwd),
|
||||
'outside-workspace',
|
||||
'file.txt',
|
||||
);
|
||||
expect(workspaceContext.isPathWithinWorkspace(invalidPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should resolve symbolic links before validation', () => {
|
||||
const symlinkPath = path.join(mockCwd, 'symlink-file');
|
||||
const realPath = path.join(mockCwd, 'real-file');
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||
if (p === symlinkPath) {
|
||||
return realPath;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
|
||||
expect(workspaceContext.isPathWithinWorkspace(symlinkPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nested directories correctly', () => {
|
||||
const nestedPath = path.join(
|
||||
mockCwd,
|
||||
'deeply',
|
||||
'nested',
|
||||
'path',
|
||||
'file.txt',
|
||||
);
|
||||
expect(workspaceContext.isPathWithinWorkspace(nestedPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle edge cases (root, parent references)', () => {
|
||||
const rootPath = '/';
|
||||
const parentPath = path.dirname(mockCwd);
|
||||
|
||||
expect(workspaceContext.isPathWithinWorkspace(rootPath)).toBe(false);
|
||||
expect(workspaceContext.isPathWithinWorkspace(parentPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle non-existent paths correctly', () => {
|
||||
const nonExistentPath = path.join(mockCwd, 'does-not-exist.txt');
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p !== nonExistentPath);
|
||||
|
||||
// Should still validate based on path structure
|
||||
expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDirectories', () => {
|
||||
it('should return a copy of directories array', () => {
|
||||
workspaceContext = new WorkspaceContext(mockCwd);
|
||||
const dirs1 = workspaceContext.getDirectories();
|
||||
const dirs2 = workspaceContext.getDirectories();
|
||||
|
||||
expect(dirs1).not.toBe(dirs2); // Different array instances
|
||||
expect(dirs1).toEqual(dirs2); // Same content
|
||||
});
|
||||
});
|
||||
|
||||
describe('symbolic link security', () => {
|
||||
beforeEach(() => {
|
||||
workspaceContext = new WorkspaceContext(mockCwd);
|
||||
});
|
||||
|
||||
it('should follow symlinks but validate resolved path', () => {
|
||||
const symlinkInsideWorkspace = path.join(mockCwd, 'link-to-subdir');
|
||||
const resolvedInsideWorkspace = path.join(mockCwd, 'subdir');
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||
if (p === symlinkInsideWorkspace) {
|
||||
return resolvedInsideWorkspace;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
|
||||
expect(
|
||||
workspaceContext.isPathWithinWorkspace(symlinkInsideWorkspace),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should prevent sandbox escape via symlinks', () => {
|
||||
const symlinkEscape = path.join(mockCwd, 'escape-link');
|
||||
const resolvedOutside = path.resolve(mockCwd, '..', 'outside-file');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const pathStr = p.toString();
|
||||
return (
|
||||
pathStr === symlinkEscape ||
|
||||
pathStr === resolvedOutside ||
|
||||
pathStr === mockCwd
|
||||
);
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => {
|
||||
if (p.toString() === symlinkEscape) {
|
||||
return resolvedOutside;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
vi.mocked(fs.statSync).mockImplementation(
|
||||
(p) =>
|
||||
({
|
||||
isDirectory: () => p.toString() !== resolvedOutside,
|
||||
}) as fs.Stats,
|
||||
);
|
||||
|
||||
workspaceContext = new WorkspaceContext(mockCwd);
|
||||
expect(workspaceContext.isPathWithinWorkspace(symlinkEscape)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle circular symlinks', () => {
|
||||
const circularLink = path.join(mockCwd, 'circular');
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.realpathSync).mockImplementation(() => {
|
||||
throw new Error('ELOOP: too many symbolic links encountered');
|
||||
});
|
||||
|
||||
// Should handle the error gracefully
|
||||
expect(workspaceContext.isPathWithinWorkspace(circularLink)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
packages/core/src/utils/workspaceContext.ts
Normal file
127
packages/core/src/utils/workspaceContext.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* WorkspaceContext manages multiple workspace directories and validates paths
|
||||
* against them. This allows the CLI to operate on files from multiple directories
|
||||
* in a single session.
|
||||
*/
|
||||
export class WorkspaceContext {
|
||||
private directories: Set<string>;
|
||||
|
||||
/**
|
||||
* Creates a new WorkspaceContext with the given initial directory and optional additional directories.
|
||||
* @param initialDirectory The initial working directory (usually cwd)
|
||||
* @param additionalDirectories Optional array of additional directories to include
|
||||
*/
|
||||
constructor(initialDirectory: string, additionalDirectories: string[] = []) {
|
||||
this.directories = new Set<string>();
|
||||
|
||||
this.addDirectoryInternal(initialDirectory);
|
||||
|
||||
for (const dir of additionalDirectories) {
|
||||
this.addDirectoryInternal(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a directory to the workspace.
|
||||
* @param directory The directory path to add (can be relative or absolute)
|
||||
* @param basePath Optional base path for resolving relative paths (defaults to cwd)
|
||||
*/
|
||||
addDirectory(directory: string, basePath: string = process.cwd()): void {
|
||||
this.addDirectoryInternal(directory, basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to add a directory with validation.
|
||||
*/
|
||||
private addDirectoryInternal(
|
||||
directory: string,
|
||||
basePath: string = process.cwd(),
|
||||
): void {
|
||||
const absolutePath = path.isAbsolute(directory)
|
||||
? directory
|
||||
: path.resolve(basePath, directory);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new Error(`Directory does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(absolutePath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${absolutePath}`);
|
||||
}
|
||||
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = fs.realpathSync(absolutePath);
|
||||
} catch (_error) {
|
||||
throw new Error(`Failed to resolve path: ${absolutePath}`);
|
||||
}
|
||||
|
||||
this.directories.add(realPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a copy of all workspace directories.
|
||||
* @returns Array of absolute directory paths
|
||||
*/
|
||||
getDirectories(): readonly string[] {
|
||||
return Array.from(this.directories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given path is within any of the workspace directories.
|
||||
* @param pathToCheck The path to validate
|
||||
* @returns True if the path is within the workspace, false otherwise
|
||||
*/
|
||||
isPathWithinWorkspace(pathToCheck: string): boolean {
|
||||
try {
|
||||
const absolutePath = path.resolve(pathToCheck);
|
||||
|
||||
let resolvedPath = absolutePath;
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
try {
|
||||
resolvedPath = fs.realpathSync(absolutePath);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of this.directories) {
|
||||
if (this.isPathWithinRoot(resolvedPath, dir)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within a given root directory.
|
||||
* @param pathToCheck The absolute path to check
|
||||
* @param rootDirectory The absolute root directory
|
||||
* @returns True if the path is within the root directory, false otherwise
|
||||
*/
|
||||
private isPathWithinRoot(
|
||||
pathToCheck: string,
|
||||
rootDirectory: string,
|
||||
): boolean {
|
||||
const relative = path.relative(rootDirectory, pathToCheck);
|
||||
return (
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== '..' &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user