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

@@ -591,17 +591,27 @@ export function apply(ctx: Context, config: AcpConfig): void {
return Promise.resolve()
},
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateWorkspaceParams(params)
validateMcpServers(params)
const sessionId = SessionId(randomUUID())
const handle = agents.create({
const handle = await agents.create({
agentId: AgentId(sessionId),
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
// Creation is now asynchronous because it awaits the unpublished setup
// transaction. A client disconnect can therefore close this bridge
// after the entry check but before the handle resolves; never install a
// post-close record that quiesce() could not have seen.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
immediately on close; real stdio may let the handler resume */
if (closed) {
await handle.dispose()
throw internalError('connection closed during session/new')
}
bySession.set(handle.agent, sessionId)
sessions.set(sessionId, {
sessionId,
@@ -611,7 +621,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
terminalEnabled: terminalOutputCap,
inflight: undefined,
})
return Promise.resolve({ sessionId })
return { sessionId }
},
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {

View File

@@ -229,10 +229,10 @@ describe('acp bridge — disposal & HMR safety', () => {
// dispose one handle, and assert the other survives, registered and
// queryable, with its session still in the store.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = harness.ctx.agents.create({
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
})
const handleB = harness.ctx.agents.create({
const handleB = await harness.ctx.agents.create({
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
})
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
@@ -261,7 +261,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// a clean turn, dispose, and assert the session was STILL removed.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = harness.ctx.agents.create({
const handle = await harness.ctx.agents.create({
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'go' }])
@@ -282,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// first call's await agent.done + final flush finished. Every caller must
// observe the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = harness.ctx.agents.create({
const handle = await harness.ctx.agents.create({
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
})
// Drive a turn that hangs in the model stream, so the loop is mid-turn when

View File

@@ -27,7 +27,7 @@ describe('acp bridge — demux & config edges', () => {
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const before = harness.updates.length
const { agent: foreign } = harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
foreign.send([{ type: 'text', text: 'hi' }])
await foreign.whenIdle()
await new Promise(r => setTimeout(r, 10))