mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 01:27:41 -07:00
merge: pull main into channels and resolve conflicts
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user