From b5bed87064e452e31b04519d18a4e7e84dd9e2d2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:02:14 +0100 Subject: [PATCH 1/2] feat(core): add CapabilityAskAdmission for the ask tier's held-open capability requests Holds an incoming capability-request open exactly as RoomProtocol's own pendingRoomJoins and ConnectionApproval's own pendingInboundConnections already do, surfacing each one as a capability_request event a human can act on. Built directly on wire-mesh-core's createCapabilityRequestHandler (the same ask primitive agent-comms#164's bubble-up module builds on), so accepting a held ask mints and returns the granted token exactly as core/room's own room.join admission does. --- src/core/capability-ask.ts | 162 ++++++++++++++ src/test/capability-ask.test.ts | 375 ++++++++++++++++++++++++++++++++ 2 files changed, 537 insertions(+) create mode 100644 src/core/capability-ask.ts create mode 100644 src/test/capability-ask.test.ts diff --git a/src/core/capability-ask.ts b/src/core/capability-ask.ts new file mode 100644 index 00000000..11d18e82 --- /dev/null +++ b/src/core/capability-ask.ts @@ -0,0 +1,162 @@ +/** + * CapabilityAskAdmission — the ask tier surfaced at the tool layer (agent-comms#165): the generic, capability-agnostic counterpart to RoomProtocol's own pendingRoomJoins and ConnectionApproval's own pendingInboundConnections, built directly on wire-mesh-core's createCapabilityRequestHandler (the ask primitive agent-comms#164's own bubble-up module already builds on top of, wire-mesh#78). Three tiers exist at the tool layer for whether an action gets a capability token: allow (a held token already answers canGrant), deny (canGrant says no and resolveBubbleUpRoute finds no route to bubble the ask up to either), and ask -- a capability-request held open pending a human decision, which is what this module gives a name and a surface to. A capability-gated verb that lands in that third tier registers a handler built by createAskHandler below instead of failing outright: the request is held open exactly as core/room's own room.join admission holds one open, surfaced to the owning agent as a capability_request delivery event a human can act on (the approval prompt), and the outcome (acceptCapabilityRequest/rejectCapabilityRequest) resolves the held request in place, minting the granted token on acceptance -- the same accept/reject/list shape room_accept/room_reject/room_pending and mesh_accept/mesh_reject/mesh_pending already establish in tool.ts, generalised the way the issue's own framing asks for ("mirror its shape for capability requests generally"). With only one user-principal identity per machine today (agent-comms#160/#161), "the right device or principal" the issue's own framing anticipates routing an ask to is currently always this device -- the same simplification core/room's own single-decision-maker admission already makes; createAskHandler is the integration point a future capability-gated verb (e.g. agent-comms#162's dm:send) registers against its own session to get a ready `(incoming) => Promise` handler backed by this admission's live pending state. + */ + +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { createCapabilityRequestHandler } from "wire-mesh-core/domain/capability-request"; +import type { + CapabilityGrantRequestEvent, + CapabilityGrantDecision, +} from "wire-mesh-core/domain/capability-request"; +import type { IncomingManageRequest } from "wire-mesh-core/domain/mesh-session"; +import type { + CapabilityScope, + DeviceId, +} from "wire-mesh-core/generated/protocol"; +import type { Clock } from "wire-mesh-core/ports/clock"; +import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import { nanoid } from "./nanoid.js"; +import { CommsError } from "./store.js"; +import type { DeliveryEngine } from "./delivery-engine.js"; +import type { DeliveryEvent } from "./types.js"; + +/** The state and collaborators CapabilityAskAdmission needs from MeshStore -- getPeerId/getOnDelivery/queueDelivery mirror ConnectionApprovalDeps exactly, since surfacing a capability_request event to the owning agent is the identical "queue it, then push it live if a callback is registered" mechanism connection_request already uses. */ +export interface CapabilityAskAdmissionDeps { + getPeerId: () => string; + getOnDelivery: () => + | ((agentId: string, event: DeliveryEvent) => void | Promise) + | undefined; + queueDelivery: DeliveryEngine["queueDelivery"]; +} + +export interface CreateCapabilityAskHandlerOptions { + /** The capability this handler surfaces asks for -- one handler per capability, the same convention createCapabilityRequestHandler and createBubbleUpCapabilityRequestHandler already establish. */ + capability: string; + identity: IdentityPort; + clock: Clock; + /** The peer device-id authenticated on this session's own connection -- the requester, and so the future bearer of any token accepting the ask mints. */ + bearerDevice: DeviceId; + /** Receiver-side auto-reject window (wire-mesh#81): how long the ask may sit awaiting a human decision before wire-mesh-core's own createCapabilityRequestHandler responds with a manage-error timeout on this admission's behalf. */ + timeoutMs: number; +} + +interface PendingCapabilityAsk { + capability: string; + scope: Readonly; + requesterDevice: DeviceId; + decide: (decision: Readonly) => Promise; +} + +export class CapabilityAskAdmission { + private readonly pending = new Map(); + + constructor(private readonly deps: Readonly) {} + + /** Builds a manage-request handler for one capability's incoming capability-requests that never decides on its own: every not-malformed, not-yet-expired ask is held open and surfaced as a capability_request delivery event, exactly like handleConnectionRequest already does for an inbound mesh connection. */ + createAskHandler( + options: Readonly, + ): (incoming: Readonly) => Promise { + return createCapabilityRequestHandler({ + capability: options.capability, + identity: options.identity, + clock: options.clock, + bearerDevice: options.bearerDevice, + timeoutMs: options.timeoutMs, + onRequest: (event: Readonly) => { + this.handleCapabilityRequest(options.capability, event); + }, + }); + } + + private handleCapabilityRequest( + capability: string, + event: Readonly, + ): void { + const requestId = nanoid(); + this.pending.set(requestId, { + capability, + scope: event.scope, + requesterDevice: event.requesterDevice, + decide: event.decide, + }); + + const deliveryEvent: DeliveryEvent = { + type: "capability_request", + requestId, + capability, + scopeKind: event.scope.kind, + ...(event.scope.path !== undefined + ? { scopePath: event.scope.path } + : {}), + requesterDevice: deviceIdToHex(event.requesterDevice), + }; + const peerId = this.deps.getPeerId(); + this.deps.queueDelivery(peerId, deliveryEvent); + const onDelivery = this.deps.getOnDelivery(); + if (onDelivery) { + void onDelivery(peerId, deliveryEvent); + } + } + + /** Every capability-request currently held open awaiting a human decision. */ + listPendingCapabilityRequests(): { + requestId: string; + capability: string; + scopeKind: string; + scopePath?: string; + requesterDevice: string; + }[] { + return [...this.pending.entries()].map(([requestId, ask]) => ({ + requestId, + capability: ask.capability, + scopeKind: ask.scope.kind, + ...(ask.scope.path !== undefined ? { scopePath: ask.scope.path } : {}), + requesterDevice: deviceIdToHex(ask.requesterDevice), + })); + } + + /** Approves a pending capability-request, resolving its held decide() with an accept -- wire-mesh-core's own createCapabilityRequestHandler mints the granted token and responds on this admission's behalf. `capability`, when given, grants something narrower than what was originally asked for (the primitive's own CapabilityGrantDecision allows this); absent, the request's own originally-asked-for capability is granted unchanged. */ + async acceptCapabilityRequest( + requestId: string, + options: Readonly<{ + expires: number; + delegationsRemaining?: number; + capability?: string; + }>, + ): Promise { + const pending = this.requirePending(requestId); + this.pending.delete(requestId); + await pending.decide({ + kind: "accept", + capability: options.capability ?? pending.capability, + expires: options.expires, + ...(options.delegationsRemaining !== undefined + ? { delegationsRemaining: options.delegationsRemaining } + : {}), + }); + } + + /** Denies a pending capability-request, optionally with a reason surfaced to the requester in the resulting manage-error's own message field. */ + async rejectCapabilityRequest( + requestId: string, + reason?: string, + ): Promise { + const pending = this.requirePending(requestId); + this.pending.delete(requestId); + await pending.decide({ + kind: "reject", + ...(reason !== undefined ? { reason } : {}), + }); + } + + private requirePending(requestId: string): PendingCapabilityAsk { + const pending = this.pending.get(requestId); + if (pending === undefined) { + throw new CommsError( + `No pending capability request ${requestId}`, + "NOT_PENDING", + ); + } + return pending; + } +} diff --git a/src/test/capability-ask.test.ts b/src/test/capability-ask.test.ts new file mode 100644 index 00000000..882b7b59 --- /dev/null +++ b/src/test/capability-ask.test.ts @@ -0,0 +1,375 @@ +/** + * Direct, DI-based unit tests for CapabilityAskAdmission -- mirrors connection-approval.test.ts's own approach (a narrow, injectable deps surface, fake collaborators via vi.fn()) so every branch of the ask tier's tool-layer surfacing (agent-comms#165) is asserted on directly rather than only indirectly through a live transport. + */ +import { webcrypto } from "node:crypto"; +import { describe, it, expect, vi } from "vitest"; +import { createNodeIdentity } from "wire-mesh-core/adapters/node-identity"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { verifyCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import type { Clock } from "wire-mesh-core/ports/clock"; +import type { IncomingManageRequest } from "wire-mesh-core/domain/mesh-session"; +import { + CapabilityAskAdmission, + type CapabilityAskAdmissionDeps, +} from "../core/capability-ask.js"; +import type { DeliveryEvent } from "../core/types.js"; + +const ES256 = -7; +const NOW_MS = 1_893_456_000_000; +const HOUR_MS = 3_600_000; +const EXPIRES_MS = NOW_MS + HOUR_MS; +const CAPABILITY = "dm:send"; +const SCOPE = { kind: "user" as const }; +const OWNER_ID = "owner-peer"; + +async function generateEs256Identity(): Promise { + const keyPair = await webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const publicKeyBytes = new Uint8Array( + await webcrypto.subtle.exportKey("raw", keyPair.publicKey), + ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); +} + +function fixedClock(atMs: number): Clock { + return { now: () => atMs }; +} + +interface Harness { + deps: CapabilityAskAdmissionDeps; + admission: CapabilityAskAdmission; + queueDelivery: ReturnType; + onDelivery: ReturnType | undefined; +} + +function makeHarness( + options: Readonly<{ withOnDelivery?: boolean }> = {}, +): Harness { + const queueDelivery = vi.fn(); + const onDelivery = options.withOnDelivery === true ? vi.fn() : undefined; + const deps: CapabilityAskAdmissionDeps = { + getPeerId: () => OWNER_ID, + getOnDelivery: () => onDelivery, + queueDelivery, + }; + return { + deps, + admission: new CapabilityAskAdmission(deps), + queueDelivery, + onDelivery, + }; +} + +type FakeIncoming = IncomingManageRequest & { + respond: ReturnType; +}; + +let nextRequestId = 0; +function fakeIncoming(params: Record): FakeIncoming { + nextRequestId += 1; + return { + requestId: nextRequestId, + command: { verb: CAPABILITY, params }, + scope: SCOPE, + respond: vi.fn(async () => undefined), + }; +} + +describe("CapabilityAskAdmission — createAskHandler", () => { + it("holds the request open, queues a capability_request delivery event, and touches neither respond nor onDelivery's own callback synchronously", async () => { + const h = makeHarness({ withOnDelivery: true }); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + const incoming = fakeIncoming({ + verb: "capability.request", + capability: CAPABILITY, + }); + // The handler awaits only until onRequest is called, never until decide() settles -- the ask stays held open, so this resolves without ever calling respond(). + await handler(incoming); + + expect(incoming.respond).not.toHaveBeenCalled(); + expect(h.queueDelivery).toHaveBeenCalledTimes(1); + const [deliveredTo, event] = h.queueDelivery.mock.calls[0] as [ + string, + DeliveryEvent, + ]; + expect(deliveredTo).toBe(OWNER_ID); + expect(event).toEqual({ + type: "capability_request", + requestId: expect.any(String), + capability: CAPABILITY, + scopeKind: "user", + requesterDevice: deviceIdToHex(requester.deviceId), + }); + expect(h.onDelivery).toHaveBeenCalledTimes(1); + }); + + it("includes scopePath in the delivery event when the incoming request's own scope carries one", async () => { + const h = makeHarness(); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + nextRequestId += 1; + const incoming: FakeIncoming = { + requestId: nextRequestId, + command: { + verb: CAPABILITY, + params: { verb: "capability.request", capability: CAPABILITY }, + }, + scope: { kind: "room", path: "abc/general" }, + respond: vi.fn(async () => undefined), + }; + await handler(incoming); + + const [, event] = h.queueDelivery.mock.calls[0] as [string, DeliveryEvent]; + expect(event).toMatchObject({ + scopeKind: "room", + scopePath: "abc/general", + }); + }); + + it("responds malformed for a request naming a different capability, and never surfaces it as a pending ask", async () => { + const h = makeHarness(); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + const incoming = fakeIncoming({ + verb: "capability.request", + capability: "room:member", + }); + await handler(incoming); + + expect(incoming.respond).toHaveBeenCalledWith({ + result: "error", + code: "malformed", + }); + expect(h.queueDelivery).not.toHaveBeenCalled(); + expect(h.admission.listPendingCapabilityRequests()).toEqual([]); + }); +}); + +describe("CapabilityAskAdmission — listPendingCapabilityRequests / acceptCapabilityRequest / rejectCapabilityRequest", () => { + it("lists every still-held ask with its capability, scope, and requester", async () => { + const h = makeHarness(); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + await handler( + fakeIncoming({ verb: "capability.request", capability: CAPABILITY }), + ); + + expect(h.admission.listPendingCapabilityRequests()).toEqual([ + { + requestId: expect.any(String), + capability: CAPABILITY, + scopeKind: "user", + requesterDevice: deviceIdToHex(requester.deviceId), + }, + ]); + }); + + it("accepting a pending ask mints a granted token, responds ok, and removes it from the pending list", async () => { + const h = makeHarness(); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + const incoming = fakeIncoming({ + verb: "capability.request", + capability: CAPABILITY, + }); + await handler(incoming); + const [requestId] = h.admission + .listPendingCapabilityRequests() + .map((p) => p.requestId); + if (requestId === undefined) throw new Error("no pending request"); + + await h.admission.acceptCapabilityRequest(requestId, { + expires: EXPIRES_MS, + delegationsRemaining: 1, + }); + + expect(incoming.respond).toHaveBeenCalledTimes(1); + const outcome = incoming.respond.mock.calls[0]?.[0] as { + result: string; + "granted-token": Parameters[0]; + }; + expect(outcome.result).toBe("ok"); + + const verdict = await verifyCapabilityToken(outcome["granted-token"], { + identity, + clock: fixedClock(NOW_MS), + revocation: createRevocationView(), + expectedBearer: requester.deviceId, + }); + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect(verdict.claims.capability).toBe(CAPABILITY); + } + expect(h.admission.listPendingCapabilityRequests()).toEqual([]); + }); + + it("rejecting a pending ask responds denied with the given reason and removes it from the pending list", async () => { + const h = makeHarness(); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + const incoming = fakeIncoming({ + verb: "capability.request", + capability: CAPABILITY, + }); + await handler(incoming); + const [requestId] = h.admission + .listPendingCapabilityRequests() + .map((p) => p.requestId); + if (requestId === undefined) throw new Error("no pending request"); + + await h.admission.rejectCapabilityRequest(requestId, "not right now"); + + expect(incoming.respond).toHaveBeenCalledWith({ + result: "error", + code: "denied", + message: "not right now", + }); + expect(h.admission.listPendingCapabilityRequests()).toEqual([]); + }); + + it("rejects with no message field when no reason is given", async () => { + const h = makeHarness(); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + const incoming = fakeIncoming({ + verb: "capability.request", + capability: CAPABILITY, + }); + await handler(incoming); + const [requestId] = h.admission + .listPendingCapabilityRequests() + .map((p) => p.requestId); + if (requestId === undefined) throw new Error("no pending request"); + + await h.admission.rejectCapabilityRequest(requestId); + + expect(incoming.respond).toHaveBeenCalledWith({ + result: "error", + code: "denied", + }); + }); + + it("throws naming the exact unknown request id when accepting, and never touches decide", async () => { + const h = makeHarness(); + await expect( + h.admission.acceptCapabilityRequest("no-such-request", { + expires: EXPIRES_MS, + }), + ).rejects.toThrow("No pending capability request no-such-request"); + }); + + it("throws naming the exact unknown request id when rejecting", async () => { + const h = makeHarness(); + await expect( + h.admission.rejectCapabilityRequest("no-such-request"), + ).rejects.toThrow("No pending capability request no-such-request"); + }); + + it("grants a narrower capability than requested when the accept decision names one explicitly", async () => { + const h = makeHarness(); + const identity = await generateEs256Identity(); + const requester = await generateEs256Identity(); + const handler = h.admission.createAskHandler({ + capability: CAPABILITY, + identity, + clock: fixedClock(NOW_MS), + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + + const incoming = fakeIncoming({ + verb: "capability.request", + capability: CAPABILITY, + }); + await handler(incoming); + const [requestId] = h.admission + .listPendingCapabilityRequests() + .map((p) => p.requestId); + if (requestId === undefined) throw new Error("no pending request"); + + await h.admission.acceptCapabilityRequest(requestId, { + expires: EXPIRES_MS, + capability: "dm:send-narrow", + }); + + const outcome = incoming.respond.mock.calls[0]?.[0] as { + result: string; + "granted-token": Parameters[0]; + }; + const verdict = await verifyCapabilityToken(outcome["granted-token"], { + identity, + clock: fixedClock(NOW_MS), + revocation: createRevocationView(), + expectedBearer: requester.deviceId, + }); + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect(verdict.claims.capability).toBe("dm:send-narrow"); + } + }); +}); From f5fe9d799e3d077befb0ca1dcae2e2783716cf1a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:03:50 +0100 Subject: [PATCH 2/2] feat(core): surface capability_request events and the accept/reject/pending actions Adds a capability_request DeliveryEvent variant and capability_pending/ capability_accept/capability_reject CommsAction variants, wires them through MeshStore (constructing a CapabilityAskAdmission and exposing createCapabilityAskHandler as the registration point a capability-gated verb uses) and CommsTool, and registers the new actions in the MCP tool schema and every exhaustive DeliveryEvent switch. --- src/bridges/user/tui.ts | 2 + src/core/bridge.ts | 36 +++ src/core/mesh-store.ts | 71 ++++++ src/core/tool.ts | 80 ++++++ src/core/types.ts | 21 ++ .../capability-admission-tool-actions.test.ts | 241 ++++++++++++++++++ 6 files changed, 451 insertions(+) create mode 100644 src/test/capability-admission-tool-actions.test.ts diff --git a/src/bridges/user/tui.ts b/src/bridges/user/tui.ts index 33d0fd37..d077fc9b 100644 --- a/src/bridges/user/tui.ts +++ b/src/bridges/user/tui.ts @@ -286,6 +286,8 @@ function formatForTerminal(event: DeliveryEvent): string { return `${CYAN}✎ ${event.oldName} is now ${event.newName}${RESET}`; case "connection_request": return `${CYAN}🔗 Connection request from ${event.peerId} (${event.name})${RESET}`; + case "capability_request": + return `${CYAN}🔑 ${event.requesterDevice} is asking for "${event.capability}" (${event.requestId})${RESET}`; default: return event satisfies never; } diff --git a/src/core/bridge.ts b/src/core/bridge.ts index c79afc48..c92f77b1 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -55,6 +55,9 @@ export const MCP_TOOL_PARAMS = z.object({ "room_accept", "room_reject", "room_pending", + "capability_accept", + "capability_reject", + "capability_pending", "mesh_discover", "mesh_advertise", "mesh_unadvertise", @@ -88,6 +91,10 @@ export const MCP_TOOL_PARAMS = z.object({ connectionId: z.string().optional(), requesterId: z.string().optional(), streamingBehavior: z.enum(["steer", "followUp", "info"]).optional(), + requestId: z.string().optional(), + capability: z.string().optional(), + expires: z.number().optional(), + delegationsRemaining: z.number().optional(), }); export type ToolParams = z.infer; @@ -287,6 +294,32 @@ export function buildAction(params: Record): CommsAction { } case "room_pending": return { action: "room_pending" }; + case "capability_accept": { + if (p.requestId === undefined) + throw new BuildActionError("capability_accept", "requestId"); + if (p.expires === undefined) + throw new BuildActionError("capability_accept", "expires"); + return { + action: "capability_accept", + requestId: p.requestId, + expires: p.expires, + ...(p.delegationsRemaining !== undefined && { + delegationsRemaining: p.delegationsRemaining, + }), + ...(p.capability !== undefined && { capability: p.capability }), + }; + } + case "capability_reject": { + if (p.requestId === undefined) + throw new BuildActionError("capability_reject", "requestId"); + return { + action: "capability_reject", + requestId: p.requestId, + ...(p.reason !== undefined && { reason: p.reason }), + }; + } + case "capability_pending": + return { action: "capability_pending" }; case "mesh_discover": { const discover: CommsAction & { action: "mesh_discover" } = { action: "mesh_discover", @@ -401,6 +434,8 @@ export function formatDeliveryEvent(event: DeliveryEvent): string { return `${event.oldName} is now known as ${event.newName}`; case "connection_request": return `Connection request from ${event.peerId} (${event.name}) fingerprint ${event.fingerprint}`; + case "capability_request": + return `${event.requesterDevice} is asking for "${event.capability}" (${event.requestId})`; default: return event satisfies never; } @@ -432,6 +467,7 @@ export function isActionableEvent(event: DeliveryEvent): boolean { case "invite_declined": case "name_changed": case "connection_request": + case "capability_request": return false; default: return event satisfies never; diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index ba8b12d2..374d6f03 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -23,6 +23,9 @@ import { RoomMessaging } from "./room-messaging.js"; import { RoomLifecycle } from "./room-lifecycle.js"; import { AgentRegistry } from "./agent-registry.js"; import { ConnectionApproval } from "./connection-approval.js"; +import { CapabilityAskAdmission } from "./capability-ask.js"; +import type { IncomingManageRequest } from "wire-mesh-core/domain/mesh-session"; +import type { DeviceId } from "wire-mesh-core/generated/protocol"; import { StaleAgentChecker } from "./stale-agent-checker.js"; import { PeerLifecycle } from "./peer-lifecycle.js"; import type { RoomVerbHandler } from "./room-router.js"; @@ -100,6 +103,7 @@ export class MeshStore implements CommsStore { private readonly roomLifecycle: RoomLifecycle; private readonly agentRegistry: AgentRegistry; private readonly connectionApproval: ConnectionApproval; + private readonly capabilityAskAdmission: CapabilityAskAdmission; private readonly staleAgentChecker: StaleAgentChecker; private readonly coordinatorGateway: CoordinatorGateway; private readonly peerLifecycle: PeerLifecycle; @@ -271,6 +275,14 @@ export class MeshStore implements CommsStore { }, }); + this.capabilityAskAdmission = new CapabilityAskAdmission({ + getPeerId: () => this.peerId, + getOnDelivery: () => this.onDelivery, + queueDelivery: (agentId, event) => { + this.deliveryEngine.queueDelivery(agentId, event); + }, + }); + this.staleAgentChecker = new StaleAgentChecker({ agents: this.agents, deliveryQueues: this.deliveryQueues, @@ -715,6 +727,65 @@ export class MeshStore implements CommsStore { this.roomProtocol.rejectRoomJoin(roomPath, requesterId, reason); } + // ----------------------------------------------------------------------- + // Capability-request ask tier (mesh-only, agent-comms#165) + // ----------------------------------------------------------------------- + + /** Builds a manage-request handler surfacing one capability's incoming capability-requests as held-open asks (agent-comms#165's own tool-layer surface for the ask tier) -- the registration point a capability-gated verb (e.g. agent-comms#162's dm:send) wires into its own session dispatch, backed by this store's identity/clock and this admission's live pending state. */ + createCapabilityAskHandler( + options: Readonly<{ + capability: string; + bearerDevice: DeviceId; + timeoutMs: number; + }>, + ): (incoming: Readonly) => Promise { + const { identity, clock } = this.requireIdentity(); + return this.capabilityAskAdmission.createAskHandler({ + capability: options.capability, + identity, + clock, + bearerDevice: options.bearerDevice, + timeoutMs: options.timeoutMs, + }); + } + + /** Every capability-request currently held open awaiting this store's own accept/reject decision. */ + listPendingCapabilityRequests(): { + requestId: string; + capability: string; + scopeKind: string; + scopePath?: string; + requesterDevice: string; + }[] { + return this.capabilityAskAdmission.listPendingCapabilityRequests(); + } + + /** Approves a pending capability request, minting and returning the granted token. */ + async acceptCapabilityRequest( + requestId: string, + options: Readonly<{ + expires: number; + delegationsRemaining?: number; + capability?: string; + }>, + ): Promise { + await this.capabilityAskAdmission.acceptCapabilityRequest( + requestId, + options, + ); + } + + /** Denies a pending capability request, optionally with a reason. */ + async rejectCapabilityRequest( + requestId: string, + reason?: string, + ): Promise { + await this.capabilityAskAdmission.rejectCapabilityRequest( + requestId, + reason, + ); + } + // ----------------------------------------------------------------------- // Mesh visibility // ----------------------------------------------------------------------- diff --git a/src/core/tool.ts b/src/core/tool.ts index 4ebf25fe..0636c513 100644 --- a/src/core/tool.ts +++ b/src/core/tool.ts @@ -81,6 +81,25 @@ export interface MeshOnlyFeatures { reason?: string, ) => void; listPendingRoomJoins?: () => { roomPath: string; requesterId: string }[]; + acceptCapabilityRequest?: ( + requestId: string, + options: Readonly<{ + expires: number; + delegationsRemaining?: number; + capability?: string; + }>, + ) => Promise; + rejectCapabilityRequest?: ( + requestId: string, + reason?: string, + ) => Promise; + listPendingCapabilityRequests?: () => { + requestId: string; + capability: string; + scopeKind: string; + scopePath?: string; + requesterDevice: string; + }[]; connectToRemote?: (host: string, port: number) => Promise; setVisibility?: (level: MeshVisibility, adapter?: string) => Promise; getVisibility?: (adapter?: string) => MeshVisibility; @@ -186,6 +205,12 @@ export class CommsTool { return this.roomReject(ctx, action); case "room_pending": return this.roomPending(ctx); + case "capability_accept": + return await this.capabilityAccept(ctx, action); + case "capability_reject": + return await this.capabilityReject(ctx, action); + case "capability_pending": + return this.capabilityPending(ctx); case "mesh_discover": return await this.meshDiscover(action); case "mesh_advertise": @@ -706,4 +731,59 @@ export class CommsTool { isError: false, }; } + + private async capabilityAccept( + _ctx: Readonly, + action: CommsAction & { action: "capability_accept" }, + ): Promise { + if (!this.store.acceptCapabilityRequest) + return notMeshBacked("capability_accept"); + const acceptCapabilityRequest = this.store.acceptCapabilityRequest.bind( + this.store, + ); + return tryMeshAction("accept", async () => { + await acceptCapabilityRequest(action.requestId, { + expires: action.expires, + ...(action.delegationsRemaining !== undefined + ? { delegationsRemaining: action.delegationsRemaining } + : {}), + ...(action.capability !== undefined + ? { capability: action.capability } + : {}), + }); + return `Accepted capability request ${action.requestId}.`; + }); + } + + private async capabilityReject( + _ctx: Readonly, + action: CommsAction & { action: "capability_reject" }, + ): Promise { + if (!this.store.rejectCapabilityRequest) + return notMeshBacked("capability_reject"); + const rejectCapabilityRequest = this.store.rejectCapabilityRequest.bind( + this.store, + ); + return tryMeshAction("reject", async () => { + await rejectCapabilityRequest(action.requestId, action.reason); + return `Rejected capability request ${action.requestId}.`; + }); + } + + private capabilityPending(_ctx: Readonly): CommsResult { + if (!this.store.listPendingCapabilityRequests) + return notMeshBacked("capability_pending"); + const pending = this.store.listPendingCapabilityRequests(); + if (pending.length === 0) + return { content: "No pending capability requests.", isError: false }; + + const lines = pending.map( + (p) => + `${p.requestId} ${p.capability} ${p.scopeKind}${p.scopePath !== undefined ? `:${p.scopePath}` : ""} from ${p.requesterDevice}`, + ); + return { + content: `Pending capability requests:\n${lines.join("\n")}`, + isError: false, + }; + } } diff --git a/src/core/types.ts b/src/core/types.ts index be176812..66d83396 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -256,6 +256,14 @@ export const DeliveryEventSchema = defineSchema( name: z.string(), fingerprint: z.string(), }), + z.object({ + type: z.literal("capability_request"), + requestId: z.string(), + capability: z.string(), + scopeKind: z.string(), + scopePath: z.string().optional(), + requesterDevice: z.string(), + }), ]), ); export type DeliveryEvent = z.infer; @@ -372,6 +380,19 @@ export const CommsActionSchema = defineSchema( reason: z.string().optional(), }), z.object({ action: z.literal("room_pending") }), + z.object({ + action: z.literal("capability_accept"), + requestId: z.string(), + expires: z.number(), + delegationsRemaining: z.number().optional(), + capability: z.string().optional(), + }), + z.object({ + action: z.literal("capability_reject"), + requestId: z.string(), + reason: z.string().optional(), + }), + z.object({ action: z.literal("capability_pending") }), z.object({ action: z.literal("mesh_discover"), method: z.string().optional(), diff --git a/src/test/capability-admission-tool-actions.test.ts b/src/test/capability-admission-tool-actions.test.ts new file mode 100644 index 00000000..9f7537d0 --- /dev/null +++ b/src/test/capability-admission-tool-actions.test.ts @@ -0,0 +1,241 @@ +/** + * Unit tests for the capability_pending/capability_accept/capability_reject CommsTool actions -- the human-facing wrapper around MeshStore's own createCapabilityAskHandler/listPendingCapabilityRequests/acceptCapabilityRequest/rejectCapabilityRequest (agent-comms#165), mirroring room-admission-tool-actions.test.ts's own real-MeshStore integration style rather than a fully faked harness. + */ + +import { webcrypto } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import type { IncomingManageRequest } from "wire-mesh-core/domain/mesh-session"; +import { createNodeIdentity } from "wire-mesh-core/adapters/node-identity"; +import { verifyCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import { MeshStore } from "../core/mesh-store.js"; +import { CommsTool } from "../core/tool.js"; +import { buildAction } from "../core/bridge.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { loadOrCreateIdentity } from "../core/identity-store.js"; +import { wireTestTransport } from "./test-transport.js"; + +const ES256 = -7; +const HOUR_MS = 3_600_000; +const CAPABILITY = "dm:send"; + +async function generateEs256Identity(): Promise { + const keyPair = await webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const publicKeyBytes = new Uint8Array( + await webcrypto.subtle.exportKey("raw", keyPair.publicKey), + ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); +} + +function fakeCapabilityRequest(): Omit { + return { + requestId: 1, + command: { + verb: CAPABILITY, + params: { verb: "capability.request", capability: CAPABILITY }, + }, + scope: { kind: "user" }, + }; +} + +describe("capability admission CommsTool actions", () => { + it("capability_pending lists a held-open capability request", async () => { + const store = new MeshStore(); + await wireTestTransport(store); + const owner = await store.registerAgent({ + name: "owner", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const requester = await generateEs256Identity(); + const handler = store.createCapabilityAskHandler({ + capability: CAPABILITY, + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + void handler({ + ...fakeCapabilityRequest(), + respond: async () => {}, + }); + + const tool = new CommsTool(store); + const ctx = { + agentId: owner.id, + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + }; + const pendingResult = await tool.handle( + ctx, + buildAction({ action: "capability_pending" }), + ); + + expect(pendingResult.isError).toBe(false); + expect(pendingResult.content.includes(CAPABILITY)).toBeTruthy(); + }); + + it("capability_accept mints and returns a granted token to the requester", async () => { + const store = new MeshStore(); + const slot = await wireTestTransport(store); + const owner = await store.registerAgent({ + name: "owner", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const requester = await generateEs256Identity(); + const handler = store.createCapabilityAskHandler({ + capability: CAPABILITY, + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + let respondedWith: unknown; + const outcomePromise = handler({ + ...fakeCapabilityRequest(), + respond: async (outcome) => { + respondedWith = outcome; + }, + }); + + const tool = new CommsTool(store); + const ctx = { + agentId: owner.id, + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + }; + const requestId = store.listPendingCapabilityRequests()[0]?.requestId; + if (requestId === undefined) throw new Error("no pending request found"); + + const expires = Date.now() + HOUR_MS; + const acceptResult = await tool.handle( + ctx, + buildAction({ + action: "capability_accept", + requestId, + expires, + }), + ); + + expect(acceptResult.isError).toBe(false); + await outcomePromise; + const outcome = respondedWith as { + result: string; + "granted-token": Parameters[0]; + }; + expect(outcome.result).toBe("ok"); + + const identity = await toIdentityPort(loadOrCreateIdentity(slot)); + const verdict = await verifyCapabilityToken(outcome["granted-token"], { + identity, + clock: { now: () => Date.now() }, + revocation: createRevocationView(), + expectedBearer: requester.deviceId, + }); + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect(verdict.claims.capability).toBe(CAPABILITY); + } + + const pendingAfter = await tool.handle( + ctx, + buildAction({ action: "capability_pending" }), + ); + expect(pendingAfter.content).toBe("No pending capability requests."); + }); + + it("capability_reject denies the pending request with an optional reason", async () => { + const store = new MeshStore(); + await wireTestTransport(store); + const owner = await store.registerAgent({ + name: "owner", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const requester = await generateEs256Identity(); + const handler = store.createCapabilityAskHandler({ + capability: CAPABILITY, + bearerDevice: requester.deviceId, + timeoutMs: HOUR_MS, + }); + let respondedWith: unknown; + const outcomePromise = handler({ + ...fakeCapabilityRequest(), + respond: async (outcome) => { + respondedWith = outcome; + }, + }); + + const tool = new CommsTool(store); + const ctx = { + agentId: owner.id, + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + }; + const pendingList = store.listPendingCapabilityRequests(); + const requestId = pendingList[0]?.requestId; + if (requestId === undefined) throw new Error("no pending request found"); + + const rejectResult = await tool.handle( + ctx, + buildAction({ + action: "capability_reject", + requestId, + reason: "not now", + }), + ); + + expect(rejectResult.isError).toBe(false); + await outcomePromise; + expect(respondedWith).toEqual({ + result: "error", + code: "denied", + message: "not now", + }); + }); + + it("capability_accept on a store with no matching pending request reports failure, not a crash", async () => { + const store = new MeshStore(); + await wireTestTransport(store); + const owner = await store.registerAgent({ + name: "owner", + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + const ctx = { + agentId: owner.id, + harness: "pi", + cwd: "/tmp/p", + pid: process.pid, + }; + + const result = await tool.handle( + ctx, + buildAction({ + action: "capability_accept", + requestId: "not-a-real-request", + expires: Date.now() + HOUR_MS, + }), + ); + + expect(result.isError).toBe(true); + }); +});