diff --git a/session.ts b/session.ts index 2e90290..3d3c5de 100644 --- a/session.ts +++ b/session.ts @@ -1,79 +1,145 @@ /** * Session ID resolution for mcp-server-nerf. * - * Resolves the Claude Code session ID from the environment, filesystem - * artifacts, or generates a stable fallback. + * Resolves the Claude Code session ID from the environment, the project + * transcript directory, or generates a fallback identifier of last resort. */ -import { readdirSync } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; import { createHash } from "node:crypto"; import { log } from "./logger.ts"; +const PROJECTS_DIR = join(homedir(), ".claude", "projects"); + +/** + * UUID-shaped basename (case-insensitive). Claude Code transcript filenames + * are lowercase v4 UUIDs in current builds, but the `i` flag costs nothing + * and protects against a hypothetical future generator that uses uppercase. + */ +const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** * Resolve the session ID from the Claude Code environment. * - * Strategy: - * 1. Check CLAUDE_SESSION_ID env var (set by some Claude Code configurations) - * 2. Scan /tmp for claude session artifacts (transcript/output files) - * 3. Fallback: generate a stable ID from PID + process start time + * Strategy, in order: + * 1. Explicit `override` from tool params. + * 2. `CLAUDE_SESSION_ID` env var (set by some Claude Code configurations). + * 3. Most-recently-modified transcript in this project's transcript dir. + * Claude Code writes per-project transcripts at + * ~/.claude/projects//.jsonl + * where = process.cwd() with `/` replaced by `-`. Scoping to the + * calling CC's own project dir disambiguates correctly when the user has + * multiple concurrent CC sessions in different projects. + * 4. Fallback: stable ID from `md5(pid-ppid)`. Logged at `warn` level so the + * fleet sees this gap if it fires. + * + * Returns the resolved session ID. Never throws. + * + * `projectsDir` and `cwd` are exposed for testability; production callers pass + * neither and get the real Claude Code paths. */ -export function resolveSessionId(override?: string): string { - // 0. Explicit override from tool params +export function resolveSessionId( + override?: string, + projectsDir: string = PROJECTS_DIR, + cwd: string = process.cwd(), +): string { if (override) { log.debug("state_change", { what: "session", to: override }, "Resolved via explicit override"); return override; } - // 1. Direct env var const envId = process.env.CLAUDE_SESSION_ID; if (envId) { log.debug("state_change", { what: "session", to: envId }, "Resolved via CLAUDE_SESSION_ID env var"); return envId; } - // 2. Scan /tmp for session artifacts - const scanned = scanForSessionArtifacts(); - if (scanned) { - log.debug("state_change", { what: "session", to: scanned }, "Resolved via artifact scan"); - return scanned; + const fromTranscripts = resolveFromTranscripts(projectsDir, cwd); + if (fromTranscripts) { + log.debug("state_change", { what: "session", to: fromTranscripts }, "Resolved via newest transcript"); + return fromTranscripts; } - // 3. Fallback: stable ID from PID + timestamp const stableId = generateStableId(); - log.debug("state_change", { what: "session", to: stableId }, "Resolved via stable ID fallback"); + log.warn( + "session_resolution", + { cause: "fallback_used", to: stableId }, + "Resolved via md5(pid-ppid) fallback — no transcript found in project's transcript dir", + ); return stableId; } /** - * Scan /tmp for Claude session artifacts and extract a session ID. - * Looks for files matching claude-session-* or similar patterns. + * Derive the per-project transcript directory name from a project root. + * Claude Code uses the absolute path with `/` replaced by `-`. + * + * /home/bakerb/sandbox/github/foo → -home-bakerb-sandbox-github-foo + * + * Exported for testing. + */ +export function projectSlug(cwd: string): string { + return cwd.replace(/\//g, "-"); +} + +/** + * Find the newest UUID-named `.jsonl` transcript in this project's transcript + * directory. Scans only `//`, where `slug` is derived from + * `cwd` (defaults to `process.cwd()`). + * + * Returns the basename (without `.jsonl`) of the newest match, or null when + * the directory is missing/empty/unreadable. Exported for testing. */ -function scanForSessionArtifacts(): string | null { +export function resolveFromTranscripts( + projectsDir: string = PROJECTS_DIR, + cwd: string = process.cwd(), +): string | null { + const slug = projectSlug(cwd); + const projectDir = join(projectsDir, slug); + + if (!existsSync(projectDir)) { + return null; + } + + let newest: { sessionId: string; mtimeMs: number } | null = null; + + let entries: string[]; try { - const entries = readdirSync("/tmp"); - // Look for nerf config files first (nerf-.json) - for (const entry of entries) { - const match = entry.match(/^nerf-([a-f0-9-]+)\.json$/); - if (match) { - return match[1]; - } - } - // Look for claude session markers - for (const entry of entries) { - const match = entry.match(/^claude-session-([a-f0-9-]+)/); - if (match) { - return match[1]; + entries = readdirSync(projectDir); + } catch (err) { + // Directory existed (existsSync passed) but was unreadable — permissions, + // symlink loop, FS error. Log so an operator diagnosing a fallback can + // see the actual cause, not just "no transcript found." + log.debug( + "session_resolution", + { projectDir, err: String(err) }, + "readdirSync failed on existing project dir", + ); + return null; + } + + for (const entry of entries) { + if (!entry.endsWith(".jsonl")) continue; + const sessionId = entry.slice(0, -".jsonl".length); + if (!SESSION_UUID_RE.test(sessionId)) continue; + try { + const mtimeMs = statSync(join(projectDir, entry)).mtimeMs; + if (!newest || mtimeMs > newest.mtimeMs) { + newest = { sessionId, mtimeMs }; } + } catch { + // Unreadable file — skip silently } - } catch { - // /tmp not readable — fall through } - return null; + + return newest?.sessionId ?? null; } /** - * Generate a stable ID from process characteristics. - * Uses PID and a fixed seed so the ID is deterministic within a process. + * Generate a stable ID from process characteristics. Last-resort fallback — + * never collides with the UUID shape used by real Claude Code sessions, so + * this ID cannot be confused for a transcript-derived one downstream. */ function generateStableId(): string { const seed = `${process.pid}-${process.ppid ?? 0}`; diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 0000000..0bde67e --- /dev/null +++ b/tests/session.test.ts @@ -0,0 +1,162 @@ +/** + * Regression tests for session.ts — session ID resolution. + * + * Covers issue #24: the previous /tmp self-debris scan returned a fake + * 12-char-hex md5(pid-ppid) ID forever once written. The new resolver reads + * from the per-project transcript dir under ~/.claude/projects/ — Claude Code + * writes those files itself, so the source of truth cannot be self-poisoned. + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolveSessionId, resolveFromTranscripts, projectSlug } from "../session.ts"; + +const SESSION_A = "991053c7-f9e4-4840-b906-d0252650793e"; +const SESSION_B = "44c9b97f-ac26-4fe8-be6f-45466b0b265e"; +const SESSION_C = "5044048d-1cf7-4497-b9d7-66b78bc9d188"; + +function writeTranscript(projectsDir: string, project: string, sessionId: string, mtimeMs: number): void { + const projDir = join(projectsDir, project); + mkdirSync(projDir, { recursive: true }); + const path = join(projDir, `${sessionId}.jsonl`); + writeFileSync(path, ""); + const seconds = mtimeMs / 1000; + utimesSync(path, seconds, seconds); +} + +describe("projectSlug", () => { + test("replaces all forward slashes with dashes", () => { + expect(projectSlug("/home/bakerb/sandbox/github/foo")).toBe("-home-bakerb-sandbox-github-foo"); + }); + + test("handles single-component absolute path", () => { + expect(projectSlug("/foo")).toBe("-foo"); + }); + + test("preserves dashes already in the path", () => { + expect(projectSlug("/home/user/my-project")).toBe("-home-user-my-project"); + }); +}); + +describe("resolveFromTranscripts (issue #24)", () => { + let projectsDir: string; + const CWD = "/home/test/project-a"; + const SLUG = "-home-test-project-a"; + + beforeEach(() => { + projectsDir = mkdtempSync(join(tmpdir(), "nerf-session-projects-")); + }); + + afterEach(() => { + try { rmSync(projectsDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + test("returns null when project dir does not exist for this cwd", () => { + expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull(); + }); + + test("returns null when project dir is empty", () => { + mkdirSync(join(projectsDir, SLUG), { recursive: true }); + expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull(); + }); + + test("returns the session ID from a single transcript", () => { + writeTranscript(projectsDir, SLUG, SESSION_A, Date.now()); + expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_A); + }); + + test("returns newest by mtime within this project", () => { + const now = Date.now(); + writeTranscript(projectsDir, SLUG, SESSION_A, now - 30_000); + writeTranscript(projectsDir, SLUG, SESSION_B, now); + writeTranscript(projectsDir, SLUG, SESSION_C, now - 60_000); + expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_B); + }); + + test("ignores transcripts in other project directories (multi-session isolation)", () => { + // Critical regression check: when the user has concurrent CC sessions + // in different projects, the resolver MUST scope to its own cwd, not pick + // up the freshest transcript across the entire fleet. + const now = Date.now(); + writeTranscript(projectsDir, SLUG, SESSION_A, now - 60_000); + writeTranscript(projectsDir, "-home-test-other-project", SESSION_B, now); // newer but wrong project + expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_A); + }); + + test("ignores non-UUID-shaped basenames (debris)", () => { + // Old fallback IDs were 12-char hex like 81457c8d97e2 — must not be picked up + // even if a stray file ends up in a project dir. + writeTranscript(projectsDir, SLUG, "81457c8d97e2", Date.now()); + expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull(); + }); + + test("ignores non-jsonl files", () => { + const projDir = join(projectsDir, SLUG); + mkdirSync(projDir, { recursive: true }); + writeFileSync(join(projDir, `${SESSION_A}.json`), ""); + writeFileSync(join(projDir, `${SESSION_A}.txt`), ""); + expect(resolveFromTranscripts(projectsDir, CWD)).toBeNull(); + }); + + test("UUID-shaped match wins over debris with newer mtime", () => { + const now = Date.now(); + writeTranscript(projectsDir, SLUG, SESSION_A, now - 60_000); + writeTranscript(projectsDir, SLUG, "abcd1234ef56", now); // newer debris, ignored + expect(resolveFromTranscripts(projectsDir, CWD)).toBe(SESSION_A); + }); +}); + +describe("resolveSessionId (issue #24)", () => { + let savedEnv: string | undefined; + + beforeEach(() => { + savedEnv = process.env.CLAUDE_SESSION_ID; + delete process.env.CLAUDE_SESSION_ID; + }); + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.CLAUDE_SESSION_ID; + } else { + process.env.CLAUDE_SESSION_ID = savedEnv; + } + }); + + test("explicit override beats env var", () => { + process.env.CLAUDE_SESSION_ID = SESSION_B; + expect(resolveSessionId(SESSION_A)).toBe(SESSION_A); + }); + + test("env var beats transcript scan", () => { + process.env.CLAUDE_SESSION_ID = SESSION_A; + expect(resolveSessionId()).toBe(SESSION_A); + }); + + test("fallback fires with 12-char hex ID when no transcript exists for this cwd", () => { + // Force the resolver into the fallback path with an empty projectsDir + + // a cwd that has no transcripts. This is the path that USED to be the + // only thing that fired (the bug); now it must be a true last resort. + const emptyProjectsDir = mkdtempSync(join(tmpdir(), "nerf-fallback-empty-")); + try { + const id = resolveSessionId(undefined, emptyProjectsDir, "/home/test/no-transcripts"); + expect(id).toMatch(/^[0-9a-f]{12}$/); + } finally { + try { rmSync(emptyProjectsDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); + + test("transcript scan beats fallback when a transcript is present", () => { + const projectsDir = mkdtempSync(join(tmpdir(), "nerf-resolve-")); + const cwd = "/home/test/project-x"; + const slug = "-home-test-project-x"; + try { + writeTranscript(projectsDir, slug, SESSION_A, Date.now()); + const id = resolveSessionId(undefined, projectsDir, cwd); + expect(id).toBe(SESSION_A); + } finally { + try { rmSync(projectsDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); +});