mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-04 02:11:11 -07:00
feat(core): subagent isolation and cleanup hardening (#23903)
This commit is contained in:
@@ -726,7 +726,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Wrap handleDeleteSession to return a Promise for UIActions interface
|
||||
const handleDeleteSession = useCallback(
|
||||
async (session: SessionInfo): Promise<void> => {
|
||||
handleDeleteSessionSync(session);
|
||||
await handleDeleteSessionSync(session);
|
||||
},
|
||||
[handleDeleteSessionSync],
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ export const useSessionBrowser = (
|
||||
* Deletes a session by ID using the ChatRecordingService.
|
||||
*/
|
||||
handleDeleteSession: useCallback(
|
||||
(session: SessionInfo) => {
|
||||
async (session: SessionInfo) => {
|
||||
// Note: Chat sessions are stored on disk using a filename derived from
|
||||
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
|
||||
// The ChatRecordingService.deleteSession API expects this file basename
|
||||
@@ -108,7 +108,7 @@ export const useSessionBrowser = (
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (chatRecordingService) {
|
||||
chatRecordingService.deleteSession(session.file);
|
||||
await chatRecordingService.deleteSession(session.file);
|
||||
}
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'Error deleting session:', error);
|
||||
|
||||
@@ -106,6 +106,8 @@ describe('Session Cleanup (Refactored)', () => {
|
||||
);
|
||||
// Session directory
|
||||
await fs.mkdir(path.join(testTempDir, sessionId), { recursive: true });
|
||||
// Subagent chats directory
|
||||
await fs.mkdir(path.join(chatsDir, sessionId), { recursive: true });
|
||||
}
|
||||
|
||||
async function seedSessions() {
|
||||
@@ -274,6 +276,7 @@ describe('Session Cleanup (Refactored)', () => {
|
||||
existsSync(path.join(toolOutputsDir, `session-${sessions[1].id}`)),
|
||||
).toBe(false);
|
||||
expect(existsSync(path.join(testTempDir, sessions[1].id))).toBe(false); // Session directory should be deleted
|
||||
expect(existsSync(path.join(chatsDir, sessions[1].id))).toBe(false); // Subagent chats directory should be deleted
|
||||
});
|
||||
|
||||
it('should NOT delete sessions within the cutoff date', async () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Storage,
|
||||
TOOL_OUTPUTS_DIR,
|
||||
type Config,
|
||||
deleteSessionArtifactsAsync,
|
||||
deleteSubagentSessionDirAndArtifactsAsync,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings, SessionRetentionSettings } from '../config/settings.js';
|
||||
import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js';
|
||||
@@ -59,48 +61,18 @@ function deriveShortIdFromFileName(fileName: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the log path for a session ID.
|
||||
*/
|
||||
function getSessionLogPath(tempDir: string, safeSessionId: string): string {
|
||||
return path.join(tempDir, 'logs', `session-${safeSessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up associated artifacts (logs, tool-outputs, directory) for a session.
|
||||
*/
|
||||
async function deleteSessionArtifactsAsync(
|
||||
async function cleanupSessionAndSubagentsAsync(
|
||||
sessionId: string,
|
||||
config: Config,
|
||||
): Promise<void> {
|
||||
const tempDir = config.storage.getProjectTempDir();
|
||||
const chatsDir = path.join(tempDir, 'chats');
|
||||
|
||||
// Cleanup logs
|
||||
const logsDir = path.join(tempDir, 'logs');
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const logPath = getSessionLogPath(tempDir, safeSessionId);
|
||||
if (logPath.startsWith(logsDir)) {
|
||||
await fs.unlink(logPath).catch(() => {});
|
||||
}
|
||||
|
||||
// Cleanup tool outputs
|
||||
const toolOutputDir = path.join(
|
||||
tempDir,
|
||||
TOOL_OUTPUTS_DIR,
|
||||
`session-${safeSessionId}`,
|
||||
);
|
||||
const toolOutputsBase = path.join(tempDir, TOOL_OUTPUTS_DIR);
|
||||
if (toolOutputDir.startsWith(toolOutputsBase)) {
|
||||
await fs
|
||||
.rm(toolOutputDir, { recursive: true, force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Cleanup session directory
|
||||
const sessionDir = path.join(tempDir, safeSessionId);
|
||||
if (safeSessionId && sessionDir.startsWith(tempDir + path.sep)) {
|
||||
await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
await deleteSessionArtifactsAsync(sessionId, tempDir);
|
||||
await deleteSubagentSessionDirAndArtifactsAsync(sessionId, chatsDir, tempDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,7 +173,7 @@ export async function cleanupExpiredSessions(
|
||||
await fs.unlink(filePath);
|
||||
|
||||
if (fullSessionId) {
|
||||
await deleteSessionArtifactsAsync(fullSessionId, config);
|
||||
await cleanupSessionAndSubagentsAsync(fullSessionId, config);
|
||||
}
|
||||
result.deleted++;
|
||||
} else {
|
||||
@@ -230,7 +202,7 @@ export async function cleanupExpiredSessions(
|
||||
|
||||
const sessionId = sessionToDelete.sessionInfo?.id;
|
||||
if (sessionId) {
|
||||
await deleteSessionArtifactsAsync(sessionId, config);
|
||||
await cleanupSessionAndSubagentsAsync(sessionId, config);
|
||||
}
|
||||
|
||||
if (config.getDebugMode()) {
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function deleteSession(
|
||||
try {
|
||||
// Use ChatRecordingService to delete the session
|
||||
const chatRecordingService = new ChatRecordingService(config);
|
||||
chatRecordingService.deleteSession(sessionToDelete.file);
|
||||
await chatRecordingService.deleteSession(sessionToDelete.file);
|
||||
|
||||
const time = formatRelativeTime(sessionToDelete.lastUpdated);
|
||||
writeToStdout(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -84,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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
148
packages/core/src/utils/sessionOperations.test.ts
Normal file
148
packages/core/src/utils/sessionOperations.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/core/src/utils/sessionOperations.ts
Normal file
122
packages/core/src/utils/sessionOperations.ts
Normal file
@@ -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