Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/bridges/user/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
36 changes: 36 additions & 0 deletions src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<typeof MCP_TOOL_PARAMS>;
Expand Down Expand Up @@ -287,6 +294,32 @@ export function buildAction(params: Record<string, unknown>): 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",
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
162 changes: 162 additions & 0 deletions src/core/capability-ask.ts
Original file line number Diff line number Diff line change
@@ -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<void>` 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<void>)
| 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<CapabilityScope>;
requesterDevice: DeviceId;
decide: (decision: Readonly<CapabilityGrantDecision>) => Promise<void>;
}

export class CapabilityAskAdmission {
private readonly pending = new Map<string, PendingCapabilityAsk>();

constructor(private readonly deps: Readonly<CapabilityAskAdmissionDeps>) {}

/** 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<CreateCapabilityAskHandlerOptions>,
): (incoming: Readonly<IncomingManageRequest>) => Promise<void> {
return createCapabilityRequestHandler({
capability: options.capability,
identity: options.identity,
clock: options.clock,
bearerDevice: options.bearerDevice,
timeoutMs: options.timeoutMs,
onRequest: (event: Readonly<CapabilityGrantRequestEvent>) => {
this.handleCapabilityRequest(options.capability, event);
},
});
}

private handleCapabilityRequest(
capability: string,
event: Readonly<CapabilityGrantRequestEvent>,
): 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<void> {
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<void> {
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;
}
}
71 changes: 71 additions & 0 deletions src/core/mesh-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<IncomingManageRequest>) => Promise<void> {
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<void> {
await this.capabilityAskAdmission.acceptCapabilityRequest(
requestId,
options,
);
}

/** Denies a pending capability request, optionally with a reason. */
async rejectCapabilityRequest(
requestId: string,
reason?: string,
): Promise<void> {
await this.capabilityAskAdmission.rejectCapabilityRequest(
requestId,
reason,
);
}

// -----------------------------------------------------------------------
// Mesh visibility
// -----------------------------------------------------------------------
Expand Down
Loading
Loading