fix(session-persistence): scope the ownerless-state claim to the cwd (review)
A reviewer found a cross-cwd hole: the ownerless-state claim path validated only the seed prefix (via loadStored, any scope) and never compared the tracked header's cwd to the live session's. So an ownerless `create(meta(id, "/a"))` with cursor 0 (seed matches trivially) was claimed by a live session with the same id at cwd "/b", and the "/b" events then appended under the "/a" header — bypassing the cwd-scoped loadLive() guard that the HMR-adopt path (case 2) uses. Add a cwd equality check before the seed check in the ownerless-claim branch: a same-id ownerless artifact at a different cwd is a collision, not a claim. This is a coordinator-level invariant (the live session's cwd must match the tracked meta's cwd) and applies to both backends. Tests (shared coordinator contract, run per backend): a live session at a different cwd cannot claim cursor-0 ownerless state, cannot claim a loaded-prefix even when the seed matches, and a no-cwd state cannot be claimed by a cwd'd session. All fail without the guard. Also documents WHY the `materialized` flag is needed (lazy create leaves no artifact; it distinguishes registered-but-unwritten from durably-present for has()/reclaim) and reframes the module doc to current-state, not the refactor history (per the new AGENTS.md doc convention).
This commit is contained in:
@@ -68,6 +68,7 @@ export interface CoordinatorFixture {
|
||||
|
||||
/** A constant absolute cwd; jsonl keys directories off it, memory/sqlite ignore it. */
|
||||
const WORK = '/w'
|
||||
const OTHER = '/other'
|
||||
|
||||
/** The per-session init map a backend exposes for white-box init awaits. */
|
||||
function inits(persistence: SessionPersistence): Map<Session, Promise<void>> {
|
||||
@@ -535,6 +536,58 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('a live session at a DIFFERENT cwd cannot claim cursor-0 ownerless state (cwd scope)', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
// create() registers ownerless state at cwd /a (cursor 0 — claims would
|
||||
// otherwise match trivially on the seed).
|
||||
await ctx.sessionPersistence.create(meta('wrong-cwd-claim', OTHER))
|
||||
// A live session reusing the id but at cwd WORK must NOT claim it — the
|
||||
// cwd scope is the fence (without it, WORK events would append under the
|
||||
// OTHER header). Rejected as a collision.
|
||||
const live = ctx.sessions.create('wrong-cwd-claim', { seed: oneTurnLog(), meta: { cwd: WORK } })
|
||||
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a live session at a DIFFERENT cwd cannot claim loaded-prefix ownerless state (cwd scope)', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
// Materialize + load at cwd OTHER (ownerless, cursor = 6).
|
||||
await ctx.sessionPersistence.create(meta('wrong-cwd-load', OTHER))
|
||||
await ctx.sessionPersistence.append(SessionId('wrong-cwd-load'), oneTurnLog())
|
||||
const { events } = await ctx.sessionPersistence.load(SessionId('wrong-cwd-load'))
|
||||
// A live session whose SEED matches the loaded prefix but whose cwd is
|
||||
// WORK must still be rejected — the cwd guard runs before the seed check.
|
||||
const live = ctx.sessions.create('wrong-cwd-load', { seed: events, meta: { cwd: WORK } })
|
||||
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
// Ownerless state created WITHOUT a cwd (the no-cwd bucket).
|
||||
await ctx.sessionPersistence.create(meta('no-cwd-state'))
|
||||
// A live session reusing the id but WITH cwd WORK is a cwd mismatch
|
||||
// (undefined vs WORK) and must be rejected.
|
||||
const live = ctx.sessions.create('no-cwd-state', { seed: oneTurnLog(), meta: { cwd: WORK } })
|
||||
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// --- append adopts a storage-only session (fresh instance, no prior create/load) ---
|
||||
|
||||
it('append adopts a storage-only session (fresh instance) and continues the seq', async () => {
|
||||
|
||||
Reference in New Issue
Block a user