diff --git a/scripts/lib/session-adapters/claude-history.js b/scripts/lib/session-adapters/claude-history.js index 8f5f07271..a42c46283 100644 --- a/scripts/lib/session-adapters/claude-history.js +++ b/scripts/lib/session-adapters/claude-history.js @@ -73,15 +73,7 @@ function resolveSessionRecord(target, cwd) { }; } - let alias; - let aliasReadError; - try { - alias = sessionAliases.resolveAlias(explicitTarget); - } catch (error) { - // Alias lookup is optional when the supplied identifier independently - // resolves to recorded history. Preserve the error otherwise. - aliasReadError = error; - } + const alias = sessionAliases.resolveAlias(explicitTarget); if (alias) { return { session: hydrateSessionFromPath(alias.sessionPath), @@ -94,7 +86,6 @@ function resolveSessionRecord(target, cwd) { const session = sessionManager.getSessionById(explicitTarget, true); if (!session) { - if (aliasReadError) throw aliasReadError; throw new Error(`Claude session not found: ${explicitTarget}`); } diff --git a/tests/lib/claude-history-alias-unavailable.test.js b/tests/lib/claude-history-alias-unavailable.test.js index ec92e3bc0..586b9527c 100644 --- a/tests/lib/claude-history-alias-unavailable.test.js +++ b/tests/lib/claude-history-alias-unavailable.test.js @@ -11,51 +11,82 @@ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-history- const data = path.join(root, '.claude'); const sessions = path.join(data, 'session-data'); fs.mkdirSync(sessions, { recursive: true }); -const id = '550e8400-e29b-41d4-a716-446655440000'; +const id = 'a1111111-e29b-41d4-a716-446655440000'; +const prefix = id.slice(0, 8); const name = `2026-10-10-${id}-session.tmp`; -const sessionFile = path.join(sessions, name); -fs.writeFileSync(sessionFile, '# History fixture\n**Worktree:** /fixture/project\n\n### Completed\n- [x] existing-history-sentinel\n'); +const sessionA = path.join(sessions, name); +const sessionB = path.join(sessions, '2026-10-10-b2222222-session.tmp'); +for (const [file, label] of [[sessionA, 'A'], [sessionB, 'B']]) { + fs.writeFileSync(file, `# History ${label}\n**Worktree:** /fixture/project\n\n### Completed\n- [x] history-${label}-sentinel\n`); +} const aliasPath = path.join(data, 'session-aliases.json'); -const aliasBytes = JSON.stringify({ version: '1.0', aliases: { kept: { sessionPath: sessionFile } } }); +const collisionNames = ['a', prefix, id, 'kept']; +const aliasBytes = JSON.stringify({ version: '1.0', aliases: Object.fromEntries( + collisionNames.map(alias => [alias, { sessionPath: sessionB }])) }); fs.writeFileSync(aliasPath, aliasBytes); const keys = ['HOME', 'USERPROFILE', 'ECC_AGENT_DATA_HOME']; const saved = Object.fromEntries(keys.map(key => [key, process.env[key]])); Object.assign(process.env, { HOME: root, USERPROFILE: root, ECC_AGENT_DATA_HOME: data }); -const adapter = createClaudeHistoryAdapter({ persistSnapshots: false }); +const recording = path.join(root, 'canonical-snapshot.json'); +const publications = []; +const adapter = createClaudeHistoryAdapter({ persistCanonicalSnapshotImpl(snapshot) { + publications.push(snapshot); + fs.writeFileSync(recording, JSON.stringify(snapshot)); +} }); let passed = 0; let failed = 0; function test(name, run) { try { run(); passed++; console.log(`PASS ${name}`); } catch (error) { failed++; console.error(`FAIL ${name}: ${error.stack}`); } } +function denyAliasReads(code, run) { + const originalRead = fs.readFileSync; + const expected = Object.assign(new Error('injected unreadable alias store'), { code }); + fs.readFileSync = function (file) { + if (file === aliasPath) throw expected; + return originalRead.apply(this, arguments); + }; + try { run(expected); } + finally { fs.readFileSync = originalRead; } + assert.strictEqual(fs.readFileSync(aliasPath, 'utf8'), aliasBytes); + assert.deepStrictEqual(fs.readdirSync(data).sort(), ['session-aliases.json', 'session-data']); +} try { - test('readable alias retains truthful claude-alias source identity', () => { - const snapshot = adapter.open('claude:kept').getSnapshot(); - assert.deepStrictEqual(snapshot.session.sourceTarget, { type: 'claude-alias', value: 'kept' }); - assert.strictEqual(snapshot.workers[0].artifacts.sessionFile, sessionFile); + test('readable aliases select B despite conflicting A prefixes and UUIDs', () => { + for (const alias of collisionNames) { + const snapshot = adapter.open(`claude:${alias}`).getSnapshot(); + assert.deepStrictEqual(snapshot.session.sourceTarget, { type: 'claude-alias', value: alias }); + assert.strictEqual(snapshot.workers[0].artifacts.sessionFile, sessionB); + assert.strictEqual(JSON.parse(fs.readFileSync(recording, 'utf8')).workers[0].artifacts.sessionFile, sessionB); + } assert.strictEqual(fs.readFileSync(aliasPath, 'utf8'), aliasBytes); }); for (const code of ['EACCES', 'EPERM']) { - test(`known history remains readable while aliases return ${code}, and unresolved targets still fail`, () => { - const originalRead = fs.readFileSync; - const expected = Object.assign(new Error('injected unreadable alias store'), { code }); - fs.readFileSync = function (file) { - if (file === aliasPath) throw expected; - return originalRead.apply(this, arguments); - }; - try { - for (const target of [id, name]) { - const snapshot = adapter.open(`claude:${target}`).getSnapshot(); - assert.deepStrictEqual(snapshot.session.sourceTarget, { type: 'claude-history', value: target }); - assert.strictEqual(snapshot.workers[0].artifacts.sessionFile, sessionFile); - assert.ok(snapshot.workers[0].outputs.summary.includes('existing-history-sentinel')); + test(`${code} alias collisions preserve errors and never publish another conversation`, () => { + adapter.open('claude:a').getSnapshot(); + const before = fs.readFileSync(recording, 'utf8'); + publications.length = 0; + denyAliasReads(code, expected => { + for (const target of [...collisionNames, 'does-not-exist']) { + let thrown; + try { adapter.open(`claude:${target}`).getSnapshot(); } catch (error) { thrown = error; } + assert.strictEqual(publications.length, 0, 'Unresolved alias identity must not publish any snapshot'); + assert.strictEqual(fs.readFileSync(recording, 'utf8'), before); + assert.strictEqual(thrown, expected); } - assert.throws(() => adapter.open('claude:does-not-exist').getSnapshot(), error => error === expected); - assert.throws(() => adapter.open('claude:kept').getSnapshot(), error => error === expected); assert.throws(() => aliases.setAlias('added', '/new'), error => error === expected); - } finally { fs.readFileSync = originalRead; } - assert.strictEqual(fs.readFileSync(aliasPath, 'utf8'), aliasBytes); - assert.deepStrictEqual(fs.readdirSync(data).sort(), ['session-aliases.json', 'session-data']); + }); + }); + test(`${code} still permits an exact filename with truthful history identity`, () => { + publications.length = 0; + denyAliasReads(code, () => { + const snapshot = adapter.open(`claude:${name}`).getSnapshot(); + assert.deepStrictEqual(snapshot.session.sourceTarget, { type: 'claude-history', value: name }); + assert.strictEqual(snapshot.workers[0].artifacts.sessionFile, sessionA); + assert.ok(snapshot.workers[0].outputs.summary.includes('history-A-sentinel')); + assert.strictEqual(publications.length, 1); + assert.strictEqual(JSON.parse(fs.readFileSync(recording, 'utf8')).workers[0].artifacts.sessionFile, sessionA); + }); }); } } finally {