diff --git a/packages/agentstack/PRD.md b/packages/agentstack/PRD.md index e514ac2..b18e861 100644 --- a/packages/agentstack/PRD.md +++ b/packages/agentstack/PRD.md @@ -74,7 +74,8 @@ Backed by `src/index.ts`, `src/types.ts`, `src/manifest.ts`: - **Status lifecycle** — `pending → queued → running → blocked → complete | failed | cancelled`. - **`AgentStack` coordinator** (in-memory `Map` storage, injectable clock): `registerAgent`, `getAgent`, `createTask`, `getTask`, `assignTask`, `updateTaskStatus`, - `delegate`, `revokeDelegation`, `listTasks`, `snapshot`, and an `on(listener)` event subscription. + `delegate`, `revokeDelegation`, `listDelegations`, `hasDelegation`, `listTasks`, `snapshot`, + and an `on(listener)` event subscription. - **Events** — `agent.registered`, `task.created`, `task.assigned`, `task.updated`, `delegation.granted`, `delegation.revoked`. - **`agentStackPlugin`** — a LogicSRC `PluginDefinition`, plus `agentStackManifest` declaring diff --git a/packages/agentstack/README.md b/packages/agentstack/README.md index 131f12e..8cceff9 100644 --- a/packages/agentstack/README.md +++ b/packages/agentstack/README.md @@ -15,7 +15,8 @@ Shared AppKit OpenSpec (`profullstack-web/openspec/specs/agentstack`). (`pending → queued → running → blocked → complete | failed | cancelled`) that can bind to payment, escrow, and reputation events. - **`AgentStack`** — an in-memory coordinator that registers agents, tracks tasks, records - delegation grants, and emits coordination events. Storage backends can wrap the same API. + delegation grants, checks active scoped authority, and emits coordination events. Storage + backends can wrap the same API. - **`agentStackPlugin`** — a LogicSRC `PluginDefinition` (validated against the plugin manifest schema) exposing AgentStack as a coordination plugin with routes, events, permissions, and a TUI panel. @@ -39,6 +40,10 @@ const task = stack.createTask({ ownerDid: owner, sourceApp: "sh1pt.com", title: stack.assignTask(task.id, agentDid("abc")); stack.updateTaskStatus(task.id, "running"); stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" }); + +const grant = stack.delegate(owner, agentDid("abc"), ["tasks:create"]); +stack.hasDelegation(owner, agentDid("abc"), "tasks:create"); // true while the grant is active +stack.revokeDelegation(grant.id); ``` ## Cross-app identity diff --git a/packages/agentstack/src/index.test.ts b/packages/agentstack/src/index.test.ts index b262257..0da66cf 100644 --- a/packages/agentstack/src/index.test.ts +++ b/packages/agentstack/src/index.test.ts @@ -120,15 +120,38 @@ describe("AgentStack coordinator", () => { it("records delegation grants and emits events", () => { const stack = new AgentStack(); const listener = vi.fn(); - stack.on(listener); - stack.registerAgent(agent); - const grant = stack.delegate(owner, agent.did, ["tasks:create"]); - - expect(grant.ownerDid).toBe(owner); - expect(grant.agentDid).toBe(agent.did); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" })); - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" })); - }); + stack.on(listener); + stack.registerAgent(agent); + const grant = stack.delegate(owner, agent.did, ["tasks:create"]); + + expect(grant.ownerDid).toBe(owner); + expect(grant.agentDid).toBe(agent.did); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "agent.registered" })); + expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: "delegation.granted" })); + }); + + it("checks active delegation authority by owner, agent, scope, and expiry", () => { + const stack = new AgentStack(() => "2026-01-01T00:00:00.000Z"); + stack.registerAgent(agent); + + stack.delegate(owner, agent.did, ["tasks:create"], "2026-01-02T00:00:00.000Z"); + + expect(stack.hasDelegation(owner, agent.did, "tasks:create")).toBe(true); + expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(false); + expect(stack.hasDelegation(owner, agent.did, "tasks:create", "2026-01-03T00:00:00.000Z")).toBe(false); + expect(stack.listDelegations({ ownerDid: owner, agentDid: agent.did, scope: "tasks:create" })).toHaveLength(1); + expect(stack.listDelegations({ activeAt: "2026-01-03T00:00:00.000Z" })).toHaveLength(0); + }); + + it("allows wildcard delegation scopes and ignores revoked grants", () => { + const stack = new AgentStack(); + stack.registerAgent(agent); + const grant = stack.delegate(owner, agent.did, ["*"]); + + expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(true); + stack.revokeDelegation(grant.id); + expect(stack.hasDelegation(owner, agent.did, "tasks:update")).toBe(false); + }); it("rejects unknown agents and invalid DIDs", () => { const stack = new AgentStack(); diff --git a/packages/agentstack/src/index.ts b/packages/agentstack/src/index.ts index 1aec33d..49d3473 100644 --- a/packages/agentstack/src/index.ts +++ b/packages/agentstack/src/index.ts @@ -178,18 +178,46 @@ export class AgentStack { return grant; } - revokeDelegation(grantId: string): DelegationGrant { - const grant = this.delegations.get(grantId); - if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`); - this.delegations.delete(grantId); - this.emit({ type: "delegation.revoked", grant }); - return grant; - } - - listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] { - return [...this.tasks.values()].filter((task) => { - if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false; - if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false; + revokeDelegation(grantId: string): DelegationGrant { + const grant = this.delegations.get(grantId); + if (!grant) throw new Error(`Unknown delegation grant: ${grantId}`); + this.delegations.delete(grantId); + this.emit({ type: "delegation.revoked", grant }); + return grant; + } + + listDelegations(filter?: { + ownerDid?: string; + agentDid?: string; + scope?: string; + activeAt?: string; + }): DelegationGrant[] { + return [...this.delegations.values()].filter((grant) => { + if (filter?.ownerDid && grant.ownerDid !== filter.ownerDid) return false; + if (filter?.agentDid && grant.agentDid !== filter.agentDid) return false; + if (filter?.scope && !this.grantAllowsScope(grant, filter.scope)) return false; + if (filter?.activeAt && !this.grantIsActive(grant, filter.activeAt)) return false; + return true; + }); + } + + hasDelegation(ownerDidValue: string, agentDidValue: string, scope: string, at: string = this.now()): boolean { + if (!parseDid(ownerDidValue)) throw new Error(`Invalid owner DID: ${ownerDidValue}`); + if (!this.agents.has(agentDidValue)) throw new Error(`Unknown agent: ${agentDidValue}`); + return ( + this.listDelegations({ + ownerDid: ownerDidValue, + agentDid: agentDidValue, + scope, + activeAt: at + }).length > 0 + ); + } + + listTasks(filter?: { ownerDid?: string; assigneeDid?: string; status?: TaskStatus }): DidTask[] { + return [...this.tasks.values()].filter((task) => { + if (filter?.ownerDid && task.ownerDid !== filter.ownerDid) return false; + if (filter?.assigneeDid && task.assigneeDid !== filter.assigneeDid) return false; if (filter?.status && task.status !== filter.status) return false; return true; }); @@ -205,10 +233,18 @@ export class AgentStack { private requireTask(id: string): DidTask { const task = this.tasks.get(id); - if (!task) throw new Error(`Unknown task: ${id}`); - return task; - } -} + if (!task) throw new Error(`Unknown task: ${id}`); + return task; + } + + private grantAllowsScope(grant: DelegationGrant, scope: string): boolean { + return grant.scopes.includes(scope) || grant.scopes.includes("*"); + } + + private grantIsActive(grant: DelegationGrant, at: string): boolean { + return !grant.expiresAt || grant.expiresAt > at; + } +} /** LogicSRC plugin definition exposing AgentStack as a coordination plugin. */ export const agentStackPlugin: PluginDefinition = {