From 4636d1ef39f62351c447a5fb929cd2b3c7794323 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 7 May 2026 11:40:23 +0200 Subject: [PATCH] debug-bundle: include proxy-events.jsonl tail in saved bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the wire-level proxy capture (from claude-code-headless' mitmAddon and codex-headless' ResponsesProxy) into every saved debug bundle, gated on the session having a proxy run dir on disk. Forensic gap before this change: Bundles carried state-snapshot, feed-debug, proxy-semantic, and HTML — useful for "what did the renderer think happened" but silent on "what was actually sent on the wire". The on-disk proxy-events.jsonl had every prompt and header, but it lived outside the bundle's file list and required the user to manually find the run dir under ~/.config/cc-shell/proxy/. Anyone trying to debug a sidecar leak (the orphan-ghost case from PR #54) needed both the bundle AND the user's proxy dir to make any progress. Three additions: 1. src/main/storage/proxyEventsReader.ts (new) Finds the latest proxy run dir matching {cwd, sessionKey}, reads proxy-events.jsonl, tails to PROXY_EVENTS_BUNDLE_MAX_BYTES (5 MiB) when oversized. Returns nulls on any error — bundle save must NEVER fail because the proxy log is missing or unreadable. Uses the same sanitizePath helper the proxy writers use, so the reader and writers can never disagree on the segment shape. 2. IPC seam: 'debug:read-proxy-events' Renderer-callable handler that wraps the reader. Single new types entry (ProxyEventsBundleSection) plus debugApi.readProxyEvents. 3. saveDebugBundle.ts Calls window.api.readProxyEvents during assembly, conditionally adds proxy-events.jsonl + proxy-session-meta.json to the file list when the reader finds something. Manifest gains a `proxyRunDir` field pointing at the FULL log on disk so users who hit the 5 MiB tail cap know where to look for older traffic. Both bundle callers (autosave + manual) pass cwd + providerSessionId from session meta. No new state, no new IPC shape beyond the one read. Verification: - npm run build:app ✅ - Existing bundle layout unchanged for sessions without proxy (the new files are conditional on reader output). --- src/main/ipc/debug.ts | 27 ++ src/main/storage/proxyEventsReader.ts | 240 ++++++++++++++++++ src/preload/api/debug.ts | 12 + src/preload/api/types.ts | 13 + .../src/features/debug/saveDebugBundle.ts | 80 +++++- 5 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 src/main/storage/proxyEventsReader.ts diff --git a/src/main/ipc/debug.ts b/src/main/ipc/debug.ts index b6e5e861a..f2aa96dfa 100644 --- a/src/main/ipc/debug.ts +++ b/src/main/ipc/debug.ts @@ -9,6 +9,10 @@ import { type SaveDebugBundleParams, type SaveDebugBundleResult, } from '@main/storage/debugBundle.js' +import { + readProxyEventsForBundle, + type ProxyEventsBundleSection, +} from '@main/storage/proxyEventsReader.js' // Debug-panel IPC. // @@ -47,4 +51,27 @@ export function registerDebugIpc(): void { return saveDebugBundle(params) }, ) + + // Read the latest proxy-events.jsonl for a session (Claude or + // Codex; they share the on-disk layout). Used by saveDebugBundle + // in the renderer to pull the wire-level capture into the bundle + // without forcing the whole bundle assembler into the main + // process. Errors are swallowed inside readProxyEventsForBundle — + // a missing or unreadable proxy log must never break bundle + // save. + ipcMain.handle( + 'debug:read-proxy-events', + async ( + _evt, + params: { cwd: string; sessionKey?: string | null }, + ): Promise => { + if (!params || typeof params.cwd !== 'string') { + return { proxyEvents: null, runDir: null, sessionMeta: null } + } + return readProxyEventsForBundle({ + cwd: params.cwd, + sessionKey: params.sessionKey ?? null, + }) + }, + ) } diff --git a/src/main/storage/proxyEventsReader.ts b/src/main/storage/proxyEventsReader.ts new file mode 100644 index 000000000..40545c51a --- /dev/null +++ b/src/main/storage/proxyEventsReader.ts @@ -0,0 +1,240 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import { homedir } from 'os' +import { join } from 'path' + +import { canonicalizePath, sanitizePath } from '@shared/runtime/projectDir.js' + +// Reader for the on-disk proxy-events.jsonl files. +// +// WHY this exists: +// Both Claude (via mitmproxy + ProxyServer in claude-code-headless) +// and Codex (via ResponsesProxy after PR feat/codex-proxy-capture) +// write per-session-run JSONL logs under +// ~/.config/cc-shell/proxy////proxy-events.jsonl +// +// These contain the wire-level capture of every API request and +// response chunk: headers, request body (up to 2 MiB), pre-parsed +// request_shape, response chunks. They're the authoritative +// forensic record of "what was actually sent and received". Without +// them in the bundle, debugging "why did this sidecar leak?" or +// "what was the prompt that caused this output?" requires the user +// to manually find the right run dir on disk and decode bodies by +// hand. +// +// This module provides a main-process reader that the +// debug-bundle assembler can call (via IPC) to pull the latest run +// for a given session into the bundle. +// +// WHY a separate file from debugBundle.ts: +// The bundle assembler runs in the renderer; it can't read disk. +// File reading is a main-process concern, exposed over IPC. Keeping +// the reader in its own module means the assembler can call it +// without bringing in the bundle's disk-write logic, and the +// reader's tests (when we have them) don't need the bundle harness. + +const PROXY_ROOT = join(homedir(), '.config', 'cc-shell', 'proxy') + +// Cap on the proxy-events.jsonl content shipped into a bundle. Long +// sessions can produce 100+ MB of wire log; bundles aren't the right +// storage for that. We tail the most recent N MiB which covers +// "the recent traffic that's relevant to whatever the user just +// observed" without ballooning bundles. +// +// WHY tail and not full: +// The interesting events for any "I just saw this break" report +// are within the last few minutes of traffic. Older traffic is +// useful for trend analysis but not bundle-immediacy. If the user +// needs the full log they can grab it directly from +// ~/.config/cc-shell/proxy/.../proxy-events.jsonl — the run dir +// path goes into the bundle's manifest so they know where to look. +const PROXY_EVENTS_BUNDLE_MAX_BYTES = 5 * 1024 * 1024 + + +export type ProxyEventsBundleSection = { + /** Trimmed contents of the latest run's proxy-events.jsonl, or + * null if no log was found for the session. Capped at + * PROXY_EVENTS_BUNDLE_MAX_BYTES; if the file is larger, only the + * tail bytes are included and a synthetic + * `{kind:'truncated', dropped_bytes}` header line is prepended + * so consumers know they're not seeing the start of the run. */ + proxyEvents: string | null + /** Path of the run directory whose JSONL we sampled. Goes into + * the bundle manifest so the user can find the full log on disk + * if the truncated tail isn't enough. Null if no run was found. */ + runDir: string | null + /** session-meta.json from the same run dir, when present. + * Carries the cwd / sessionKey / createdAt context the proxy + * recorded at start. Null if file is missing or unreadable. */ + sessionMeta: string | null +} + + +/** Find the latest proxy run dir matching the given session, read + * its events file (tailing if oversized), and return the section + * shape for inclusion in a debug bundle. + * + * Search strategy: + * 1. Compute the project segment from `cwd` using the SAME + * sanitiser the proxy writers use (sanitizePath → + * collapse-dashes → trim). Mismatched sanitiser would silently + * miss every bundle. + * 2. Optionally narrow to a session-segment subdir matching + * `sessionKey`. If absent or the dir doesn't exist, fall back + * to scanning ALL session subdirs under the project — this + * handles cases where the session id changed between proxy + * launch and bundle capture (e.g. Codex resume after a + * provider id remap). + * 3. Of all run subdirs containing a proxy-events.jsonl, pick the + * one with the newest mtime on its events file. + * + * Returns {proxyEvents:null, runDir:null, sessionMeta:null} when no + * matching log exists. Never throws — bundle save must not fail + * because the proxy log is missing or unreadable. + */ +export async function readProxyEventsForBundle(opts: { + cwd: string + sessionKey?: string | null +}): Promise { + const empty: ProxyEventsBundleSection = { + proxyEvents: null, + runDir: null, + sessionMeta: null, + } + try { + const canonical = await canonicalizePath(opts.cwd) + const projectSegment = sanitiseSegment(canonical) + if (!projectSegment) return empty + const projectDir = join(PROXY_ROOT, projectSegment) + const sessionSegments = await pickSessionSegments(projectDir, opts.sessionKey ?? null) + if (sessionSegments.length === 0) return empty + + const latest = await findLatestRun(projectDir, sessionSegments) + if (!latest) return empty + + const eventsPath = join(latest.runDir, 'proxy-events.jsonl') + const proxyEvents = await readEventsTail(eventsPath, latest.size) + const sessionMeta = await readSessionMeta(join(latest.runDir, 'session-meta.json')) + + return { + proxyEvents, + runDir: latest.runDir, + sessionMeta, + } + } catch { + // Bundle save must NEVER fail because of an unreadable proxy + // log. Empty section is the documented "no record found" signal. + return empty + } +} + + +function sanitiseSegment(value: string): string { + const sanitized = sanitizePath(value).replace(/-+/g, '-').replace(/^-|-$/g, '') + return sanitized +} + + +async function pickSessionSegments( + projectDir: string, + sessionKey: string | null, +): Promise { + // If we have a sessionKey, prefer its sanitised form. Fall back to + // every session-segment subdir if the targeted one doesn't exist — + // a session id can change between proxy launch and bundle save in + // edge cases (provider remap, restart with fresh providerSessionId) + // and we'd rather hand the user the wrong session's log than no + // log at all. + if (sessionKey) { + const segment = sanitiseSegment(sessionKey) + if (segment) { + try { + const stats = await stat(join(projectDir, segment)) + if (stats.isDirectory()) return [segment] + } catch { + // fall through to broad scan + } + } + } + try { + const entries = await readdir(projectDir, { withFileTypes: true }) + return entries.filter(e => e.isDirectory()).map(e => e.name) + } catch { + return [] + } +} + + +async function findLatestRun( + projectDir: string, + sessionSegments: string[], +): Promise<{ runDir: string; size: number; mtimeMs: number } | null> { + let best: { runDir: string; size: number; mtimeMs: number } | null = null + for (const sessionSegment of sessionSegments) { + const sessionDir = join(projectDir, sessionSegment) + let runEntries: string[] + try { + runEntries = await readdir(sessionDir) + } catch { + continue + } + for (const runName of runEntries) { + const runDir = join(sessionDir, runName) + const eventsPath = join(runDir, 'proxy-events.jsonl') + try { + const stats = await stat(eventsPath) + if (!stats.isFile()) continue + if (best === null || stats.mtimeMs > best.mtimeMs) { + best = { runDir, size: stats.size, mtimeMs: stats.mtimeMs } + } + } catch { + // events file missing — empty run dir or fresh proxy that + // hasn't fired any requests yet. Skip silently. + } + } + } + return best +} + + +async function readEventsTail(path: string, size: number): Promise { + try { + if (size <= PROXY_EVENTS_BUNDLE_MAX_BYTES) { + return await readFile(path, 'utf-8') + } + // Read the trailing PROXY_EVENTS_BUNDLE_MAX_BYTES bytes. We can't + // use readFile with a position arg (no slice option in fs.promises + // readFile), so open + read directly. + const { open } = await import('node:fs/promises') + const handle = await open(path, 'r') + try { + const start = size - PROXY_EVENTS_BUNDLE_MAX_BYTES + const buf = Buffer.alloc(PROXY_EVENTS_BUNDLE_MAX_BYTES) + await handle.read(buf, 0, PROXY_EVENTS_BUNDLE_MAX_BYTES, start) + // Drop the first partial line so consumers always see a clean + // JSONL boundary at byte 0 of the captured content. + const content = buf.toString('utf-8') + const firstNewline = content.indexOf('\n') + const trimmed = firstNewline >= 0 ? content.slice(firstNewline + 1) : content + const droppedBytes = size - trimmed.length + const header = JSON.stringify({ + kind: 'truncated', + reason: `proxy-events.jsonl exceeded ${PROXY_EVENTS_BUNDLE_MAX_BYTES} bytes; only the trailing portion is included in this bundle. Full log on disk: ${path}`, + dropped_bytes: droppedBytes, + }) + return `${header}\n${trimmed}` + } finally { + await handle.close() + } + } catch { + return null + } +} + + +async function readSessionMeta(path: string): Promise { + try { + return await readFile(path, 'utf-8') + } catch { + return null + } +} diff --git a/src/preload/api/debug.ts b/src/preload/api/debug.ts index 2d826bada..310a63ca8 100644 --- a/src/preload/api/debug.ts +++ b/src/preload/api/debug.ts @@ -2,6 +2,7 @@ import { ipcRenderer } from 'electron' import type { FeedDebugPersistEntry, + ProxyEventsBundleSection, SaveDebugBundleParams, SaveDebugBundleResult, } from '@preload/api/types.js' @@ -25,4 +26,15 @@ export const debugApi = { saveDebugBundle: (params: SaveDebugBundleParams): Promise => ipcRenderer.invoke('debug:save-bundle', params), + + // Pull the latest proxy-events.jsonl tail + session-meta for a + // given session into the renderer so the bundle assembler can + // include it. Returns nulls when no proxy log was found — never + // throws. See main/storage/proxyEventsReader.ts for caps and + // search strategy. + readProxyEvents: (params: { + cwd: string + sessionKey?: string | null + }): Promise => + ipcRenderer.invoke('debug:read-proxy-events', params), } diff --git a/src/preload/api/types.ts b/src/preload/api/types.ts index 37268526e..a710b0b7a 100644 --- a/src/preload/api/types.ts +++ b/src/preload/api/types.ts @@ -250,6 +250,19 @@ export type SaveDebugBundleResult = { bundlePath: string } +// Section of a debug bundle filled by readProxyEvents IPC. +// Carries the renderer-readable form of whatever +// ~/.config/cc-shell/proxy////proxy-events.jsonl +// existed for the target session at bundle-save time. Nulls signal +// "no record found" — callers must tolerate them. See +// main/storage/proxyEventsReader.ts for the search strategy and +// PROXY_EVENTS_BUNDLE_MAX_BYTES tail cap. +export type ProxyEventsBundleSection = { + proxyEvents: string | null + runDir: string | null + sessionMeta: string | null +} + export type SessionInfo = { sessionId: string summary: string diff --git a/src/renderer/src/features/debug/saveDebugBundle.ts b/src/renderer/src/features/debug/saveDebugBundle.ts index 717de5366..bb531873a 100644 --- a/src/renderer/src/features/debug/saveDebugBundle.ts +++ b/src/renderer/src/features/debug/saveDebugBundle.ts @@ -44,6 +44,24 @@ const FILE_NAMES = { semantic: 'proxy-semantic.json', htmlRaw: 'html-raw.html', htmlClean: 'html-clean.html', + // Wire-level proxy capture (mitm for Claude, ResponsesProxy for + // Codex). Files are populated only when the session was started + // with `useProxy: true` AND the proxy actually wrote any events. + // For sessions without a proxy log, these entries are simply + // omitted from the file list — the manifest reflects the actual + // contents. + // + // proxy-events.jsonl carries the request/response wire log: + // headers (allowlist), request body (≤ 2 MiB or tailed), response + // chunks, request_shape, etc. See + // main/storage/proxyEventsReader.ts for the tail cap and search + // strategy. + // + // proxy-session-meta.json is the run dir's session-meta.json, + // written by the proxy at start. Carries cwd / sessionKey / + // createdAt context. + proxyEvents: 'proxy-events.jsonl', + proxySessionMeta: 'proxy-session-meta.json', } as const // Screen text can be tens of KB of ANSI-stripped terminal buffer. @@ -202,6 +220,7 @@ function buildManifest( capturedAt: number, files: string[], reason: string, + proxyRunDir: string | null, ): string { const manifest = { schemaVersion: BUNDLE_SCHEMA_VERSION, @@ -212,6 +231,13 @@ function buildManifest( capturedAt, capturedAtIso: new Date(capturedAt).toISOString(), files, + // Pointer to the FULL proxy log on disk. The bundled + // proxy-events.jsonl is tail-capped at + // PROXY_EVENTS_BUNDLE_MAX_BYTES (5 MiB at the time of writing — + // see main/storage/proxyEventsReader.ts); anything older than + // that tail still lives at this path. Null when the session had + // no proxy capture at all. + proxyRunDir, // Small bits of runtime state that help orient a consumer // without parsing state-snapshot.json first. Deliberately a // flat summary, not a duplicate. @@ -241,8 +267,19 @@ export async function assembleAndSaveDebugBundle(params: { runtime: SessionRuntime kind: string reason?: string + /** Working directory of the session, used to locate the matching + * proxy-events run dir under ~/.config/cc-shell/proxy/. Optional + * because sessions that ran without proxy capture (or callers + * that don't have the cwd handy) should still produce a bundle — + * the proxy section just gets omitted. */ + cwd?: string + /** providerSessionId (Claude resume id, Codex thread id). When + * present, the proxy reader narrows its search to the matching + * `resume-` session segment. Falls back to scanning every + * session segment under the cwd if absent or unmatched. */ + providerSessionId?: string | null }): Promise<{ bundlePath: string }> { - const { sessionId, runtime, kind, reason = 'manual' } = params + const { sessionId, runtime, kind, reason = 'manual', cwd, providerSessionId } = params const capturedAt = Date.now() await window.api.flushPerformance().catch(() => {}) const performanceSnapshot = await window.api.getPerformanceSnapshot().catch(() => null) @@ -251,6 +288,17 @@ export async function assembleAndSaveDebugBundle(params: { recordHtmlTraceSnapshot(sessionId, html.raw, 'manual') recordScreenTailSnapshot(sessionId, runtime.recentScreen) + // Pull the wire-level proxy capture in parallel with the rest of + // the bundle. Failure here NEVER aborts the bundle — the IPC + // helper returns nulls on any error, and we omit the section if + // nothing was found. See main/storage/proxyEventsReader.ts. + const proxySection = cwd + ? await window.api.readProxyEvents({ + cwd, + sessionKey: providerSessionId ? `resume-${providerSessionId}` : null, + }).catch(() => null) + : null + const files: BundleFile[] = [ { name: FILE_NAMES.manifest, @@ -274,6 +322,17 @@ export async function assembleAndSaveDebugBundle(params: { }, { name: FILE_NAMES.htmlRaw, content: html.raw }, { name: FILE_NAMES.htmlClean, content: html.clean }, + // Proxy section is conditional. Only emit the files when the + // reader found something — otherwise the manifest would list + // entries that don't exist on disk, which is worse than a quiet + // omission. The reader returns nulls for missing/unreadable + // logs. + ...(proxySection?.proxyEvents + ? [{ name: FILE_NAMES.proxyEvents, content: proxySection.proxyEvents }] + : []), + ...(proxySection?.sessionMeta + ? [{ name: FILE_NAMES.proxySessionMeta, content: proxySection.sessionMeta }] + : []), ...exportDebugTraceFiles(sessionId), ...(performanceSnapshot?.files ?? []), ] @@ -287,6 +346,11 @@ export async function assembleAndSaveDebugBundle(params: { capturedAt, files.map(file => file.name), reason, + // Pass the resolved run dir so the manifest can document where + // the FULL log lives on disk — bundle tail capture is bounded + // at PROXY_EVENTS_BUNDLE_MAX_BYTES, anything earlier is still + // available there. + proxySection?.runDir ?? null, ), } @@ -326,6 +390,12 @@ export async function autosaveActiveAgentDebugBundles( runtime, kind, reason, + // cwd + providerSessionId let the assembler pull the latest + // proxy-events.jsonl into the bundle. Both come from the + // session metadata Workspace already tracks; no new + // plumbing needed. + cwd: meta?.cwd, + providerSessionId: meta?.providerSessionId ?? null, }) saved.push({ sessionId, bundlePath }) } catch (err) { @@ -376,7 +446,13 @@ export async function runSaveDebugBundleCommand(workspace: Workspace): Promise