fix(core): enforce agent-scoped ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-11 22:55:26 +08:00
parent 850796bb35
commit 3263dab822
62 changed files with 3982 additions and 857 deletions

View File

@@ -538,8 +538,12 @@ export class SessionStore extends Service {
const emitCtx = this.ctx
session.onAppend = (event) => { emitCtx.emit(carrier, 'session/event', session, event) }
this.store.set(session.id, session)
let entered = true
return () => {
if (!entered) return
entered = false
session.onAppend = undefined
this.carriers.delete(session)
this.store.delete(session.id)
}
}
@@ -549,7 +553,7 @@ export class SessionStore extends Service {
* yield the detach disposer first (rollback safety — see {@link enter}).
* @param session - the entered session to announce to listeners. */
announce(session: Session): void {
this.ctx.emit(this.carrierFor(session), 'session/created', session)
this.ctx.emit(this.liveCarrierFor(session), 'session/created', session)
}
/**
@@ -563,13 +567,23 @@ export class SessionStore extends Service {
* @returns resolves when every flush listener has settled; rejects if one rejects.
*/
async flush(session: Session): Promise<void> {
await this.ctx.parallel(this.carrierFor(session), 'session/flush', session)
await this.ctx.parallel(this.liveCarrierFor(session), 'session/flush', session)
}
/** The carrier {@link enter} captured, or a subject-less one for a session
* never entered (defensive: dispatch stays filtered either way). */
private carrierFor(session: Session): Scoped<Session> {
return this.carriers.get(session) ?? scopeTarget(session, undefined)
/** Return the exact live session's carrier; detached/prepared objects reject. */
private liveCarrierFor(session: Session): Scoped<Session> {
if (this.store.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const carrier = this.carriers.get(session)
// enter() installs store + carrier in one synchronous sequence; a live
// session without one is an internal invariant violation, never fallback
// to subject-less dispatch (that would silently cross scope boundaries).
/* v8 ignore next -- enter installs store and carrier in one synchronous sequence */
if (carrier === undefined) {
throw new Error(`session "${session.id}" has no dispatch carrier`)
}
return carrier
}
/**

View File

@@ -91,16 +91,33 @@ describe('sessions.flush()', () => {
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
})
it('flushes a never-entered session with a subject-less carrier (defensive path)', async () => {
it('rejects a never-entered session instead of inventing a carrier', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const flushed: string[] = []
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
const detached = ctx.sessions.prepare()
await ctx.sessions.flush(detached)
expect(flushed).toEqual([`global:${detached.id}`])
const prepared = ctx.sessions.prepare()
await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
expect(flushed).toEqual([])
})
it('clears a detached carrier and rejects stale flushes', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')
const flushed: string[] = []
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
const session = scope.ctx.sessions.prepare()
const detach = scope.ctx.sessions.enter(session)
await ctx.sessions.flush(session)
expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`])
detach()
await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/)
expect(flushed).toHaveLength(2)
})
it('keyOf sanity: distinct scopes carry distinct keys', async () => {

View File

@@ -289,6 +289,7 @@ describe('SessionStore', () => {
expect(created).toEqual([session])
// The detach disposer removes the entry + stops notification.
detach()
detach() // idempotent: cannot disturb a later same-id lifecycle
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})