fix(debug): scope report bundle to current session subtree

createReportBundle's addSubagentSessions scanned path.dirname(sessionFile)
- the sessions root holding every session in history - and bundled the 10
newest .jsonl files, leaking unrelated sessions' transcripts and bloating
archives to tens of MB. Real subagent sessions live inside the current
session's own artifacts dir, so recurse that subtree and drop the
sessions-root scan entirely.

Fixes #8648
This commit is contained in:
roboomp
2026-08-15 12:55:23 +00:00
parent ffd53ff92a
commit 7638f03121
3 changed files with 105 additions and 59 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### 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
### Changed
@@ -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<string, string>,
files: string[],
dirPath: string,
archivePrefix: string,
): Promise<void> {
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<string, string>,
files: string[],
sessionDir: string,
parentBasename: string,
): Promise<void> {
// 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
}
}
@@ -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<string[]> {
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);
});
});