Merge pull request #1495 from ratorin/fix/session-end-transcript-path-isolation

fix(hooks): isolate session-end.js filename using transcript_path UUID (#1494)
This commit is contained in:
Affaan Mustafa
2026-04-21 18:14:23 -04:00
committed by GitHub
2 changed files with 140 additions and 5 deletions

View File

@@ -16,6 +16,7 @@ const {
getDateString,
getTimeString,
getSessionIdShort,
sanitizeSessionId,
getProjectName,
ensureDir,
readFile,
@@ -178,19 +179,45 @@ function mergeSessionHeader(content, today, currentTime, metadata) {
}
async function main() {
// Parse stdin JSON to get transcript_path
// Parse stdin JSON to get transcript_path; fall back to env var on missing,
// empty, or non-string values as well as on malformed JSON.
let transcriptPath = null;
try {
const input = JSON.parse(stdinData);
transcriptPath = input.transcript_path;
if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) {
transcriptPath = input.transcript_path;
}
} catch {
// Fallback: try env var for backwards compatibility
transcriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
// Malformed stdin: fall through to the env-var fallback below.
}
if (!transcriptPath) {
const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
if (typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0) {
transcriptPath = envTranscriptPath;
}
}
const sessionsDir = getSessionsDir();
const today = getDateString();
const shortId = getSessionIdShort();
// Derive shortId from transcript_path UUID when available, using the SAME
// last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps
// backward compatibility for normal sessions (the derived shortId matches what
// getSessionIdShort() would have produced from the same UUID), while making
// every session map to a unique filename based on its own transcript UUID.
//
// Without this, a parent session and any `claude -p ...` subprocess spawned by
// another Stop hook share the project-name fallback filename, and the subprocess
// overwrites the parent's summary. See issue #1494 for full repro details.
let shortId = null;
if (transcriptPath) {
const m = path.basename(transcriptPath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
if (m) {
// Run through sanitizeSessionId() for byte-for-byte parity with
// getSessionIdShort(sessionId.slice(-8)).
shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase());
}
}
if (!shortId) { shortId = getSessionIdShort(); }
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
const sessionMetadata = getSessionMetadata();

View File

@@ -651,6 +651,114 @@ async function runTests() {
passed++;
else failed++;
// Regression test for #1494: transcript_path UUID-derived shortId (last 8 chars)
// isolates sibling subprocess invocations while preserving getSessionIdShort()
// backward compatibility (same `.slice(-8)` convention).
if (
await asyncTest('derives shortId from transcript_path UUID when available', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-session-transcript-${Date.now()}`);
const transcriptUuid = 'abcdef12-3456-4789-a012-bcdef3456789';
const expectedShortId = 'f3456789'; // Last 8 chars of UUID (matches getSessionIdShort convention)
const transcriptPath = path.join(isoHome, 'transcripts', `${transcriptUuid}.jsonl`);
try {
fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
fs.writeFileSync(transcriptPath, '');
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
HOME: isoHome,
USERPROFILE: isoHome,
// Clear CLAUDE_SESSION_ID so parent-process env does not leak into the
// child and the test deterministically exercises the transcript_path
// branch (getSessionIdShort() is the alternative path that is not
// exercised here).
CLAUDE_SESSION_ID: ''
});
const sessionsDir = getCanonicalSessionsDir(isoHome);
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`);
assert.ok(fs.existsSync(sessionFile), `Session file with transcript UUID shortId should exist: ${sessionFile}`);
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
})
)
passed++;
else failed++;
// Regression test for #1494: uppercase UUID hex digits should be normalized to
// lowercase so the filename is consistent with getSessionIdShort()'s output.
if (
await asyncTest('normalizes transcript UUID shortId to lowercase', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-session-transcript-upper-${Date.now()}`);
const transcriptUuid = 'ABCDEF12-3456-4789-A012-BCDEF3456789';
const expectedShortId = 'f3456789'; // last 8 lowercased
const transcriptPath = path.join(isoHome, 'transcripts', `${transcriptUuid}.jsonl`);
try {
fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
fs.writeFileSync(transcriptPath, '');
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
HOME: isoHome,
USERPROFILE: isoHome,
CLAUDE_SESSION_ID: ''
});
const sessionsDir = getCanonicalSessionsDir(isoHome);
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`);
assert.ok(fs.existsSync(sessionFile), `Session file with lowercase shortId should exist: ${sessionFile}`);
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
})
)
passed++;
else failed++;
// Regression test for #1494: when CLAUDE_SESSION_ID and transcript_path refer to the
// same UUID, the derived shortId must be identical to the pre-fix behaviour so that
// existing .tmp files are not orphaned on upgrade.
if (
await asyncTest('matches getSessionIdShort when transcript UUID equals CLAUDE_SESSION_ID', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-session-transcript-match-${Date.now()}`);
const sessionUuid = '11223344-5566-4778-8899-aabbccddeeff';
const expectedShortId = 'ccddeeff'; // last 8 chars of both transcript UUID and CLAUDE_SESSION_ID
const transcriptPath = path.join(isoHome, 'transcripts', `${sessionUuid}.jsonl`);
try {
fs.mkdirSync(path.dirname(transcriptPath), { recursive: true });
fs.writeFileSync(transcriptPath, '');
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
HOME: isoHome,
USERPROFILE: isoHome,
CLAUDE_SESSION_ID: sessionUuid
});
const sessionsDir = getCanonicalSessionsDir(isoHome);
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`);
assert.ok(fs.existsSync(sessionFile), `Session filename should match the pre-fix CLAUDE_SESSION_ID-based name: ${sessionFile}`);
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
})
)
passed++;
else failed++;
if (
await asyncTest('writes project, branch, and worktree metadata into new session files', async () => {
const isoHome = path.join(os.tmpdir(), `ecc-session-metadata-${Date.now()}`);