feat(core): subagent isolation and cleanup hardening (#23903)

This commit is contained in:
Abhi
2026-03-26 23:43:39 -04:00
committed by GitHub
parent aca8e1af05
commit 104587bae8
13 changed files with 520 additions and 133 deletions
@@ -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.