merge: pull main into channels and resolve conflicts

This commit is contained in:
Jack Wotherspoon
2026-03-27 13:54:41 -04:00
50 changed files with 2050 additions and 1218 deletions
+102 -10
View File
@@ -69,6 +69,10 @@ import {
type FunctionDeclaration,
} from '@google/genai';
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { GeminiClient } from '../core/client.js';
import type { SandboxManager } from '../services/sandboxManager.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { getDirectoryContextString } from '../utils/environmentContext.js';
import { z } from 'zod';
@@ -377,10 +381,8 @@ describe('LocalAgentExecutor', () => {
describe('create (Initialization and Validation)', () => {
it('should explicitly map execution context properties to prevent unintended propagation', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const mockGeminiClient =
{} as unknown as import('../core/client.js').GeminiClient;
const mockSandboxManager =
{} as unknown as import('../services/sandboxManager.js').SandboxManager;
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const extendedContext = {
config: mockConfig,
promptId: mockConfig.promptId,
@@ -391,7 +393,7 @@ describe('LocalAgentExecutor', () => {
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
unintendedProperty: 'should not be here',
} as unknown as import('../config/agent-loop-context.js').AgentLoopContext;
} as unknown as AgentLoopContext;
const executor = await LocalAgentExecutor.create(
definition,
@@ -414,7 +416,7 @@ describe('LocalAgentExecutor', () => {
expect(executionContext).toBeDefined();
expect(executionContext.config).toBe(extendedContext.config);
expect(executionContext.promptId).toBe(extendedContext.promptId);
expect(executionContext.promptId).toBeDefined();
expect(executionContext.geminiClient).toBe(extendedContext.geminiClient);
expect(executionContext.sandboxManager).toBe(
extendedContext.sandboxManager,
@@ -445,7 +447,99 @@ describe('LocalAgentExecutor', () => {
expect(executionContext.messageBus).not.toBe(extendedContext.messageBus);
});
it('should create successfully with allowed tools', async () => {
it('should propagate parentSessionId from context when creating executionContext', async () => {
const parentSessionId = 'top-level-session-id';
const currentPromptId = 'subagent-a-id';
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const mockMessageBus = {
derive: () => ({}),
} as unknown as MessageBus;
const mockToolRegistry = {
getMessageBus: () => mockMessageBus,
getAllToolNames: () => [],
sortTools: () => {},
} as unknown as ToolRegistry;
const context = {
config: mockConfig,
promptId: currentPromptId,
parentSessionId,
toolRegistry: mockToolRegistry,
promptRegistry: {} as unknown as PromptRegistry,
resourceRegistry: {} as unknown as ResourceRegistry,
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext;
const definition = createTestDefinition([]);
const executor = await LocalAgentExecutor.create(definition, context);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
const chatConstructorArgs =
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
const executionContext = chatConstructorArgs[0];
expect(executionContext.parentSessionId).toBe(parentSessionId);
expect(executionContext.promptId).toBe(executor['agentId']);
});
it('should fall back to promptId if parentSessionId is missing (top-level subagent)', async () => {
const rootSessionId = 'root-session-id';
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const mockMessageBus = {
derive: () => ({}),
} as unknown as MessageBus;
const mockToolRegistry = {
getMessageBus: () => mockMessageBus,
getAllToolNames: () => [],
sortTools: () => {},
} as unknown as ToolRegistry;
const context = {
config: mockConfig,
promptId: rootSessionId,
// parentSessionId is undefined
toolRegistry: mockToolRegistry,
promptRegistry: {} as unknown as PromptRegistry,
resourceRegistry: {} as unknown as ResourceRegistry,
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext;
const definition = createTestDefinition([]);
const executor = await LocalAgentExecutor.create(definition, context);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
const chatConstructorArgs =
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
const executionContext = chatConstructorArgs[0];
expect(executionContext.parentSessionId).toBe(rootSessionId);
expect(executionContext.promptId).toBe(executor['agentId']);
});
it('should successfully with allowed tools', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await LocalAgentExecutor.create(
definition,
@@ -500,9 +594,7 @@ describe('LocalAgentExecutor', () => {
onActivity,
);
expect(executor['agentId']).toMatch(
new RegExp(`^${parentId}-${definition.name}-`),
);
expect(executor['agentId']).toBeDefined();
});
it('should correctly apply templates to initialMessages', async () => {
+3 -11
View File
@@ -121,7 +121,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private get executionContext(): AgentLoopContext {
return {
config: this.context.config,
promptId: this.context.promptId,
promptId: this.agentId,
parentSessionId: this.context.parentSessionId || this.context.promptId, // Always preserve the main agent session ID
geminiClient: this.context.geminiClient,
sandboxManager: this.context.sandboxManager,
toolRegistry: this.toolRegistry,
@@ -255,9 +256,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
agentToolRegistry.sortTools();
// Get the parent prompt ID from context
const parentPromptId = context.promptId;
// Get the parent tool call ID from context
const toolContext = getToolCallContext();
const parentCallId = toolContext?.callId;
@@ -265,7 +263,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return new LocalAgentExecutor(
definition,
context,
parentPromptId,
agentToolRegistry,
agentPromptRegistry,
agentResourceRegistry,
@@ -283,7 +280,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private constructor(
definition: LocalAgentDefinition<TOutput>,
context: AgentLoopContext,
parentPromptId: string | undefined,
toolRegistry: ToolRegistry,
promptRegistry: PromptRegistry,
resourceRegistry: ResourceRegistry,
@@ -299,11 +295,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.compressionService = new ChatCompressionService();
this.parentCallId = parentCallId;
const randomIdPart = Math.random().toString(36).slice(2, 8);
// parentPromptId will be undefined if this agent is invoked directly
// (top-level), rather than as a sub-agent.
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
this.agentId = `${parentPrefix}${this.definition.name}-${randomIdPart}`;
this.agentId = Math.random().toString(36).slice(2, 8);
}
/**
+3
View File
@@ -32,6 +32,7 @@ export class ProjectIdRequiredError extends Error {
super(
'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
);
this.name = 'ProjectIdRequiredError';
}
}
@@ -42,6 +43,7 @@ export class ProjectIdRequiredError extends Error {
export class ValidationCancelledError extends Error {
constructor() {
super('User cancelled account validation');
this.name = 'ValidationCancelledError';
}
}
@@ -51,6 +53,7 @@ export class IneligibleTierError extends Error {
constructor(ineligibleTiers: IneligibleTier[]) {
const reasons = ineligibleTiers.map((t) => t.reasonMessage).join(', ');
super(reasons);
this.name = 'IneligibleTierError';
this.ineligibleTiers = ineligibleTiers;
}
}
@@ -23,6 +23,9 @@ export interface AgentLoopContext {
/** The unique ID for the current user turn or agent thought loop. */
readonly promptId: string;
/** The unique ID for the parent session if this is a subagent. */
readonly parentSessionId?: string;
/** The registry of tools available to the agent in this context. */
readonly toolRegistry: ToolRegistry;
+11 -5
View File
@@ -947,7 +947,9 @@ export class Config implements McpContext, AgentLoopContext {
networkAccess: false,
};
this._sandboxManager = createSandboxManager(this.sandbox, params.targetDir);
this._sandboxManager = createSandboxManager(this.sandbox, {
workspace: params.targetDir,
});
if (
!(this._sandboxManager instanceof NoopSandboxManager) &&
@@ -968,8 +970,10 @@ export class Config implements McpContext, AgentLoopContext {
'default';
this._sandboxManager = createSandboxManager(
this.sandbox,
params.targetDir,
this._sandboxPolicyManager,
{
workspace: params.targetDir,
policyManager: this._sandboxPolicyManager,
},
initialApprovalMode,
);
@@ -1642,8 +1646,10 @@ export class Config implements McpContext, AgentLoopContext {
private refreshSandboxManager(): void {
this._sandboxManager = createSandboxManager(
this.sandbox,
this.targetDir,
this._sandboxPolicyManager,
{
workspace: this.targetDir,
policyManager: this._sandboxPolicyManager,
},
this.getApprovalMode(),
);
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
@@ -10,7 +10,6 @@ import {
AuthType,
createContentGeneratorConfig,
type ContentGenerator,
validateBaseUrl,
} from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai';
@@ -605,122 +604,6 @@ describe('createContentGenerator', () => {
);
});
it('should pass GOOGLE_GEMINI_BASE_URL as httpOptions.baseUrl for Gemini API', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://my-gemini-proxy.example.com');
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'https://my-gemini-proxy.example.com',
}),
}),
);
});
it('should pass GOOGLE_VERTEX_BASE_URL as httpOptions.baseUrl for Vertex AI', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://my-vertex-proxy.example.com');
await createContentGenerator(
{
apiKey: 'test-api-key',
vertexai: true,
authType: AuthType.USE_VERTEX_AI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'https://my-vertex-proxy.example.com',
}),
}),
);
});
it('should not include baseUrl in httpOptions when GOOGLE_GEMINI_BASE_URL is not set', async () => {
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', '');
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.not.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: expect.any(String),
}),
}),
);
});
it('should reject an insecure GOOGLE_GEMINI_BASE_URL for non-local hosts', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
getClientName: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'http://evil-proxy.example.com');
await expect(
createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfig,
),
).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
});
it('should pass apiVersion for Vertex AI when GOOGLE_GENAI_API_VERSION is set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
@@ -861,33 +744,3 @@ describe('createContentGeneratorConfig', () => {
expect(config.vertexai).toBe(false);
});
});
describe('validateBaseUrl', () => {
it('should accept a valid HTTPS URL', () => {
expect(() => validateBaseUrl('https://my-proxy.example.com')).not.toThrow();
});
it('should accept HTTP for localhost', () => {
expect(() => validateBaseUrl('http://localhost:8080')).not.toThrow();
});
it('should accept HTTP for 127.0.0.1', () => {
expect(() => validateBaseUrl('http://127.0.0.1:3000')).not.toThrow();
});
it('should accept HTTP for ::1', () => {
expect(() => validateBaseUrl('http://[::1]:8080')).not.toThrow();
});
it('should reject HTTP for non-local hosts', () => {
expect(() => validateBaseUrl('http://my-proxy.example.com')).toThrow(
'Custom base URL must use HTTPS unless it is localhost.',
);
});
it('should reject an invalid URL', () => {
expect(() => validateBaseUrl('not-a-url')).toThrow(
'Invalid custom base URL: not-a-url',
);
});
});
+2 -28
View File
@@ -273,25 +273,13 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
let baseUrl = config.baseUrl;
if (!baseUrl) {
const envBaseUrl = config.vertexai
? process.env['GOOGLE_VERTEX_BASE_URL']
: process.env['GOOGLE_GEMINI_BASE_URL'];
if (envBaseUrl) {
validateBaseUrl(envBaseUrl);
baseUrl = envBaseUrl;
}
} else {
validateBaseUrl(baseUrl);
}
const httpOptions: {
baseUrl?: string;
headers: Record<string, string>;
} = { headers };
if (baseUrl) {
httpOptions.baseUrl = baseUrl;
if (config.baseUrl) {
httpOptions.baseUrl = config.baseUrl;
}
const googleGenAI = new GoogleGenAI({
@@ -313,17 +301,3 @@ export async function createContentGenerator(
return generator;
}
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
export function validateBaseUrl(baseUrl: string): void {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
throw new Error(`Invalid custom base URL: ${baseUrl}`);
}
if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
throw new Error('Custom base URL must use HTTPS unless it is localhost.');
}
}
+2
View File
@@ -46,6 +46,7 @@ export * from './core/geminiRequest.js';
export * from './scheduler/scheduler.js';
export * from './scheduler/types.js';
export * from './scheduler/tool-executor.js';
export * from './scheduler/policy.js';
export * from './core/recordingContentGenerator.js';
export * from './fallback/types.js';
@@ -83,6 +84,7 @@ export * from './utils/authConsent.js';
export * from './utils/googleQuotaErrors.js';
export * from './utils/googleErrors.js';
export * from './utils/fileUtils.js';
export * from './utils/sessionOperations.js';
export * from './utils/planUtils.js';
export * from './utils/approvalModeUtils.js';
export * from './utils/fileDiffUtils.js';
@@ -362,16 +362,21 @@ describe('LinuxSandboxManager', () => {
});
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
},
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/cache', '/opt/secret.txt'],
});
const bwrapArgs = await getBwrapArgs(
{
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
},
customManager,
);
const cacheIndex = bwrapArgs.indexOf('/tmp/cache');
expect(bwrapArgs[cacheIndex - 1]).toBe('--tmpfs');
@@ -389,16 +394,21 @@ describe('LinuxSandboxManager', () => {
return p.toString();
});
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/forbidden-symlink'],
},
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/forbidden-symlink'],
});
const bwrapArgs = await getBwrapArgs(
{
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
},
customManager,
);
const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt');
expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind');
expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null');
@@ -412,16 +422,21 @@ describe('LinuxSandboxManager', () => {
});
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/not-here.txt'],
},
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/not-here.txt'],
});
const bwrapArgs = await getBwrapArgs(
{
command: 'ls',
args: [],
cwd: workspace,
env: {},
},
customManager,
);
const idx = bwrapArgs.indexOf('/tmp/not-here.txt');
expect(bwrapArgs[idx - 2]).toBe('--symlink');
expect(bwrapArgs[idx - 1]).toBe('/dev/null');
@@ -436,16 +451,21 @@ describe('LinuxSandboxManager', () => {
return p.toString();
});
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
policy: {
forbiddenPaths: ['/tmp/dir-link'],
},
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/dir-link'],
});
const bwrapArgs = await getBwrapArgs(
{
command: 'ls',
args: [],
cwd: workspace,
env: {},
},
customManager,
);
const idx = bwrapArgs.indexOf('/opt/real-dir');
expect(bwrapArgs[idx - 1]).toBe('--tmpfs');
});
@@ -456,17 +476,24 @@ describe('LinuxSandboxManager', () => {
);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
},
const customManager = new LinuxSandboxManager({
workspace,
forbiddenPaths: ['/tmp/conflict'],
});
const bwrapArgs = await getBwrapArgs(
{
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: ['/tmp/conflict'],
},
},
customManager,
);
const bindTryIdx = bwrapArgs.indexOf('--bind-try');
const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs');
@@ -25,7 +25,6 @@ import {
} from '../../services/environmentSanitization.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { spawnAsync } from '../../utils/shell-utils.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import {
isStrictlyApproved,
verifySandboxOverrides,
@@ -134,20 +133,10 @@ function touch(filePath: string, isDirectory: boolean) {
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
export interface LinuxSandboxOptions extends GlobalSandboxOptions {
modeConfig?: {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
};
policyManager?: SandboxPolicyManager;
}
export class LinuxSandboxManager implements SandboxManager {
private static maskFilePath: string | undefined;
constructor(private readonly options: LinuxSandboxOptions) {}
constructor(private readonly options: GlobalSandboxOptions) {}
isKnownSafeCommand(args: string[]): boolean {
return isKnownSafeCommand(args);
@@ -333,7 +322,7 @@ export class LinuxSandboxManager implements SandboxManager {
}
}
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || [];
for (const p of forbiddenPaths) {
let resolved: string;
try {
@@ -35,7 +35,10 @@ describe('MacOsSandboxManager', () => {
networkAccess: mockNetworkAccess,
};
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
manager = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: [],
});
// Mock the seatbelt args builder to isolate manager tests
vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockReturnValue([
@@ -68,7 +71,7 @@ describe('MacOsSandboxManager', () => {
workspace: mockWorkspace,
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
forbiddenPaths: undefined,
forbiddenPaths: [],
workspaceWrite: true,
additionalPermissions: {
fileSystem: {
@@ -177,15 +180,16 @@ describe('MacOsSandboxManager', () => {
describe('forbiddenPaths', () => {
it('should parameterize forbidden paths and explicitly deny them', async () => {
await manager.prepareCommand({
const managerWithForbidden = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/forbidden1'],
});
await managerWithForbidden.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
forbiddenPaths: ['/tmp/forbidden1'],
},
policy: mockPolicy,
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
@@ -196,15 +200,16 @@ describe('MacOsSandboxManager', () => {
});
it('explicitly denies non-existent forbidden paths to prevent creation', async () => {
await manager.prepareCommand({
const managerWithForbidden = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/does-not-exist'],
});
await managerWithForbidden.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
env: {},
policy: {
...mockPolicy,
forbiddenPaths: ['/tmp/does-not-exist'],
},
policy: mockPolicy,
});
expect(seatbeltArgsBuilder.buildSeatbeltArgs).toHaveBeenCalledWith(
@@ -215,7 +220,11 @@ describe('MacOsSandboxManager', () => {
});
it('should override allowed paths if a path is also in forbidden paths', async () => {
await manager.prepareCommand({
const managerWithForbidden = new MacOsSandboxManager({
workspace: mockWorkspace,
forbiddenPaths: ['/tmp/conflict'],
});
await managerWithForbidden.prepareCommand({
command: 'echo',
args: [],
cwd: mockWorkspace,
@@ -223,7 +232,6 @@ describe('MacOsSandboxManager', () => {
policy: {
...mockPolicy,
allowedPaths: ['/tmp/conflict'],
forbiddenPaths: ['/tmp/conflict'],
},
});
@@ -27,27 +27,14 @@ import {
isDangerousCommand,
isStrictlyApproved,
} from '../utils/commandSafety.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
export interface MacOsSandboxOptions extends GlobalSandboxOptions {
/** The current sandbox mode behavior from config. */
modeConfig?: {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
};
/** The policy manager for persistent approvals. */
policyManager?: SandboxPolicyManager;
}
/**
* A SandboxManager implementation for macOS that uses Seatbelt.
*/
export class MacOsSandboxManager implements SandboxManager {
constructor(private readonly options: MacOsSandboxOptions) {}
constructor(private readonly options: GlobalSandboxOptions) {}
isKnownSafeCommand(args: string[]): boolean {
const toolName = args[0];
@@ -121,7 +108,7 @@ export class MacOsSandboxManager implements SandboxManager {
const sandboxArgs = buildSeatbeltArgs({
workspace: this.options.workspace,
allowedPaths: [...(req.policy?.allowedPaths || [])],
forbiddenPaths: req.policy?.forbiddenPaths,
forbiddenPaths: this.options.forbiddenPaths,
networkAccess: mergedAdditional.network,
workspaceWrite,
additionalPermissions: mergedAdditional,
@@ -38,6 +38,7 @@ describe('WindowsSandboxManager', () => {
manager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { readonly: false, allowOverrides: true },
forbiddenPaths: [],
});
});
@@ -106,6 +107,7 @@ describe('WindowsSandboxManager', () => {
const planManager = new WindowsSandboxManager({
workspace: testCwd,
modeConfig: { readonly: true, allowOverrides: false },
forbiddenPaths: [],
});
const req: SandboxRequest = {
command: 'curl',
@@ -135,6 +137,7 @@ describe('WindowsSandboxManager', () => {
workspace: testCwd,
modeConfig: { allowOverrides: true, network: false },
policyManager: mockPolicyManager,
forbiddenPaths: [],
});
const req: SandboxRequest = {
@@ -362,17 +365,19 @@ describe('WindowsSandboxManager', () => {
fs.rmSync(missingPath, { recursive: true, force: true });
}
const managerWithForbidden = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: [missingPath],
});
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [missingPath],
},
};
await manager.prepareCommand(req);
await managerWithForbidden.prepareCommand(req);
// Should NOT have called icacls to deny the missing path
expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [
@@ -388,17 +393,19 @@ describe('WindowsSandboxManager', () => {
fs.mkdirSync(forbiddenPath);
}
try {
const managerWithForbidden = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: [forbiddenPath],
});
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
forbiddenPaths: [forbiddenPath],
},
};
await manager.prepareCommand(req);
await managerWithForbidden.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(forbiddenPath),
@@ -416,6 +423,11 @@ describe('WindowsSandboxManager', () => {
fs.mkdirSync(conflictPath);
}
try {
const managerWithForbidden = new WindowsSandboxManager({
workspace: testCwd,
forbiddenPaths: [conflictPath],
});
const req: SandboxRequest = {
command: 'test',
args: [],
@@ -423,11 +435,10 @@ describe('WindowsSandboxManager', () => {
env: {},
policy: {
allowedPaths: [conflictPath],
forbiddenPaths: [conflictPath],
},
};
await manager.prepareCommand(req);
await managerWithForbidden.prepareCommand(req);
const spawnMock = vi.mocked(spawnAsync);
const allowCallIndex = spawnMock.mock.calls.findIndex(
@@ -33,24 +33,11 @@ import {
isDangerousCommand,
isStrictlyApproved,
} from './commandSafety.js';
import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export interface WindowsSandboxOptions extends GlobalSandboxOptions {
/** The current sandbox mode behavior from config. */
modeConfig?: {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
};
/** The policy manager for persistent approvals. */
policyManager?: SandboxPolicyManager;
}
/**
* A SandboxManager implementation for Windows that uses Restricted Tokens,
* Job Objects, and Low Integrity levels for process isolation.
@@ -62,7 +49,7 @@ export class WindowsSandboxManager implements SandboxManager {
private readonly allowedCache = new Set<string>();
private readonly deniedCache = new Set<string>();
constructor(private readonly options: WindowsSandboxOptions) {
constructor(private readonly options: GlobalSandboxOptions) {
this.helperPath = path.resolve(__dirname, 'GeminiSandbox.exe');
}
@@ -309,7 +296,7 @@ export class WindowsSandboxManager implements SandboxManager {
// is restricted to avoid host corruption. External commands rely on
// Low Integrity read/write restrictions, while internal commands
// use the manifest for enforcement.
const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || [];
const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || [];
for (const forbiddenPath of forbiddenPaths) {
try {
await this.denyLowIntegrityAccess(forbiddenPath);
@@ -108,6 +108,30 @@ describe('ChatRecordingService', () => {
expect(conversation.kind).toBe('subagent');
});
it('should create a subdirectory for subagents if parentSessionId is present', () => {
const parentSessionId = 'test-parent-uuid';
Object.defineProperty(mockConfig, 'parentSessionId', {
value: parentSessionId,
writable: true,
configurable: true,
});
chatRecordingService.initialize(undefined, 'subagent');
chatRecordingService.recordMessage({
type: 'user',
content: 'ping',
model: 'm',
});
const chatsDir = path.join(testTempDir, 'chats');
const subagentDir = path.join(chatsDir, parentSessionId);
expect(fs.existsSync(subagentDir)).toBe(true);
const files = fs.readdirSync(subagentDir);
expect(files.length).toBeGreaterThan(0);
expect(files[0]).toBe('test-session-id.json');
});
it('should resume from an existing session if provided', () => {
const chatsDir = path.join(testTempDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
@@ -437,7 +461,7 @@ describe('ChatRecordingService', () => {
});
describe('deleteSession', () => {
it('should delete the session file, tool outputs, session directory, and logs if they exist', () => {
it('should delete the session file, tool outputs, session directory, and logs if they exist', async () => {
const sessionId = 'test-session-id';
const shortId = '12345678';
const chatsDir = path.join(testTempDir, 'chats');
@@ -464,7 +488,7 @@ describe('ChatRecordingService', () => {
fs.mkdirSync(toolOutputDir, { recursive: true });
// Call with shortId
chatRecordingService.deleteSession(shortId);
await chatRecordingService.deleteSession(shortId);
expect(fs.existsSync(sessionFile)).toBe(false);
expect(fs.existsSync(logFile)).toBe(false);
@@ -472,7 +496,7 @@ describe('ChatRecordingService', () => {
expect(fs.existsSync(sessionDir)).toBe(false);
});
it('should delete subagent files and their logs when parent is deleted', () => {
it('should delete subagent files and their logs when parent is deleted', async () => {
const parentSessionId = '12345678-session-id';
const shortId = '12345678';
const subagentSessionId = 'subagent-session-id';
@@ -494,11 +518,10 @@ describe('ChatRecordingService', () => {
JSON.stringify({ sessionId: parentSessionId }),
);
// Create subagent session file
const subagentFile = path.join(
chatsDir,
`session-2023-01-01T00-01-${shortId}.json`,
);
// Create subagent session file in subdirectory
const subagentDir = path.join(chatsDir, parentSessionId);
fs.mkdirSync(subagentDir, { recursive: true });
const subagentFile = path.join(subagentDir, `${subagentSessionId}.json`);
fs.writeFileSync(
subagentFile,
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }),
@@ -526,17 +549,55 @@ describe('ChatRecordingService', () => {
fs.mkdirSync(subagentToolOutputDir, { recursive: true });
// Call with parent sessionId
chatRecordingService.deleteSession(parentSessionId);
await chatRecordingService.deleteSession(parentSessionId);
expect(fs.existsSync(parentFile)).toBe(false);
expect(fs.existsSync(subagentFile)).toBe(false);
expect(fs.existsSync(subagentDir)).toBe(false); // Subagent directory should be deleted
expect(fs.existsSync(parentLog)).toBe(false);
expect(fs.existsSync(subagentLog)).toBe(false);
expect(fs.existsSync(parentToolOutputDir)).toBe(false);
expect(fs.existsSync(subagentToolOutputDir)).toBe(false);
});
it('should delete by basename', () => {
it('should delete subagent files and their logs when parent is deleted (legacy flat structure)', async () => {
const parentSessionId = '12345678-session-id';
const shortId = '12345678';
const subagentSessionId = 'subagent-session-id';
const chatsDir = path.join(testTempDir, 'chats');
const logsDir = path.join(testTempDir, 'logs');
fs.mkdirSync(chatsDir, { recursive: true });
fs.mkdirSync(logsDir, { recursive: true });
// Create parent session file
const parentFile = path.join(
chatsDir,
`session-2023-01-01T00-00-${shortId}.json`,
);
fs.writeFileSync(
parentFile,
JSON.stringify({ sessionId: parentSessionId }),
);
// Create legacy subagent session file (flat in chatsDir)
const subagentFile = path.join(
chatsDir,
`session-2023-01-01T00-01-${shortId}.json`,
);
fs.writeFileSync(
subagentFile,
JSON.stringify({ sessionId: subagentSessionId, kind: 'subagent' }),
);
// Call with parent sessionId
await chatRecordingService.deleteSession(parentSessionId);
expect(fs.existsSync(parentFile)).toBe(false);
expect(fs.existsSync(subagentFile)).toBe(false);
});
it('should delete by basename', async () => {
const sessionId = 'test-session-id';
const shortId = '12345678';
const chatsDir = path.join(testTempDir, 'chats');
@@ -553,16 +614,16 @@ describe('ChatRecordingService', () => {
fs.writeFileSync(logFile, '{}');
// Call with basename
chatRecordingService.deleteSession(basename);
await chatRecordingService.deleteSession(basename);
expect(fs.existsSync(sessionFile)).toBe(false);
expect(fs.existsSync(logFile)).toBe(false);
});
it('should not throw if session file does not exist', () => {
expect(() =>
it('should not throw if session file does not exist', async () => {
await expect(
chatRecordingService.deleteSession('non-existent'),
).not.toThrow();
).resolves.not.toThrow();
});
});
@@ -7,9 +7,13 @@
import { type Status } from '../scheduler/types.js';
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
import { getProjectHash } from '../utils/paths.js';
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
import path from 'node:path';
import fs from 'node:fs';
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
import {
deleteSessionArtifactsAsync,
deleteSubagentSessionDirAndArtifactsAsync,
} from '../utils/sessionOperations.js';
import { randomUUID } from 'node:crypto';
import type {
Content,
@@ -172,20 +176,46 @@ export class ChatRecordingService {
} else {
// Create new session
this.sessionId = this.context.promptId;
const chatsDir = path.join(
let chatsDir = path.join(
this.context.config.storage.getProjectTempDir(),
'chats',
);
// subagents are nested under the complete parent session id
if (this.kind === 'subagent' && this.context.parentSessionId) {
const safeParentId = sanitizeFilenamePart(
this.context.parentSessionId,
);
if (!safeParentId) {
throw new Error(
`Invalid parentSessionId after sanitization: ${this.context.parentSessionId}`,
);
}
chatsDir = path.join(chatsDir, safeParentId);
}
fs.mkdirSync(chatsDir, { recursive: true });
const timestamp = new Date()
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${this.sessionId.slice(
0,
8,
)}.json`;
const safeSessionId = sanitizeFilenamePart(this.sessionId);
if (!safeSessionId) {
throw new Error(
`Invalid sessionId after sanitization: ${this.sessionId}`,
);
}
let filename: string;
if (this.kind === 'subagent') {
filename = `${safeSessionId}.json`;
} else {
filename = `${SESSION_FILE_PREFIX}${timestamp}-${safeSessionId.slice(
0,
8,
)}.json`;
}
this.conversationFile = path.join(chatsDir, filename);
this.writeConversation({
@@ -596,21 +626,22 @@ export class ChatRecordingService {
*
* @throws {Error} If shortId validation fails.
*/
deleteSession(sessionIdOrBasename: string): void {
async deleteSession(sessionIdOrBasename: string): Promise<void> {
try {
const tempDir = this.context.config.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const shortId = this.deriveShortId(sessionIdOrBasename);
if (!fs.existsSync(chatsDir)) {
// Using stat instead of existsSync for async sanity
if (!(await fs.promises.stat(chatsDir).catch(() => null))) {
return; // Nothing to delete
}
const matchingFiles = this.getMatchingSessionFiles(chatsDir, shortId);
for (const file of matchingFiles) {
this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
await this.deleteSessionAndArtifacts(chatsDir, file, tempDir);
}
} catch (error) {
debugLogger.error('Error deleting session file.', error);
@@ -654,14 +685,14 @@ export class ChatRecordingService {
/**
* Deletes a single session file and its associated logs, tool-outputs, and directory.
*/
private deleteSessionAndArtifacts(
private async deleteSessionAndArtifacts(
chatsDir: string,
file: string,
tempDir: string,
): void {
): Promise<void> {
const filePath = path.join(chatsDir, file);
try {
const fileContent = fs.readFileSync(filePath, 'utf8');
const fileContent = await fs.promises.readFile(filePath, 'utf8');
const content = JSON.parse(fileContent) as unknown;
let fullSessionId: string | undefined;
@@ -673,60 +704,22 @@ export class ChatRecordingService {
}
// Delete the session file
fs.unlinkSync(filePath);
await fs.promises.unlink(filePath);
if (fullSessionId) {
this.deleteSessionLogs(fullSessionId, tempDir);
this.deleteSessionToolOutputs(fullSessionId, tempDir);
this.deleteSessionDirectory(fullSessionId, tempDir);
// Delegate to shared utility!
await deleteSessionArtifactsAsync(fullSessionId, tempDir);
await deleteSubagentSessionDirAndArtifactsAsync(
fullSessionId,
chatsDir,
tempDir,
);
}
} catch (error) {
debugLogger.error(`Error deleting associated file ${file}:`, error);
}
}
/**
* Cleans up activity logs for a session.
*/
private deleteSessionLogs(sessionId: string, tempDir: string): void {
const logsDir = path.join(tempDir, 'logs');
const safeSessionId = sanitizeFilenamePart(sessionId);
const logPath = path.join(logsDir, `session-${safeSessionId}.jsonl`);
if (fs.existsSync(logPath) && logPath.startsWith(logsDir)) {
fs.unlinkSync(logPath);
}
}
/**
* Cleans up tool outputs for a session.
*/
private deleteSessionToolOutputs(sessionId: string, tempDir: string): void {
const safeSessionId = sanitizeFilenamePart(sessionId);
const toolOutputDir = path.join(
tempDir,
'tool-outputs',
`session-${safeSessionId}`,
);
const toolOutputsBase = path.join(tempDir, 'tool-outputs');
if (
fs.existsSync(toolOutputDir) &&
toolOutputDir.startsWith(toolOutputsBase)
) {
fs.rmSync(toolOutputDir, { recursive: true, force: true });
}
}
/**
* Cleans up the session-specific directory.
*/
private deleteSessionDirectory(sessionId: string, tempDir: string): void {
const safeSessionId = sanitizeFilenamePart(sessionId);
const sessionDir = path.join(tempDir, safeSessionId);
if (fs.existsSync(sessionDir) && sessionDir.startsWith(tempDir)) {
fs.rmSync(sessionDir, { recursive: true, force: true });
}
}
/**
* Rewinds the conversation to the state just before the specified message ID.
* All messages from (and including) the specified ID onwards are removed.
@@ -221,7 +221,7 @@ describe('FileDiscoveryService', () => {
});
});
describe('shouldGitIgnoreFile & shouldGeminiIgnoreFile', () => {
describe('shouldIgnoreFile & shouldIgnoreDirectory', () => {
beforeEach(async () => {
await fs.mkdir(path.join(projectRoot, '.git'));
await createTestFile('.gitignore', 'node_modules/');
@@ -238,6 +238,13 @@ describe('FileDiscoveryService', () => {
).toBe(true);
});
it('should return true for git-ignored directories', () => {
const service = new FileDiscoveryService(projectRoot);
expect(
service.shouldIgnoreDirectory(path.join(projectRoot, 'node_modules')),
).toBe(true);
});
it('should return false for non-git-ignored files', () => {
const service = new FileDiscoveryService(projectRoot);
@@ -293,6 +300,7 @@ describe('FileDiscoveryService', () => {
]);
});
});
describe('precedence (.geminiignore over .gitignore)', () => {
beforeEach(async () => {
await fs.mkdir(path.join(projectRoot, '.git'));
@@ -495,4 +503,99 @@ describe('FileDiscoveryService', () => {
expect(paths[0]).toBe(path.join(projectRoot, '.gitignore'));
});
});
describe('getIgnoredPaths', () => {
beforeEach(async () => {
await fs.mkdir(path.join(projectRoot, '.git'));
});
it('should return all ignored paths that exist on disk', async () => {
await createTestFile(
'.gitignore',
'ignored-dir/\nignored-file.txt\n*.log',
);
await createTestFile('ignored-dir/inside.txt');
await createTestFile('ignored-file.txt');
await createTestFile('keep.log');
await createTestFile('src/index.ts');
await createTestFile(GEMINI_IGNORE_FILE_NAME, 'secrets/');
await createTestFile('secrets/passwords.txt');
const service = new FileDiscoveryService(projectRoot);
const ignoredPaths = await service.getIgnoredPaths();
const expectedPaths = [
path.join(projectRoot, '.git'),
path.join(projectRoot, 'ignored-dir'),
path.join(projectRoot, 'ignored-file.txt'),
path.join(projectRoot, 'keep.log'),
path.join(projectRoot, 'secrets'),
].sort();
expect(ignoredPaths.sort()).toEqual(expectedPaths);
});
it('should optimize by not traversing into ignored directories', async () => {
await createTestFile('.gitignore', 'ignored-dir/');
const ignoredDir = path.join(projectRoot, 'ignored-dir');
await fs.mkdir(ignoredDir);
await createTestFile('ignored-dir/large-file-1.txt');
const service = new FileDiscoveryService(projectRoot);
const ignoredPaths = await service.getIgnoredPaths();
expect(ignoredPaths.sort()).toEqual(
[path.join(projectRoot, '.git'), ignoredDir].sort(),
);
});
it('should handle un-ignore patterns correctly', async () => {
await createTestFile(
'.gitignore',
'ignored-dir/*\n!ignored-dir/keep.txt',
);
await createTestFile('ignored-dir/ignored.txt');
await createTestFile('ignored-dir/keep.txt');
const service = new FileDiscoveryService(projectRoot);
const ignoredPaths = await service.getIgnoredPaths();
expect(ignoredPaths).toContain(
path.join(projectRoot, 'ignored-dir/ignored.txt'),
);
expect(ignoredPaths).not.toContain(
path.join(projectRoot, 'ignored-dir/keep.txt'),
);
expect(ignoredPaths).not.toContain(path.join(projectRoot, 'ignored-dir'));
});
it('should respect FilterFilesOptions when provided', async () => {
await createTestFile('.gitignore', 'ignored-by-git.txt');
await createTestFile(GEMINI_IGNORE_FILE_NAME, 'ignored-by-gemini.txt');
await createTestFile('ignored-by-git.txt');
await createTestFile('ignored-by-gemini.txt');
const service = new FileDiscoveryService(projectRoot);
const onlyGemini = await service.getIgnoredPaths({
respectGitIgnore: false,
respectGeminiIgnore: true,
});
expect(onlyGemini).toContain(
path.join(projectRoot, 'ignored-by-gemini.txt'),
);
expect(onlyGemini).not.toContain(
path.join(projectRoot, 'ignored-by-git.txt'),
);
const onlyGit = await service.getIgnoredPaths({
respectGitIgnore: true,
respectGeminiIgnore: false,
});
expect(onlyGit).toContain(path.join(projectRoot, 'ignored-by-git.txt'));
expect(onlyGit).not.toContain(
path.join(projectRoot, 'ignored-by-gemini.txt'),
);
});
});
});
@@ -14,6 +14,8 @@ import {
} from '../utils/ignoreFileParser.js';
import { isGitRepository } from '../utils/gitUtils.js';
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
import { isNodeError } from '../utils/errors.js';
import { debugLogger } from '../utils/debugLogger.js';
import fs from 'node:fs';
import * as path from 'node:path';
@@ -83,6 +85,60 @@ export class FileDiscoveryService {
}
}
/**
* Returns all absolute paths (files and directories) within the project root that should be ignored.
*/
async getIgnoredPaths(options: FilterFilesOptions = {}): Promise<string[]> {
const ignoredPaths: string[] = [];
/**
* Recursively walks the directory tree to find ignored paths.
*/
const walk = async (currentDir: string) => {
let dirEntries: fs.Dirent[];
try {
dirEntries = await fs.promises.readdir(currentDir, {
withFileTypes: true,
});
} catch (error: unknown) {
if (
isNodeError(error) &&
(error.code === 'EACCES' || error.code === 'ENOENT')
) {
// Stop if the directory is inaccessible or doesn't exist
debugLogger.debug(
`Skipping directory ${currentDir} due to ${error.code}`,
);
return;
}
throw error;
}
// Traverse sibling directories concurrently to improve performance.
await Promise.all(
dirEntries.map(async (entry) => {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
// Optimization: If a directory is ignored, its contents are not traversed.
if (this.shouldIgnoreDirectory(fullPath, options)) {
ignoredPaths.push(fullPath);
} else {
await walk(fullPath);
}
} else {
if (this.shouldIgnoreFile(fullPath, options)) {
ignoredPaths.push(fullPath);
}
}
}),
);
};
await walk(this.projectRoot);
return ignoredPaths;
}
private applyFilterFilesOptions(options?: FilterFilesOptions): void {
if (!options) return;
@@ -100,34 +156,16 @@ export class FileDiscoveryService {
}
/**
* Filters a list of file paths based on ignore rules
* Filters a list of file paths based on ignore rules.
*
* NOTE: Directory paths must include a trailing slash to be correctly identified and
* matched against directory-specific ignore patterns (e.g., 'dist/').
*/
filterFiles(filePaths: string[], options: FilterFilesOptions = {}): string[] {
const {
respectGitIgnore = this.defaultFilterFileOptions.respectGitIgnore,
respectGeminiIgnore = this.defaultFilterFileOptions.respectGeminiIgnore,
} = options;
return filePaths.filter((filePath) => {
if (
respectGitIgnore &&
respectGeminiIgnore &&
this.combinedIgnoreFilter
) {
return !this.combinedIgnoreFilter.isIgnored(filePath);
}
// Always respect custom ignore filter if provided
if (this.customIgnoreFilter?.isIgnored(filePath)) {
return false;
}
if (respectGitIgnore && this.gitIgnoreFilter?.isIgnored(filePath)) {
return false;
}
if (respectGeminiIgnore && this.geminiIgnoreFilter?.isIgnored(filePath)) {
return false;
}
return true;
// Infer directory status from the string format
const isDir = filePath.endsWith('/') || filePath.endsWith('\\');
return !this._shouldIgnore(filePath, isDir, options);
});
}
@@ -152,13 +190,61 @@ export class FileDiscoveryService {
}
/**
* Unified method to check if a file should be ignored based on filtering options
* Checks if a specific file should be ignored based on project ignore rules.
*/
shouldIgnoreFile(
filePath: string,
options: FilterFilesOptions = {},
): boolean {
return this.filterFiles([filePath], options).length === 0;
return this._shouldIgnore(filePath, false, options);
}
/**
* Checks if a specific directory should be ignored based on project ignore rules.
*/
shouldIgnoreDirectory(
dirPath: string,
options: FilterFilesOptions = {},
): boolean {
return this._shouldIgnore(dirPath, true, options);
}
/**
* Internal unified check for paths.
*/
private _shouldIgnore(
filePath: string,
isDirectory: boolean,
options: FilterFilesOptions = {},
): boolean {
const {
respectGitIgnore = this.defaultFilterFileOptions.respectGitIgnore,
respectGeminiIgnore = this.defaultFilterFileOptions.respectGeminiIgnore,
} = options;
if (respectGitIgnore && respectGeminiIgnore && this.combinedIgnoreFilter) {
return this.combinedIgnoreFilter.isIgnored(filePath, isDirectory);
}
if (this.customIgnoreFilter?.isIgnored(filePath, isDirectory)) {
return true;
}
if (
respectGitIgnore &&
this.gitIgnoreFilter?.isIgnored(filePath, isDirectory)
) {
return true;
}
if (
respectGeminiIgnore &&
this.geminiIgnoreFilter?.isIgnored(filePath, isDirectory)
) {
return true;
}
return false;
}
/**
@@ -142,7 +142,7 @@ function ensureSandboxAvailable(): boolean {
describe('SandboxManager Integration', () => {
const workspace = process.cwd();
const manager = createSandboxManager({ enabled: true }, workspace);
const manager = createSandboxManager({ enabled: true }, { workspace });
// Skip if we are on an unsupported platform or if it's a NoopSandboxManager
const shouldSkip =
@@ -235,7 +235,7 @@ describe('SandboxManager Integration', () => {
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
{ workspace: tempWorkspace, forbiddenPaths: [forbiddenDir] },
);
const { command, args } = Platform.touch(testFile);
@@ -244,7 +244,6 @@ describe('SandboxManager Integration', () => {
args,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [forbiddenDir] },
});
const result = await runCommand(sandboxed);
@@ -268,7 +267,7 @@ describe('SandboxManager Integration', () => {
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
{ workspace: tempWorkspace, forbiddenPaths: [forbiddenDir] },
);
const { command, args } = Platform.cat(nestedFile);
@@ -277,7 +276,6 @@ describe('SandboxManager Integration', () => {
args,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [forbiddenDir] },
});
const result = await runCommand(sandboxed);
@@ -298,7 +296,7 @@ describe('SandboxManager Integration', () => {
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
{ workspace: tempWorkspace, forbiddenPaths: [conflictDir] },
);
const { command, args } = Platform.touch(testFile);
@@ -309,7 +307,6 @@ describe('SandboxManager Integration', () => {
env: process.env,
policy: {
allowedPaths: [conflictDir],
forbiddenPaths: [conflictDir],
},
});
@@ -329,7 +326,7 @@ describe('SandboxManager Integration', () => {
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
{ workspace: tempWorkspace, forbiddenPaths: [nonExistentPath] },
);
const { command, args } = Platform.echo('survived');
const sandboxed = await osManager.prepareCommand({
@@ -339,7 +336,6 @@ describe('SandboxManager Integration', () => {
env: process.env,
policy: {
allowedPaths: [nonExistentPath],
forbiddenPaths: [nonExistentPath],
},
});
const result = await runCommand(sandboxed);
@@ -362,7 +358,7 @@ describe('SandboxManager Integration', () => {
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
{ workspace: tempWorkspace, forbiddenPaths: [nonExistentFile] },
);
// We use touch to attempt creation of the file
@@ -374,7 +370,6 @@ describe('SandboxManager Integration', () => {
args: argsTouch,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [nonExistentFile] },
});
// Execute the command, we expect it to fail (permission denied or read-only file system)
@@ -402,7 +397,7 @@ describe('SandboxManager Integration', () => {
try {
const osManager = createSandboxManager(
{ enabled: true },
tempWorkspace,
{ workspace: tempWorkspace, forbiddenPaths: [symlinkFile] },
);
// Attempt to read the target file directly
@@ -413,7 +408,6 @@ describe('SandboxManager Integration', () => {
args: argsTarget,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [symlinkFile] }, // Forbid the symlink
});
const resultTarget = await runCommand(commandTarget);
expect(resultTarget.status).not.toBe(0);
@@ -426,7 +420,6 @@ describe('SandboxManager Integration', () => {
args: argsLink,
cwd: tempWorkspace,
env: process.env,
policy: { forbiddenPaths: [symlinkFile] }, // Forbid the symlink
});
const resultLink = await runCommand(commandLink);
expect(resultLink.status).not.toBe(0);
@@ -364,7 +364,10 @@ describe('SandboxManager', () => {
describe('createSandboxManager', () => {
it('should return NoopSandboxManager if sandboxing is disabled', () => {
const manager = createSandboxManager({ enabled: false }, '/workspace');
const manager = createSandboxManager(
{ enabled: false },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(NoopSandboxManager);
});
@@ -375,7 +378,10 @@ describe('SandboxManager', () => {
'should return $expected.name if sandboxing is enabled and platform is $platform',
({ platform, expected }) => {
vi.spyOn(os, 'platform').mockReturnValue(platform);
const manager = createSandboxManager({ enabled: true }, '/workspace');
const manager = createSandboxManager(
{ enabled: true },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(expected);
},
);
@@ -384,7 +390,7 @@ describe('SandboxManager', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true, command: 'windows-native' },
'/workspace',
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(WindowsSandboxManager);
});
@@ -393,7 +399,7 @@ describe('SandboxManager', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true, command: 'docker' as unknown as 'windows-native' },
'/workspace',
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(LocalSandboxManager);
});
+17 -2
View File
@@ -22,6 +22,7 @@ import {
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import type { ShellExecutionResult } from './shellExecutionService.js';
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
export interface SandboxPermissions {
/** Filesystem permissions. */
fileSystem?: {
@@ -40,8 +41,6 @@ export interface SandboxPermissions {
export interface ExecutionPolicy {
/** Additional absolute paths to grant full read/write access to. */
allowedPaths?: string[];
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
forbiddenPaths?: string[];
/** Whether network access is allowed. */
networkAccess?: boolean;
/** Rules for scrubbing sensitive environment variables. */
@@ -50,6 +49,16 @@ export interface ExecutionPolicy {
additionalPermissions?: SandboxPermissions;
}
/**
* Configuration for the sandbox mode behavior.
*/
export interface SandboxModeConfig {
readonly?: boolean;
network?: boolean;
approvedTools?: string[];
allowOverrides?: boolean;
}
/**
* Global configuration options used to initialize a SandboxManager.
*/
@@ -59,6 +68,12 @@ export interface GlobalSandboxOptions {
* This directory is granted full read and write access.
*/
workspace: string;
/** Absolute paths to explicitly deny read/write access to (overrides allowlists). */
forbiddenPaths?: string[];
/** The current sandbox mode behavior from config. */
modeConfig?: SandboxModeConfig;
/** The policy manager for persistent approvals. */
policyManager?: SandboxPolicyManager;
}
/**
@@ -9,50 +9,36 @@ import {
type SandboxManager,
NoopSandboxManager,
LocalSandboxManager,
type GlobalSandboxOptions,
} from './sandboxManager.js';
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { type SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
/**
* Creates a sandbox manager based on the provided settings.
*/
export function createSandboxManager(
sandbox: SandboxConfig | undefined,
workspace: string,
policyManager?: SandboxPolicyManager,
options: GlobalSandboxOptions,
approvalMode?: string,
): SandboxManager {
if (approvalMode === 'yolo') {
return new NoopSandboxManager();
}
const modeConfig =
policyManager && approvalMode
? policyManager.getModeConfig(approvalMode)
: undefined;
if (!options.modeConfig && options.policyManager && approvalMode) {
options.modeConfig = options.policyManager.getModeConfig(approvalMode);
}
if (sandbox?.enabled) {
if (os.platform() === 'win32' && sandbox?.command === 'windows-native') {
return new WindowsSandboxManager({
workspace,
modeConfig,
policyManager,
});
return new WindowsSandboxManager(options);
} else if (os.platform() === 'linux') {
return new LinuxSandboxManager({
workspace,
modeConfig,
policyManager,
});
return new LinuxSandboxManager(options);
} else if (os.platform() === 'darwin') {
return new MacOsSandboxManager({
workspace,
modeConfig,
policyManager,
});
return new MacOsSandboxManager(options);
}
return new LocalSandboxManager();
}
+22 -5
View File
@@ -355,12 +355,29 @@ describe('getErrorType', () => {
expect(getErrorType(undefined)).toBe('unknown');
});
it('should strip leading underscores from error names', () => {
class _GaxiosError extends Error {}
it('should use explicitly set error names', () => {
class _GaxiosError extends Error {
constructor(message: string) {
super(message);
this.name = 'GaxiosError';
}
}
expect(getErrorType(new _GaxiosError('test'))).toBe('GaxiosError');
const errorWithUnderscoreName = new Error('test');
errorWithUnderscoreName.name = '_CodeBuddyError';
expect(getErrorType(errorWithUnderscoreName)).toBe('CodeBuddyError');
class BadRequestError3 extends Error {
constructor(message: string) {
super(message);
this.name = 'BadRequestError';
}
}
expect(getErrorType(new BadRequestError3('test'))).toBe('BadRequestError');
class _AbortError2 extends Error {
constructor(message: string) {
super(message);
this.name = 'AbortError';
}
}
expect(getErrorType(new _AbortError2('test'))).toBe('AbortError');
});
});
+31 -9
View File
@@ -57,9 +57,11 @@ export function getErrorMessage(error: unknown): string {
export function getErrorType(error: unknown): string {
if (!(error instanceof Error)) return 'unknown';
// Return constructor name if the generic 'Error' name is used (for custom errors)
// Use the constructor name if the standard error name is missing or generic.
const name =
error.name === 'Error' ? (error.constructor?.name ?? 'Error') : error.name;
error.name && error.name !== 'Error'
? error.name
: (error.constructor?.name ?? 'Error');
// Strip leading underscore from error names. Bundlers like esbuild sometimes
// rename classes to avoid scope collisions.
@@ -72,42 +74,50 @@ export class FatalError extends Error {
readonly exitCode: number,
) {
super(message);
this.name = 'FatalError';
}
}
export class FatalAuthenticationError extends FatalError {
constructor(message: string) {
super(message, 41);
this.name = 'FatalAuthenticationError';
}
}
export class FatalInputError extends FatalError {
constructor(message: string) {
super(message, 42);
this.name = 'FatalInputError';
}
}
export class FatalSandboxError extends FatalError {
constructor(message: string) {
super(message, 44);
this.name = 'FatalSandboxError';
}
}
export class FatalConfigError extends FatalError {
constructor(message: string) {
super(message, 52);
this.name = 'FatalConfigError';
}
}
export class FatalTurnLimitedError extends FatalError {
constructor(message: string) {
super(message, 53);
this.name = 'FatalTurnLimitedError';
}
}
export class FatalToolExecutionError extends FatalError {
constructor(message: string) {
super(message, 54);
this.name = 'FatalToolExecutionError';
}
}
export class FatalCancellationError extends FatalError {
constructor(message: string) {
super(message, 130); // Standard exit code for SIGINT
this.name = 'FatalCancellationError';
}
}
@@ -118,7 +128,12 @@ export class CanceledError extends Error {
}
}
export class ForbiddenError extends Error {}
export class ForbiddenError extends Error {
constructor(message: string) {
super(message);
this.name = 'ForbiddenError';
}
}
export class AccountSuspendedError extends ForbiddenError {
readonly appealUrl?: string;
readonly appealLinkText?: string;
@@ -130,8 +145,18 @@ export class AccountSuspendedError extends ForbiddenError {
this.appealLinkText = metadata?.['appeal_url_link_text'];
}
}
export class UnauthorizedError extends Error {}
export class BadRequestError extends Error {}
export class UnauthorizedError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnauthorizedError';
}
}
export class BadRequestError extends Error {
constructor(message: string) {
super(message);
this.name = 'BadRequestError';
}
}
export class ChangeAuthRequestedError extends Error {
constructor() {
@@ -264,10 +289,7 @@ export function isAuthenticationError(error: unknown): boolean {
}
// Check for UnauthorizedError class (from MCP SDK or our own)
if (
error instanceof Error &&
error.constructor.name === 'UnauthorizedError'
) {
if (error instanceof Error && error.name === 'UnauthorizedError') {
return true;
}
@@ -178,7 +178,7 @@ async function readFullStructure(
const subFolderPath = path.join(currentPath, subFolderName);
const isIgnored =
options.fileService?.shouldIgnoreFile(
options.fileService?.shouldIgnoreDirectory(
subFolderPath,
filterFileOptions,
) ?? false;
+64 -229
View File
@@ -33,279 +33,114 @@ describe('GitIgnoreParser', () => {
await fs.rm(projectRoot, { recursive: true, force: true });
});
describe('Basic ignore behaviors', () => {
describe('Core Git Logic', () => {
beforeEach(async () => {
await setupGitRepo();
});
it('should not ignore files when no .gitignore exists', async () => {
expect(parser.isIgnored('file.txt')).toBe(false);
});
it('should identify paths ignored by the root .gitignore', async () => {
await createTestFile('.gitignore', 'node_modules/\n*.log\n/dist\n.env');
it('should ignore files based on a root .gitignore', async () => {
const gitignoreContent = `
# Comment
node_modules/
*.log
/dist
.env
`;
await createTestFile('.gitignore', gitignoreContent);
expect(parser.isIgnored(path.join('node_modules', 'some-lib'))).toBe(
expect(parser.isIgnored('node_modules/package/index.js', false)).toBe(
true,
);
expect(parser.isIgnored(path.join('src', 'app.log'))).toBe(true);
expect(parser.isIgnored(path.join('dist', 'index.js'))).toBe(true);
expect(parser.isIgnored('.env')).toBe(true);
expect(parser.isIgnored('src/index.js')).toBe(false);
expect(parser.isIgnored('src/app.log', false)).toBe(true);
expect(parser.isIgnored('dist/bundle.js', false)).toBe(true);
expect(parser.isIgnored('.env', false)).toBe(true);
expect(parser.isIgnored('src/index.js', false)).toBe(false);
});
it('should handle git exclude file', async () => {
it('should identify paths ignored by .git/info/exclude', async () => {
await createTestFile(
path.join('.git', 'info', 'exclude'),
'temp/\n*.tmp',
);
expect(parser.isIgnored('temp/file.txt', false)).toBe(true);
expect(parser.isIgnored('src/file.tmp', false)).toBe(true);
});
expect(parser.isIgnored(path.join('temp', 'file.txt'))).toBe(true);
expect(parser.isIgnored(path.join('src', 'file.tmp'))).toBe(true);
expect(parser.isIgnored('src/file.js')).toBe(false);
it('should identify the .git directory as ignored regardless of patterns', () => {
expect(parser.isIgnored('.git', true)).toBe(true);
expect(parser.isIgnored('.git/config', false)).toBe(true);
});
it('should identify ignored directories when explicitly flagged', async () => {
await createTestFile('.gitignore', 'dist/');
expect(parser.isIgnored('dist', true)).toBe(true);
expect(parser.isIgnored('dist', false)).toBe(false);
});
});
describe('isIgnored path handling', () => {
describe('Nested .gitignore precedence', () => {
beforeEach(async () => {
await setupGitRepo();
const gitignoreContent = `
node_modules/
*.log
/dist
/.env
src/*.tmp
!src/important.tmp
`;
await createTestFile('.gitignore', gitignoreContent);
});
it('should always ignore .git directory', () => {
expect(parser.isIgnored('.git')).toBe(true);
expect(parser.isIgnored(path.join('.git', 'config'))).toBe(true);
expect(parser.isIgnored(path.join(projectRoot, '.git', 'HEAD'))).toBe(
true,
await createTestFile('.gitignore', '*.log\n/ignored-at-root/');
await createTestFile(
'subdir/.gitignore',
'!special.log\nfile-in-subdir.txt',
);
});
it('should ignore files matching patterns', () => {
it('should prioritize nested rules over root rules', () => {
expect(parser.isIgnored('any.log', false)).toBe(true);
expect(parser.isIgnored('subdir/any.log', false)).toBe(true);
expect(parser.isIgnored('subdir/special.log', false)).toBe(false);
});
it('should correctly anchor nested patterns', () => {
expect(parser.isIgnored('subdir/file-in-subdir.txt', false)).toBe(true);
expect(parser.isIgnored('file-in-subdir.txt', false)).toBe(false);
});
it('should stop processing if an ancestor directory is ignored', async () => {
await createTestFile(
'ignored-at-root/.gitignore',
'!should-not-work.txt',
);
await createTestFile('ignored-at-root/should-not-work.txt', 'content');
expect(
parser.isIgnored(path.join('node_modules', 'package', 'index.js')),
parser.isIgnored('ignored-at-root/should-not-work.txt', false),
).toBe(true);
expect(parser.isIgnored('app.log')).toBe(true);
expect(parser.isIgnored(path.join('logs', 'app.log'))).toBe(true);
expect(parser.isIgnored(path.join('dist', 'bundle.js'))).toBe(true);
expect(parser.isIgnored('.env')).toBe(true);
expect(parser.isIgnored(path.join('config', '.env'))).toBe(false); // .env is anchored to root
});
it('should ignore files with path-specific patterns', () => {
expect(parser.isIgnored(path.join('src', 'temp.tmp'))).toBe(true);
expect(parser.isIgnored(path.join('other', 'temp.tmp'))).toBe(false);
});
it('should handle negation patterns', () => {
expect(parser.isIgnored(path.join('src', 'important.tmp'))).toBe(false);
});
it('should not ignore files that do not match patterns', () => {
expect(parser.isIgnored(path.join('src', 'index.ts'))).toBe(false);
expect(parser.isIgnored('README.md')).toBe(false);
});
it('should handle absolute paths correctly', () => {
const absolutePath = path.join(projectRoot, 'node_modules', 'lib');
expect(parser.isIgnored(absolutePath)).toBe(true);
});
it('should handle paths outside project root by not ignoring them', () => {
const outsidePath = path.resolve(projectRoot, '..', 'other', 'file.txt');
expect(parser.isIgnored(outsidePath)).toBe(false);
});
it('should handle relative paths correctly', () => {
expect(parser.isIgnored(path.join('node_modules', 'some-package'))).toBe(
true,
);
expect(
parser.isIgnored(path.join('..', 'some', 'other', 'file.txt')),
).toBe(false);
});
it('should normalize path separators on Windows', () => {
expect(parser.isIgnored(path.join('node_modules', 'package'))).toBe(true);
expect(parser.isIgnored(path.join('src', 'temp.tmp'))).toBe(true);
});
it('should handle root path "/" without throwing error', () => {
expect(() => parser.isIgnored('/')).not.toThrow();
expect(parser.isIgnored('/')).toBe(false);
});
it('should handle absolute-like paths without throwing error', () => {
expect(() => parser.isIgnored('/some/path')).not.toThrow();
expect(parser.isIgnored('/some/path')).toBe(false);
});
it('should handle paths that start with forward slash', () => {
expect(() => parser.isIgnored('/node_modules')).not.toThrow();
expect(parser.isIgnored('/node_modules')).toBe(false);
});
it('should handle backslash-prefixed files without crashing', () => {
expect(() => parser.isIgnored('\\backslash-file-test.txt')).not.toThrow();
expect(parser.isIgnored('\\backslash-file-test.txt')).toBe(false);
});
it('should handle files with absolute-like names', () => {
expect(() => parser.isIgnored('/backslash-file-test.txt')).not.toThrow();
expect(parser.isIgnored('/backslash-file-test.txt')).toBe(false);
});
});
describe('nested .gitignore files', () => {
beforeEach(async () => {
await setupGitRepo();
// Root .gitignore
await createTestFile('.gitignore', 'root-ignored.txt');
// Nested .gitignore 1
await createTestFile('a/.gitignore', '/b\nc');
// Nested .gitignore 2
await createTestFile('a/d/.gitignore', 'e.txt\nf/g');
});
it('should handle nested .gitignore files correctly', async () => {
// From root .gitignore
expect(parser.isIgnored('root-ignored.txt')).toBe(true);
expect(parser.isIgnored('a/root-ignored.txt')).toBe(true);
// From a/.gitignore: /b
expect(parser.isIgnored('a/b')).toBe(true);
expect(parser.isIgnored('b')).toBe(false);
expect(parser.isIgnored('a/x/b')).toBe(false);
// From a/.gitignore: c
expect(parser.isIgnored('a/c')).toBe(true);
expect(parser.isIgnored('a/x/y/c')).toBe(true);
expect(parser.isIgnored('c')).toBe(false);
// From a/d/.gitignore: e.txt
expect(parser.isIgnored('a/d/e.txt')).toBe(true);
expect(parser.isIgnored('a/d/x/e.txt')).toBe(true);
expect(parser.isIgnored('a/e.txt')).toBe(false);
// From a/d/.gitignore: f/g
expect(parser.isIgnored('a/d/f/g')).toBe(true);
expect(parser.isIgnored('a/f/g')).toBe(false);
});
});
describe('precedence rules', () => {
describe('Advanced Pattern Matching', () => {
beforeEach(async () => {
await setupGitRepo();
});
it('should prioritize nested .gitignore over root .gitignore', async () => {
await createTestFile('.gitignore', '*.log');
await createTestFile('a/b/.gitignore', '!special.log');
it('should handle complex negation and directory rules', async () => {
await createTestFile('.gitignore', 'docs/*\n!docs/README.md');
expect(parser.isIgnored('a/b/any.log')).toBe(true);
expect(parser.isIgnored('a/b/special.log')).toBe(false);
expect(parser.isIgnored('docs/other.txt', false)).toBe(true);
expect(parser.isIgnored('docs/README.md', false)).toBe(false);
expect(parser.isIgnored('docs/', true)).toBe(false);
});
it('should prioritize .gitignore over .git/info/exclude', async () => {
// Exclude all .log files
await createTestFile(path.join('.git', 'info', 'exclude'), '*.log');
// But make an exception in the root .gitignore
await createTestFile('.gitignore', '!important.log');
expect(parser.isIgnored('some.log')).toBe(true);
expect(parser.isIgnored('important.log')).toBe(false);
expect(parser.isIgnored(path.join('subdir', 'some.log'))).toBe(true);
expect(parser.isIgnored(path.join('subdir', 'important.log'))).toBe(
false,
);
});
});
describe('Escaped Characters', () => {
beforeEach(async () => {
await setupGitRepo();
});
it('should correctly handle escaped characters in .gitignore', async () => {
await createTestFile('.gitignore', '\\#foo\n\\!bar');
// Create files with special characters in names
await createTestFile('bla/#foo', 'content');
await createTestFile('bla/!bar', 'content');
// These should be ignored based on the escaped patterns
expect(parser.isIgnored('bla/#foo')).toBe(true);
expect(parser.isIgnored('bla/!bar')).toBe(true);
});
});
describe('Trailing Spaces', () => {
beforeEach(async () => {
await setupGitRepo();
it('should handle escaped characters like # and !', async () => {
await createTestFile('.gitignore', '\\#hashfile\n\\!exclaim');
expect(parser.isIgnored('#hashfile', false)).toBe(true);
expect(parser.isIgnored('!exclaim', false)).toBe(true);
});
it('should correctly handle significant trailing spaces', async () => {
await createTestFile('.gitignore', 'foo\\ \nbar ');
await createTestFile('foo ', 'content');
await createTestFile('bar', 'content');
await createTestFile('bar ', 'content');
// 'foo\ ' should match 'foo '
expect(parser.isIgnored('foo ')).toBe(true);
// 'bar ' should be trimmed to 'bar'
expect(parser.isIgnored('bar')).toBe(true);
expect(parser.isIgnored('bar ')).toBe(false);
expect(parser.isIgnored('foo ', false)).toBe(true);
expect(parser.isIgnored('bar', false)).toBe(true);
expect(parser.isIgnored('bar ', false)).toBe(false);
});
});
describe('Extra Patterns', () => {
beforeEach(async () => {
await setupGitRepo();
});
it('should apply extraPatterns with higher precedence than .gitignore', async () => {
describe('Extra Patterns (Constructor-passed)', () => {
it('should apply extraPatterns with highest precedence', async () => {
await createTestFile('.gitignore', '*.txt');
parser = new GitIgnoreParser(projectRoot, ['!important.txt', 'temp/']);
const extraPatterns = ['!important.txt', 'temp/'];
parser = new GitIgnoreParser(projectRoot, extraPatterns);
expect(parser.isIgnored('file.txt')).toBe(true);
expect(parser.isIgnored('important.txt')).toBe(false); // Un-ignored by extraPatterns
expect(parser.isIgnored('temp/file.js')).toBe(true); // Ignored by extraPatterns
});
it('should handle extraPatterns that unignore directories', async () => {
await createTestFile('.gitignore', '/foo/\n/a/*/c/');
const extraPatterns = ['!foo/', '!a/*/c/'];
parser = new GitIgnoreParser(projectRoot, extraPatterns);
expect(parser.isIgnored('foo/bar/file.txt')).toBe(false);
expect(parser.isIgnored('a/b/c/file.txt')).toBe(false);
});
it('should handle extraPatterns that unignore directories with nested gitignore', async () => {
await createTestFile('.gitignore', '/foo/');
await createTestFile('foo/bar/.gitignore', 'file.txt');
const extraPatterns = ['!foo/'];
parser = new GitIgnoreParser(projectRoot, extraPatterns);
expect(parser.isIgnored('foo/bar/file.txt')).toBe(true);
expect(parser.isIgnored('foo/bar/file2.txt')).toBe(false);
expect(parser.isIgnored('file.txt', false)).toBe(true);
expect(parser.isIgnored('important.txt', false)).toBe(false);
expect(parser.isIgnored('temp/anything.js', false)).toBe(true);
});
});
});
+40 -58
View File
@@ -7,9 +7,10 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import ignore, { type Ignore } from 'ignore';
import { getNormalizedRelativePath } from './ignorePathUtils.js';
export interface GitIgnoreFilter {
isIgnored(filePath: string): boolean;
isIgnored(filePath: string, isDirectory: boolean): boolean;
}
export class GitIgnoreParser implements GitIgnoreFilter {
@@ -115,37 +116,25 @@ export class GitIgnoreParser implements GitIgnoreFilter {
.filter((p) => p !== '');
}
isIgnored(filePath: string): boolean {
if (!filePath || typeof filePath !== 'string') {
return false;
}
const absoluteFilePath = path.resolve(this.projectRoot, filePath);
if (!absoluteFilePath.startsWith(this.projectRoot)) {
isIgnored(filePath: string, isDirectory: boolean): boolean {
const normalizedPath = getNormalizedRelativePath(
this.projectRoot,
filePath,
isDirectory,
);
// Root directory is never ignored by gitignore
if (
normalizedPath === null ||
normalizedPath === '' ||
normalizedPath === '/'
) {
return false;
}
try {
const resolved = path.resolve(this.projectRoot, filePath);
const relativePath = path.relative(this.projectRoot, resolved);
const ig = ignore().add('.git'); // Always ignore .git
if (relativePath === '' || relativePath.startsWith('..')) {
return false;
}
// Even in windows, Ignore expects forward slashes.
const normalizedPath = relativePath.replace(/\\/g, '/');
if (normalizedPath.startsWith('/') || normalizedPath === '') {
return false;
}
const ig = ignore();
// Always ignore .git directory
ig.add('.git');
// Load global patterns from .git/info/exclude on first call
// Load global patterns from .git/info/exclude
if (this.globalPatterns === undefined) {
const excludeFile = path.join(
this.projectRoot,
@@ -159,11 +148,12 @@ export class GitIgnoreParser implements GitIgnoreFilter {
}
ig.add(this.globalPatterns);
const pathParts = relativePath.split(path.sep);
const dirsToVisit = [this.projectRoot];
// Git checks directories hierarchically. If a parent directory is ignored,
// its children are ignored automatically, and we can stop processing.
const pathParts = normalizedPath.split('/');
let currentAbsDir = this.projectRoot;
// Collect all directories in the path
const dirsToVisit = [this.projectRoot];
for (let i = 0; i < pathParts.length - 1; i++) {
currentAbsDir = path.join(currentAbsDir, pathParts[i]);
dirsToVisit.push(currentAbsDir);
@@ -172,41 +162,33 @@ export class GitIgnoreParser implements GitIgnoreFilter {
for (const dir of dirsToVisit) {
const relativeDir = path.relative(this.projectRoot, dir);
if (relativeDir) {
const normalizedRelativeDir = relativeDir.replace(/\\/g, '/');
const igPlusExtras = ignore()
.add(ig)
.add(this.processedExtraPatterns); // takes priority over ig patterns
if (igPlusExtras.ignores(normalizedRelativeDir)) {
// This directory is ignored by an ancestor's .gitignore.
// According to git behavior, we don't need to process this
// directory's .gitignore, as nothing inside it can be
// un-ignored.
// Check if this parent directory is already ignored by patterns found so far
const parentDirRelative = getNormalizedRelativePath(
this.projectRoot,
dir,
true,
);
const currentIg = ignore().add(ig).add(this.processedExtraPatterns);
if (parentDirRelative && currentIg.ignores(parentDirRelative)) {
// Optimization: Stop once an ancestor is ignored
break;
}
}
if (this.cache.has(dir)) {
const patterns = this.cache.get(dir);
if (patterns) {
ig.add(patterns);
}
} else {
// Load and add patterns from .gitignore in the current directory
let patterns = this.cache.get(dir);
if (patterns === undefined) {
const gitignorePath = path.join(dir, '.gitignore');
if (fs.existsSync(gitignorePath)) {
const patterns = this.loadPatternsForFile(gitignorePath);
this.cache.set(dir, patterns);
ig.add(patterns);
} else {
this.cache.set(dir, ignore());
}
patterns = fs.existsSync(gitignorePath)
? this.loadPatternsForFile(gitignorePath)
: ignore();
this.cache.set(dir, patterns);
}
ig.add(patterns);
}
// Apply extra patterns (e.g. from .geminiignore) last for precedence
ig.add(this.processedExtraPatterns);
return ig.ignores(normalizedPath);
// Extra patterns (like .geminiignore) have final precedence
return ig.add(this.processedExtraPatterns).ignores(normalizedPath);
} catch (_error) {
return false;
}
+43 -164
View File
@@ -11,7 +11,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
describe('GeminiIgnoreParser', () => {
describe('IgnoreFileParser', () => {
let projectRoot: string;
async function createTestFile(filePath: string, content = '') {
@@ -21,9 +21,7 @@ describe('GeminiIgnoreParser', () => {
}
beforeEach(async () => {
projectRoot = await fs.mkdtemp(
path.join(os.tmpdir(), 'geminiignore-test-'),
);
projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ignore-file-test-'));
});
afterEach(async () => {
@@ -31,187 +29,68 @@ describe('GeminiIgnoreParser', () => {
vi.restoreAllMocks();
});
describe('when .geminiignore exists', () => {
beforeEach(async () => {
describe('Basic File Loading', () => {
it('should identify paths ignored by a single ignore file', async () => {
await createTestFile(
GEMINI_IGNORE_FILE_NAME,
'ignored.txt\n# A comment\n/ignored_dir/\n',
);
await createTestFile('ignored.txt', 'ignored');
await createTestFile('not_ignored.txt', 'not ignored');
await createTestFile(
path.join('ignored_dir', 'file.txt'),
'in ignored dir',
);
await createTestFile(
path.join('subdir', 'not_ignored.txt'),
'not ignored',
'ignored.txt\n/ignored_dir/',
);
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.isIgnored('ignored.txt', false)).toBe(true);
expect(parser.isIgnored('ignored_dir/file.txt', false)).toBe(true);
expect(parser.isIgnored('keep.txt', false)).toBe(false);
expect(parser.isIgnored('ignored_dir', true)).toBe(true);
});
it('should ignore files specified in .geminiignore', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.getPatterns()).toEqual(['ignored.txt', '/ignored_dir/']);
expect(parser.isIgnored('ignored.txt')).toBe(true);
expect(parser.isIgnored('not_ignored.txt')).toBe(false);
expect(parser.isIgnored(path.join('ignored_dir', 'file.txt'))).toBe(true);
expect(parser.isIgnored(path.join('subdir', 'not_ignored.txt'))).toBe(
false,
);
});
it('should return ignore file path when patterns exist', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.getIgnoreFilePaths()).toEqual([
path.join(projectRoot, GEMINI_IGNORE_FILE_NAME),
]);
});
it('should return true for hasPatterns when patterns exist', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.hasPatterns()).toBe(true);
});
it('should maintain patterns in memory when .geminiignore is deleted', async () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
await fs.rm(path.join(projectRoot, GEMINI_IGNORE_FILE_NAME));
expect(parser.hasPatterns()).toBe(true);
expect(parser.getIgnoreFilePaths()).toEqual([]);
});
});
describe('when .geminiignore does not exist', () => {
it('should not load any patterns and not ignore any files', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.getPatterns()).toEqual([]);
expect(parser.isIgnored('any_file.txt')).toBe(false);
});
it('should return empty array for getIgnoreFilePaths when no patterns exist', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.getIgnoreFilePaths()).toEqual([]);
});
it('should return false for hasPatterns when no patterns exist', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
it('should handle missing or empty ignore files gracefully', () => {
const parser = new IgnoreFileParser(projectRoot, 'nonexistent.ignore');
expect(parser.isIgnored('any.txt', false)).toBe(false);
expect(parser.hasPatterns()).toBe(false);
});
});
describe('when .geminiignore is empty', () => {
beforeEach(async () => {
await createTestFile(GEMINI_IGNORE_FILE_NAME, '');
describe('Multiple Ignore File Priority', () => {
const primary = 'primary.ignore';
const secondary = 'secondary.ignore';
it('should prioritize patterns from the first file in the input list', async () => {
// First file un-ignores, second file ignores
await createTestFile(primary, '!important.log');
await createTestFile(secondary, '*.log');
const parser = new IgnoreFileParser(projectRoot, [primary, secondary]);
expect(parser.isIgnored('other.log', false)).toBe(true);
expect(parser.isIgnored('important.log', false)).toBe(false);
});
it('should return file path for getIgnoreFilePaths', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.getIgnoreFilePaths()).toEqual([
path.join(projectRoot, GEMINI_IGNORE_FILE_NAME),
]);
});
it('should return existing ignore file paths in priority order', async () => {
await createTestFile(primary, 'pattern');
await createTestFile(secondary, 'pattern');
it('should return false for hasPatterns', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.hasPatterns()).toBe(false);
const parser = new IgnoreFileParser(projectRoot, [primary, secondary]);
const paths = parser.getIgnoreFilePaths();
// Implementation returns in reverse order of processing (first file = highest priority = last processed)
expect(paths[0]).toBe(path.join(projectRoot, secondary));
expect(paths[1]).toBe(path.join(projectRoot, primary));
});
});
describe('when .geminiignore only has comments', () => {
beforeEach(async () => {
await createTestFile(
GEMINI_IGNORE_FILE_NAME,
'# This is a comment\n# Another comment\n',
);
});
it('should return file path for getIgnoreFilePaths', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.getIgnoreFilePaths()).toEqual([
path.join(projectRoot, GEMINI_IGNORE_FILE_NAME),
]);
});
it('should return false for hasPatterns', () => {
const parser = new IgnoreFileParser(projectRoot, GEMINI_IGNORE_FILE_NAME);
expect(parser.hasPatterns()).toBe(false);
});
});
describe('when multiple ignore files are provided', () => {
const primaryFile = 'primary.ignore';
const secondaryFile = 'secondary.ignore';
beforeEach(async () => {
await createTestFile(primaryFile, '# Primary\n!important.txt\n');
await createTestFile(secondaryFile, '# Secondary\n*.txt\n');
await createTestFile('important.txt', 'important');
await createTestFile('other.txt', 'other');
});
it('should combine patterns from all files', () => {
const parser = new IgnoreFileParser(projectRoot, [
primaryFile,
secondaryFile,
]);
expect(parser.isIgnored('other.txt')).toBe(true);
});
it('should respect priority (first file overrides second)', () => {
const parser = new IgnoreFileParser(projectRoot, [
primaryFile,
secondaryFile,
]);
expect(parser.isIgnored('important.txt')).toBe(false);
});
it('should return all existing file paths in reverse order', () => {
const parser = new IgnoreFileParser(projectRoot, [
'nonexistent.ignore',
primaryFile,
secondaryFile,
]);
expect(parser.getIgnoreFilePaths()).toEqual([
path.join(projectRoot, secondaryFile),
path.join(projectRoot, primaryFile),
]);
});
});
describe('when patterns are passed directly', () => {
it('should ignore files matching the passed patterns', () => {
const parser = new IgnoreFileParser(projectRoot, ['*.log'], true);
expect(parser.isIgnored('debug.log')).toBe(true);
expect(parser.isIgnored('src/index.ts')).toBe(false);
});
it('should handle multiple patterns', () => {
describe('Direct Pattern Input (isPatterns = true)', () => {
it('should use raw patterns passed directly in the constructor', () => {
const parser = new IgnoreFileParser(
projectRoot,
['*.log', 'temp/'],
['*.tmp', '!safe.tmp'],
true,
);
expect(parser.isIgnored('debug.log')).toBe(true);
expect(parser.isIgnored('temp/file.txt')).toBe(true);
expect(parser.isIgnored('src/index.ts')).toBe(false);
expect(parser.isIgnored('temp.tmp', false)).toBe(true);
expect(parser.isIgnored('safe.tmp', false)).toBe(false);
});
it('should respect precedence (later patterns override earlier ones)', () => {
const parser = new IgnoreFileParser(
projectRoot,
['*.txt', '!important.txt'],
true,
);
expect(parser.isIgnored('file.txt')).toBe(true);
expect(parser.isIgnored('important.txt')).toBe(false);
});
it('should return empty array for getIgnoreFilePaths', () => {
const parser = new IgnoreFileParser(projectRoot, ['*.log'], true);
expect(parser.getIgnoreFilePaths()).toEqual([]);
});
it('should return patterns via getPatterns', () => {
const patterns = ['*.log', '!debug.log'];
it('should return provided patterns via getPatterns()', () => {
const patterns = ['*.a', '*.b'];
const parser = new IgnoreFileParser(projectRoot, patterns, true);
expect(parser.getPatterns()).toEqual(patterns);
});
+11 -23
View File
@@ -8,9 +8,10 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import ignore from 'ignore';
import { debugLogger } from './debugLogger.js';
import { getNormalizedRelativePath } from './ignorePathUtils.js';
export interface IgnoreFileFilter {
isIgnored(filePath: string): boolean;
isIgnored(filePath: string, isDirectory: boolean): boolean;
getPatterns(): string[];
getIgnoreFilePaths(): string[];
hasPatterns(): boolean;
@@ -74,37 +75,24 @@ export class IgnoreFileParser implements IgnoreFileFilter {
.filter((p) => p !== '' && !p.startsWith('#'));
}
isIgnored(filePath: string): boolean {
isIgnored(filePath: string, isDirectory: boolean): boolean {
if (this.patterns.length === 0) {
return false;
}
if (!filePath || typeof filePath !== 'string') {
return false;
}
const normalizedPath = getNormalizedRelativePath(
this.projectRoot,
filePath,
isDirectory,
);
if (
filePath.startsWith('\\') ||
filePath === '/' ||
filePath.includes('\0')
normalizedPath === null ||
normalizedPath === '' ||
normalizedPath === '/'
) {
return false;
}
const resolved = path.resolve(this.projectRoot, filePath);
const relativePath = path.relative(this.projectRoot, resolved);
if (relativePath === '' || relativePath.startsWith('..')) {
return false;
}
// Even in windows, Ignore expects forward slashes.
const normalizedPath = relativePath.replace(/\\/g, '/');
if (normalizedPath.startsWith('/') || normalizedPath === '') {
return false;
}
return this.ig.ignores(normalizedPath);
}
@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import * as path from 'node:path';
import { getNormalizedRelativePath } from './ignorePathUtils.js';
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>();
return {
...actual,
resolve: vi.fn(actual.resolve),
relative: vi.fn(actual.relative),
};
});
describe('ignorePathUtils', () => {
const projectRoot = path.resolve('/work/project');
it('should return null for invalid inputs', () => {
expect(getNormalizedRelativePath(projectRoot, '', false)).toBeNull();
expect(
getNormalizedRelativePath(projectRoot, null as unknown as string, false),
).toBeNull();
expect(
getNormalizedRelativePath(
projectRoot,
undefined as unknown as string,
false,
),
).toBeNull();
});
it('should return null for paths outside the project root', () => {
expect(
getNormalizedRelativePath(projectRoot, '/work/other', false),
).toBeNull();
expect(
getNormalizedRelativePath(projectRoot, '../outside', false),
).toBeNull();
});
it('should return null for sibling directories with matching prefixes', () => {
// If projectRoot is /work/project, /work/project-other should be null
expect(
getNormalizedRelativePath(
projectRoot,
'/work/project-other/file.txt',
false,
),
).toBeNull();
});
it('should normalize basic relative paths', () => {
expect(getNormalizedRelativePath(projectRoot, 'src/index.ts', false)).toBe(
'src/index.ts',
);
expect(
getNormalizedRelativePath(projectRoot, './src/index.ts', false),
).toBe('src/index.ts');
});
it('should normalize absolute paths within the root', () => {
expect(
getNormalizedRelativePath(
projectRoot,
path.join(projectRoot, 'src/file.ts'),
false,
),
).toBe('src/file.ts');
});
it('should enforce trailing slash for directories', () => {
expect(getNormalizedRelativePath(projectRoot, 'dist', true)).toBe('dist/');
expect(getNormalizedRelativePath(projectRoot, 'dist/', true)).toBe('dist/');
});
it('should NOT add trailing slash for files even if string has one', () => {
expect(getNormalizedRelativePath(projectRoot, 'dist/', false)).toBe('dist');
expect(getNormalizedRelativePath(projectRoot, 'src/index.ts', false)).toBe(
'src/index.ts',
);
});
it('should convert Windows backslashes to forward slashes', () => {
const winPath = 'src\\components\\Button.tsx';
expect(getNormalizedRelativePath(projectRoot, winPath, false)).toBe(
'src/components/Button.tsx',
);
const winDir = 'node_modules\\';
expect(getNormalizedRelativePath(projectRoot, winDir, true)).toBe(
'node_modules/',
);
});
it('should handle the project root itself', () => {
expect(getNormalizedRelativePath(projectRoot, projectRoot, true)).toBe('/');
expect(getNormalizedRelativePath(projectRoot, '.', true)).toBe('/');
expect(getNormalizedRelativePath(projectRoot, projectRoot, false)).toBe('');
expect(getNormalizedRelativePath(projectRoot, '.', false)).toBe('');
});
it('should remove leading slashes from relative-looking paths', () => {
expect(
getNormalizedRelativePath(
projectRoot,
path.join(projectRoot, '/file.ts'),
false,
),
).toBe('file.ts');
});
it('should reject Windows cross-drive absolute paths', () => {
// Simulate Windows path resolution where cross-drive paths return an
// absolute path without "..".
vi.spyOn(path, 'resolve').mockImplementation(
(...args) => args[args.length - 1],
);
vi.spyOn(path, 'relative').mockReturnValue('D:\\outside');
expect(
getNormalizedRelativePath('C:\\project', 'D:\\outside', false),
).toBeNull();
});
});
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import { isWithinRoot } from './fileUtils.js';
/**
* Normalizes a file path to be relative to the project root and formatted for the 'ignore' library.
*
* @returns The normalized relative path, or null if the path is invalid or outside the root.
*/
export function getNormalizedRelativePath(
projectRoot: string,
filePath: string,
isDirectory: boolean,
): string | null {
if (!filePath || typeof filePath !== 'string') {
return null;
}
const absoluteFilePath = path.resolve(projectRoot, filePath);
// Ensure the path is within the project root
if (!isWithinRoot(absoluteFilePath, projectRoot)) {
return null;
}
const relativePath = path.relative(projectRoot, absoluteFilePath);
// Convert Windows backslashes to forward slashes for the 'ignore' library
let normalized = relativePath.replace(/\\/g, '/');
// Preserve trailing slash to ensure directory patterns (e.g., 'dist/') match correctly
if (isDirectory && !normalized.endsWith('/') && normalized !== '') {
normalized += '/';
}
// Handle the project root directory
if (normalized === '') {
return isDirectory ? '/' : '';
}
// Ensure relative paths don't start with a slash unless it represents the root
if (normalized.startsWith('/') && normalized !== '/') {
normalized = normalized.substring(1);
}
return normalized;
}
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import * as os from 'node:os';
import {
deleteSessionArtifactsAsync,
deleteSubagentSessionDirAndArtifactsAsync,
validateAndSanitizeSessionId,
} from './sessionOperations.js';
describe('sessionOperations', () => {
let tempDir: string;
let chatsDir: string;
beforeEach(async () => {
vi.clearAllMocks();
// Create a real temporary directory for each test
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'session-ops-test-'));
chatsDir = path.join(tempDir, 'chats');
});
afterEach(async () => {
vi.unstubAllEnvs();
// Clean up the temporary directory
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
describe('validateAndSanitizeSessionId', () => {
it('should throw for empty or dangerous IDs', () => {
expect(() => validateAndSanitizeSessionId('')).toThrow(
'Invalid sessionId',
);
expect(() => validateAndSanitizeSessionId('.')).toThrow(
'Invalid sessionId',
);
expect(() => validateAndSanitizeSessionId('..')).toThrow(
'Invalid sessionId',
);
});
it('should sanitize valid IDs', () => {
expect(validateAndSanitizeSessionId('abc/def')).toBe('abc_def');
expect(validateAndSanitizeSessionId('valid-id')).toBe('valid-id');
});
});
describe('deleteSessionArtifactsAsync', () => {
it('should delete logs and tool outputs', async () => {
const sessionId = 'test-session';
const logsDir = path.join(tempDir, 'logs');
const toolOutputsDir = path.join(
tempDir,
'tool-outputs',
`session-${sessionId}`,
);
const sessionDir = path.join(tempDir, sessionId);
await fs.mkdir(logsDir, { recursive: true });
await fs.mkdir(toolOutputsDir, { recursive: true });
await fs.mkdir(sessionDir, { recursive: true });
const logFile = path.join(logsDir, `session-${sessionId}.jsonl`);
await fs.writeFile(logFile, '{}');
// Verify files exist before call
expect(await fs.stat(logFile)).toBeTruthy();
expect(await fs.stat(toolOutputsDir)).toBeTruthy();
expect(await fs.stat(sessionDir)).toBeTruthy();
await deleteSessionArtifactsAsync(sessionId, tempDir);
// Verify files are deleted
await expect(fs.stat(logFile)).rejects.toThrow();
await expect(fs.stat(toolOutputsDir)).rejects.toThrow();
await expect(fs.stat(sessionDir)).rejects.toThrow();
});
it('should ignore ENOENT errors during deletion', async () => {
// Don't create any files. Calling delete on non-existent files should not throw.
await expect(
deleteSessionArtifactsAsync('non-existent', tempDir),
).resolves.toBeUndefined();
});
});
describe('deleteSubagentSessionDirAndArtifactsAsync', () => {
it('should iterate subagent files and delete their artifacts', async () => {
const parentSessionId = 'parent-123';
const subDir = path.join(chatsDir, parentSessionId);
await fs.mkdir(subDir, { recursive: true });
await fs.writeFile(path.join(subDir, 'sub1.json'), '{}');
await fs.writeFile(path.join(subDir, 'sub2.json'), '{}');
const logsDir = path.join(tempDir, 'logs');
await fs.mkdir(logsDir, { recursive: true });
await fs.writeFile(path.join(logsDir, 'session-sub1.jsonl'), '{}');
await fs.writeFile(path.join(logsDir, 'session-sub2.jsonl'), '{}');
await deleteSubagentSessionDirAndArtifactsAsync(
parentSessionId,
chatsDir,
tempDir,
);
// Verify subagent directory is deleted
await expect(fs.stat(subDir)).rejects.toThrow();
// Verify artifacts are deleted
await expect(
fs.stat(path.join(logsDir, 'session-sub1.jsonl')),
).rejects.toThrow();
await expect(
fs.stat(path.join(logsDir, 'session-sub2.jsonl')),
).rejects.toThrow();
});
it('should resolve for safe path even if input contains traversals (due to sanitization)', async () => {
// Should sanitize '../unsafe' to '.._unsafe' and resolve (directory won't exist, so readdir returns [] naturally)
await expect(
deleteSubagentSessionDirAndArtifactsAsync(
'../unsafe',
chatsDir,
tempDir,
),
).resolves.toBeUndefined();
});
it('should handle ENOENT for readdir gracefully', async () => {
// Non-existent directory should not throw
await expect(
deleteSubagentSessionDirAndArtifactsAsync(
'non-existent-parent',
chatsDir,
tempDir,
),
).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { sanitizeFilenamePart } from './fileUtils.js';
import { debugLogger } from './debugLogger.js';
const LOGS_DIR = 'logs';
const TOOL_OUTPUTS_DIR = 'tool-outputs';
/**
* Validates a sessionId and returns a sanitized version.
* Throws an error if the ID is dangerous (e.g., ".", "..", or empty).
*/
export function validateAndSanitizeSessionId(sessionId: string): string {
if (!sessionId || sessionId === '.' || sessionId === '..') {
throw new Error(`Invalid sessionId: ${sessionId}`);
}
const sanitized = sanitizeFilenamePart(sessionId);
if (!sanitized) {
throw new Error(`Invalid sessionId after sanitization: ${sessionId}`);
}
return sanitized;
}
/**
* Asynchronously deletes activity logs and tool outputs for a specific session ID.
*/
export async function deleteSessionArtifactsAsync(
sessionId: string,
tempDir: string,
): Promise<void> {
try {
const safeSessionId = validateAndSanitizeSessionId(sessionId);
const logsDir = path.join(tempDir, LOGS_DIR);
const logPath = path.join(logsDir, `session-${safeSessionId}.jsonl`);
// Use fs.promises.unlink directly since we don't need to check exists first
// (catching ENOENT is idiomatic for async file system ops)
await fs.unlink(logPath).catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') throw err;
});
const toolOutputsBase = path.join(tempDir, TOOL_OUTPUTS_DIR);
const toolOutputDir = path.join(
toolOutputsBase,
`session-${safeSessionId}`,
);
await fs
.rm(toolOutputDir, { recursive: true, force: true })
.catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') throw err;
});
// Top-level session directory (e.g., tempDir/safeSessionId)
const sessionDir = path.join(tempDir, safeSessionId);
await fs
.rm(sessionDir, { recursive: true, force: true })
.catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') throw err;
});
} catch (error) {
debugLogger.error(
`Error deleting session artifacts for ${sessionId}:`,
error,
);
}
}
/**
* Iterates through subagent files in a parent's directory and deletes their artifacts
* before deleting the directory itself.
*/
export async function deleteSubagentSessionDirAndArtifactsAsync(
parentSessionId: string,
chatsDir: string,
tempDir: string,
): Promise<void> {
const safeParentSessionId = validateAndSanitizeSessionId(parentSessionId);
const subagentDir = path.join(chatsDir, safeParentSessionId);
// Safety check to ensure we don't escape chatsDir
if (!subagentDir.startsWith(chatsDir + path.sep)) {
throw new Error(`Dangerous subagent directory path: ${subagentDir}`);
}
try {
const files = await fs
.readdir(subagentDir, { withFileTypes: true })
.catch((err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') return [];
throw err;
});
for (const file of files) {
if (file.isFile() && file.name.endsWith('.json')) {
const agentId = path.basename(file.name, '.json');
await deleteSessionArtifactsAsync(agentId, tempDir);
}
}
// Finally, remove the directory itself
await fs
.rm(subagentDir, { recursive: true, force: true })
.catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') throw err;
});
} catch (error) {
debugLogger.error(
`Error cleaning up subagents for parent ${parentSessionId}:`,
error,
);
// If directory listing fails, we still try to remove the directory if it exists,
// or let the error propagate if it's a critical failure.
await fs.rm(subagentDir, { recursive: true, force: true }).catch(() => {});
}
}