diff --git a/src/helpers/session-context-copilot.ts b/src/helpers/session-context-copilot.ts index f91d16a8..8e1daaf0 100644 --- a/src/helpers/session-context-copilot.ts +++ b/src/helpers/session-context-copilot.ts @@ -196,12 +196,15 @@ export async function readCopilotSession( path: eventsFile, }; - const raw = await readTextIfExists(eventsFile); + // A file that exists but cannot be read rejects rather than yielding null. + const raw = await readTextIfExists(eventsFile).catch(() => null); if (raw === null) return noTranscript; let rawEntries: z.infer; try { - rawEntries = CopilotEventsSchema.parse(Bun.JSONL.parse(raw)); + const parsed = CopilotEventsSchema.safeParse(Bun.JSONL.parse(raw)); + if (!parsed.success) return noTranscript; + rawEntries = parsed.data; } catch { return noTranscript; } diff --git a/src/helpers/session-context.ts b/src/helpers/session-context.ts index f69fa83e..c8f6033f 100644 --- a/src/helpers/session-context.ts +++ b/src/helpers/session-context.ts @@ -283,12 +283,18 @@ export async function readClaudeCodeSession( } const sessionFile = join(projectsDir, targetName); - const raw = await readTextIfExists(sessionFile); + // `.catch` as well as the null: readTextIfExists rejects for a file that + // exists but cannot be read, which is the case sessionReadFailure names. + const raw = await readTextIfExists(sessionFile).catch(() => null); if (raw === null) return sessionReadFailure(sessionFile); let entries: TranscriptEntry[]; try { - entries = z.array(TranscriptEntrySchema).parse(Bun.JSONL.parse(raw)); + const parsed = z + .array(TranscriptEntrySchema) + .safeParse(Bun.JSONL.parse(raw)); + if (!parsed.success) return sessionReadFailure(sessionFile); + entries = parsed.data; } catch { return sessionReadFailure(sessionFile); } @@ -433,12 +439,18 @@ export async function readCursorSession( targetDir.name, `${targetDir.name}.jsonl` ); - const raw = await readTextIfExists(sessionFile); + // `.catch` as well as the null: readTextIfExists rejects for a file that + // exists but cannot be read, which is the case sessionReadFailure names. + const raw = await readTextIfExists(sessionFile).catch(() => null); if (raw === null) return sessionReadFailure(sessionFile); let entries: TranscriptEntry[]; try { - entries = z.array(TranscriptEntrySchema).parse(Bun.JSONL.parse(raw)); + const parsed = z + .array(TranscriptEntrySchema) + .safeParse(Bun.JSONL.parse(raw)); + if (!parsed.success) return sessionReadFailure(sessionFile); + entries = parsed.data; } catch { return sessionReadFailure(sessionFile); } diff --git a/tests/helpers/session-context-copilot.test.ts b/tests/helpers/session-context-copilot.test.ts index a6cc13f1..79db6cb2 100644 --- a/tests/helpers/session-context-copilot.test.ts +++ b/tests/helpers/session-context-copilot.test.ts @@ -2,6 +2,7 @@ // Copyright 2026 Archgate import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + chmodSync, mkdirSync, mkdtempSync, rmSync, @@ -60,6 +61,26 @@ describe("readCopilotSession", () => { } } + // events.jsonl exists but cannot be read: the reader reports "no transcript" + // rather than letting the rejection escape. Non-root POSIX only. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports no transcript when events.jsonl cannot be read", + async () => { + const sessionId = `copilot-${uniqueId}-locked`; + makeSession(sessionId, projectRoot, ['{"type":"user.message"}']); + const file = join(stateDir, sessionId, "events.jsonl"); + chmodSync(file, 0o000); + try { + const result = await readCopilotSession(projectRoot); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failure result"); + expect(result.error).toContain("no conversation transcript"); + } finally { + chmodSync(file, 0o600); + } + } + ); + test("returns data for most recent session matching project", async () => { const sessionId = `copilot-${uniqueId}-1`; makeSession(sessionId, projectRoot, [ diff --git a/tests/helpers/session-context-cursor.test.ts b/tests/helpers/session-context-cursor.test.ts index ceb2d7e8..e5110654 100644 --- a/tests/helpers/session-context-cursor.test.ts +++ b/tests/helpers/session-context-cursor.test.ts @@ -14,6 +14,7 @@ import { mkdtempSync, rmSync, symlinkSync, + chmodSync, writeFileSync, } from "node:fs"; import * as os from "node:os"; @@ -65,6 +66,30 @@ describe("readCursorSession", () => { writeFileSync(join(sessionDir, `${sessionId}.jsonl`), lines.join("\n")); } + // Discovery finds the file, the read then fails — permission-denied here, + // a session removed mid-read in practice. Non-root POSIX only: chmod is a + // no-op on Windows and root ignores the mode bits. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports a read failure when the transcript cannot be read", + async () => { + makeSession("session-locked", ['{"role":"user"}']); + const file = join( + transcriptsDir, + "session-locked", + "session-locked.jsonl" + ); + chmodSync(file, 0o000); + try { + const result = await readCursorSession(projectRoot); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failure result"); + expect(result.error).toBe("Failed to read session file"); + } finally { + chmodSync(file, 0o600); + } + } + ); + test("returns data for most recent session", async () => { makeSession("session-abc", [ JSON.stringify({ diff --git a/tests/helpers/session-context.test.ts b/tests/helpers/session-context.test.ts index 11ad1a0d..fc6d867e 100644 --- a/tests/helpers/session-context.test.ts +++ b/tests/helpers/session-context.test.ts @@ -9,7 +9,13 @@ import { test, type Mock, } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; import * as os from "node:os"; import { join } from "node:path"; @@ -171,6 +177,29 @@ describe("readClaudeCodeSession", () => { ); } + // A session file can exist at discovery and be unreadable at the read: + // removed in between, or permission-denied. readTextIfExists rejects + // rather than yielding null there, which is the case sessionReadFailure + // names. Only reproducible as a non-root POSIX user — chmod is a no-op on + // Windows and root ignores the mode bits. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports a read failure for a session file that cannot be read", + async () => { + const file = join(projectsDir, "session.jsonl"); + writeFileSync(file, '{"type":"user"}'); + chmodSync(file, 0o000); + try { + const result = await readClaudeCodeSession(projectRoot); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a failure result"); + expect(result.error).toBe("Failed to read session file"); + } finally { + // Restore before afterEach's rmSync, which cannot remove it otherwise. + chmodSync(file, 0o600); + } + } + ); + test("returns data with correct transcript when JSONL exists", async () => { writeSession([ { type: "user", message: { role: "user", content: "hello" } },