diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0405b4337..276f7571b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -57,6 +57,9 @@ ### Fixed - Fixed focused `ultrathink`, `orchestrate`, and `workflowz` shimmer frames repainting the full TUI every 70 ms, causing high CPU usage while composing prompts on WSL2 ([#8646](https://github.com/can1357/oh-my-pi/issues/8646)). +### Fixed + +- Fixed the `/debug` report bundle including unrelated historic sessions: subagent transcripts were collected by scanning the entire sessions directory, leaking other sessions' `.jsonl` files and bloating archives to tens of MB. The bundle now captures only the current session's own artifacts subtree ([#8648](https://github.com/can1357/oh-my-pi/issues/8648)). ## [17.3.4] - 2026-08-14 diff --git a/packages/coding-agent/src/debug/report-bundle.ts b/packages/coding-agent/src/debug/report-bundle.ts index 4329c0954..df09371b1 100644 --- a/packages/coding-agent/src/debug/report-bundle.ts +++ b/packages/coding-agent/src/debug/report-bundle.ts @@ -3,6 +3,8 @@ * * Creates a .tar.gz archive with session data, logs, system info, and optional profiling data. */ + +import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { WorkProfile } from "@oh-my-pi/pi-natives"; @@ -66,8 +68,8 @@ export interface DebugLogSource { * * Bundle contents: * - session.jsonl: Current session transcript - * - artifacts/: Session artifacts directory - * - subagents/: Subagent sessions + artifacts + * - artifacts/: Current session's artifacts subtree (recursive), including any + * subagent session transcripts nested under it * - logs.txt: Recent log entries * - system.json: OS, arch, CPU, memory, versions * - env.json: Sanitized environment variables @@ -130,14 +132,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise< // Session file might not exist yet } - // Artifacts directory (same path without .jsonl) + // Artifacts subtree (same path without .jsonl). Recursing captures the + // current session's nested subagent transcripts and their artifacts while + // staying inside this session's own directory — unrelated co-located + // sessions in the sessions root are never touched (#8648). const artifactsDir = options.sessionFile.slice(0, -6); await addDirectoryToArchive(data, files, artifactsDir, "artifacts"); - - // Look for subagent sessions in the same directory - const sessionDir = path.dirname(options.sessionFile); - const sessionBasename = path.basename(options.sessionFile, ".jsonl"); - await addSubagentSessions(data, files, sessionDir, sessionBasename); } // CPU profile @@ -172,68 +172,34 @@ export async function createReportBundle(options: ReportBundleOptions): Promise< return { path: outputPath, files }; } -/** Add all files from a directory to the archive */ +/** Recursively add every file under a directory to the archive. */ async function addDirectoryToArchive( data: Record, files: string[], dirPath: string, archivePrefix: string, ): Promise { + let entries: Dirent[]; try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile()) continue; - const filePath = path.join(dirPath, entry.name); - const archivePath = `${archivePrefix}/${entry.name}`; - try { - const content = await Bun.file(filePath).text(); - data[archivePath] = content; - files.push(archivePath); - } catch { - // Skip files we can't read - } - } + entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch { // Directory doesn't exist + return; } -} - -/** Find and add subagent session files */ -async function addSubagentSessions( - data: Record, - files: string[], - sessionDir: string, - parentBasename: string, -): Promise { - // Subagent sessions are named with task IDs in the same directory - // They follow the pattern: {timestamp}_{sessionId}.jsonl - // We look for any sessions created after the parent session - try { - const entries = await fs.readdir(sessionDir, { withFileTypes: true }); - const sessionFiles = entries - .filter(e => e.isFile() && e.name.endsWith(".jsonl") && e.name !== `${parentBasename}.jsonl`) - .map(e => e.name); - - // Limit to most recent 10 subagent sessions - const sortedFiles = sessionFiles.sort().slice(-10); - - for (const filename of sortedFiles) { - const filePath = path.join(sessionDir, filename); - const archivePath = `subagents/${filename}`; - try { - const content = await Bun.file(filePath).text(); - data[archivePath] = content; - files.push(archivePath); - - // Also add artifacts for this subagent session - const artifactsDir = filePath.slice(0, -6); - await addDirectoryToArchive(data, files, artifactsDir, `subagents/${filename.slice(0, -6)}`); - } catch { - // Skip files we can't read - } + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + const archivePath = `${archivePrefix}/${entry.name}`; + if (entry.isDirectory()) { + await addDirectoryToArchive(data, files, entryPath, archivePath); + continue; + } + if (!entry.isFile()) continue; + try { + data[archivePath] = await Bun.file(entryPath).text(); + files.push(archivePath); + } catch { + // Skip files we can't read } - } catch { - // Directory doesn't exist } } diff --git a/packages/coding-agent/test/debug/report-bundle-sessions.test.ts b/packages/coding-agent/test/debug/report-bundle-sessions.test.ts new file mode 100644 index 000000000..cc20c6272 --- /dev/null +++ b/packages/coding-agent/test/debug/report-bundle-sessions.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createReportBundle } from "@oh-my-pi/pi-coding-agent/debug/report-bundle"; +import { getConfigRootDir, removeWithRetries, setAgentDir } from "@oh-my-pi/pi-utils"; + +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; +const originalXdgStateHome = process.env.XDG_STATE_HOME; +const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); +let cleanupRoot: string | undefined; + +afterEach(async () => { + if (originalXdgStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalXdgStateHome; + } + if (originalAgentDir) { + setAgentDir(originalAgentDir); + } else { + setAgentDir(fallbackAgentDir); + delete process.env.PI_CODING_AGENT_DIR; + } + if (cleanupRoot) { + await removeWithRetries(cleanupRoot); + cleanupRoot = undefined; + } +}); + +async function archiveMembers(archivePath: string): Promise { + const archive = new Bun.Archive(await Bun.file(archivePath).bytes()); + return [...(await archive.files()).keys()].sort(); +} + +describe("report bundle sessions", () => { + it("bundles only the current session's subtree, not unrelated co-located sessions", async () => { + cleanupRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-report-sessions-")); + const xdgStateHome = path.join(cleanupRoot, "state"); + await fs.mkdir(path.join(xdgStateHome, "omp"), { recursive: true }); + process.env.XDG_STATE_HOME = xdgStateHome; + setAgentDir(fallbackAgentDir); + + const sessionsDir = path.join(cleanupRoot, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Current session and its artifacts subtree: a genuine subagent transcript + // plus a nested sub-subagent transcript one level deeper. + const sessionFile = path.join(sessionsDir, "2026-08-15T00-00-00_CURRENT.jsonl"); + await Bun.write(sessionFile, '{"type":"session","id":"CURRENT"}\n'); + const artifactsDir = sessionFile.slice(0, -6); + await fs.mkdir(path.join(artifactsDir, "SubTask"), { recursive: true }); + await Bun.write(path.join(artifactsDir, "SubTask.jsonl"), '{"type":"session","id":"SubTask"}\n'); + await Bun.write(path.join(artifactsDir, "SubTask", "NestedTask.jsonl"), '{"type":"session","id":"NestedTask"}\n'); + + // Unrelated top-level sessions co-located in the sessions root. + await Bun.write( + path.join(sessionsDir, "2026-08-10T00-00-00_OTHERA.jsonl"), + '{"type":"session","secret":"private-a"}\n', + ); + await Bun.write( + path.join(sessionsDir, "2026-08-12T00-00-00_OTHERB.jsonl"), + '{"type":"session","secret":"private-b"}\n', + ); + + const result = await createReportBundle({ sessionFile }); + const members = await archiveMembers(result.path); + await fs.rm(result.path, { force: true }); + + // Genuine subtree is captured recursively. + expect(members).toContain("artifacts/SubTask.jsonl"); + expect(members).toContain("artifacts/SubTask/NestedTask.jsonl"); + // Unrelated sessions never appear anywhere in the archive. + expect(members.some(name => name.includes("OTHERA") || name.includes("OTHERB"))).toBe(false); + }); +});