fix: verify JSON-RPC child ownership

This commit is contained in:
Tianyi Cui
2026-07-14 09:46:05 +08:00
parent c84fc5d7f2
commit 2333ab19e3
7 changed files with 68 additions and 14 deletions

View File

@@ -13,6 +13,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
- `ctx.agents.get(id: SessionId): Agent | undefined`
- `ctx.agents.isOwnedBy(id: SessionId, owner: Agent): boolean` — whether the exact live entry was created through that parent agent's scoped context; runtime ownership is independent of durable session lineage.
- `ctx.agents.list(): Agent[]`
- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root.

View File

@@ -440,6 +440,18 @@ export class AgentRegistry extends Service {
return this.store.get(id)?.agent
}
/**
* Test whether a live agent was created through one exact parent agent's
* scoped context. Runtime ownership is independent of durable session
* lineage and remains unambiguous when unrelated providers reuse an id.
* @param id - the candidate child agent's shared agent/session id.
* @param owner - the expected runtime creator agent.
* @returns true only while the exact child entry is live under that owner.
*/
isOwnedBy(id: SessionId, owner: Agent): boolean {
return this.store.get(id)?.owner === owner
}
/**
* All live agents, in registration order.
* @returns a fresh array; mutating it does not affect the registry.

View File

@@ -72,8 +72,12 @@ describe('AgentRegistry', () => {
expect(ctx.agents.list()).toEqual([root, child])
expect(ctx.agents.roots()).toEqual([root])
expect(ctx.agents.isOwnedBy(child.id, root)).toBe(true)
expect(ctx.agents.isOwnedBy(root.id, root)).toBe(false)
expect(ctx.agents.isOwnedBy(SessionId('missing'), root)).toBe(false)
detachChild()
expect(ctx.agents.isOwnedBy(child.id, root)).toBe(false)
detachRoot()
})