agent-comms 8.15.8 → 8.15.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The room verb router: one drain loop per session, dispatching each incoming manage-request by verb. A legacy FRAME_VERB command (P2's opaque-payload carriage, still the only path for anything core/room doesn't yet have real semantics for) decodes and routes to the matching TransportEvents callback exactly as WireMeshTransport used to do inline; a registered room verb (room.send, room.join, ...) goes to its own handler; anything else -- an unrecognised verb, or a room verb this phase hasn't registered a handler for yet -- gets unsupported_verb rather than silence.
|
|
3
|
+
*
|
|
4
|
+
* core/room's own manage-command shape puts the specific action in params.verb (room.send, room.join, ...), not in the outer command.verb, which is instead the single room:member capability every ordinary membership verb shares (room.join/room.invite are ungated and carry no token at all, but still ride command.verb === "room:member" -- see spec/room.cddl and its own conformance vectors). Dispatch is therefore keyed on params.verb, not command.verb.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately empty of real room-verb handlers in this phase (P3.3): the router itself, and the unsupported_verb fallback, are the whole deliverable here. Each later phase (P3.4 admission, P3.5 fan-out, P3.6 state-sync, P3.7 kick) registers the handlers for the verbs it implements.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
AcceptedMeshSession,
|
|
11
|
+
IncomingManageRequest,
|
|
12
|
+
ManageOutcome,
|
|
13
|
+
} from "wire-mesh-core/domain/mesh-session";
|
|
14
|
+
import { isMeshMessage } from "./wire-protocol.js";
|
|
15
|
+
import type { MeshMessage } from "./wire-protocol.js";
|
|
16
|
+
import type { ConnectionHandle, TransportEvents } from "./transport.js";
|
|
17
|
+
import { FRAME_VERB } from "./wire-mesh-transport.js";
|
|
18
|
+
|
|
19
|
+
/** Extracts and validates the carried MeshMessage from an incoming FRAME_VERB command. Returns undefined for anything that isn't a well-formed frame -- an unrecognised or malformed payload is dropped, not thrown. Exported for WireMeshTransport's own quarantine-gate pre-approval check (introduce/connect_request detection), the one place outside this router that still needs to decode a legacy frame directly. */
|
|
20
|
+
export function extractMessage(
|
|
21
|
+
command: IncomingManageRequest["command"],
|
|
22
|
+
): MeshMessage | undefined {
|
|
23
|
+
if (command.verb !== FRAME_VERB) return undefined;
|
|
24
|
+
const params: unknown = command.params;
|
|
25
|
+
if (typeof params !== "object" || params === null) return undefined;
|
|
26
|
+
if (!("message" in params)) return undefined;
|
|
27
|
+
const message: unknown = params.message;
|
|
28
|
+
return isMeshMessage(message) ? message : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Routes an already-decoded legacy MeshMessage to the matching TransportEvents callback -- moved verbatim from WireMeshTransport's own former route() method. */
|
|
32
|
+
function routeLegacyMessage(
|
|
33
|
+
handle: Readonly<ConnectionHandle>,
|
|
34
|
+
message: MeshMessage,
|
|
35
|
+
events: Readonly<TransportEvents>,
|
|
36
|
+
): void {
|
|
37
|
+
switch (message.method) {
|
|
38
|
+
case "introduce": {
|
|
39
|
+
events.onIntroduction(handle, {
|
|
40
|
+
peerId: handle.id,
|
|
41
|
+
dataPort: message.dataPort,
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
case "peer_list": {
|
|
46
|
+
events.onPeerList(message.peers);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
case "peer_joined": {
|
|
50
|
+
events.onPeerJoined(message.peer);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
case "become_coordinator": {
|
|
54
|
+
events.onBecomeCoordinator(message.peerList);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
case "connect_request": {
|
|
58
|
+
// Only ever reaches here if a session was somehow promoted without going through WireMeshTransport's own consumeQuarantined handling of it -- can't happen given every requiresApproval accept path routes through consumeQuarantined first, kept here only so an unrecognised-in-context method fails closed rather than falling to the onMessage case below.
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Every other wire method -- mesh-state gossip (state_sync/state_update/peer_left) -- has no dedicated TransportEvents callback and falls to the generic onMessage handler, exactly as the old unconditional default case did.
|
|
62
|
+
case "state_sync":
|
|
63
|
+
case "state_update":
|
|
64
|
+
case "peer_left": {
|
|
65
|
+
events.onMessage(handle, message);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
default: {
|
|
69
|
+
// A peer running a newer build can send a wire method this closed union doesn't know about yet -- isMeshMessage's own runtime check only requires a string method field, deliberately looser than MeshMessage's compile-time type, so this branch is reachable at runtime even though every known case above is already covered.
|
|
70
|
+
events.onMessage(handle, message);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Reads params.verb from an incoming command's params, or undefined if it isn't a plain object with a string verb field. */
|
|
77
|
+
function extractParamsVerb(params: unknown): string | undefined {
|
|
78
|
+
if (typeof params !== "object" || params === null) return undefined;
|
|
79
|
+
if (!("verb" in params)) return undefined;
|
|
80
|
+
return typeof params.verb === "string" ? params.verb : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Extra facts about a request's own transport-level origin that a room-verb handler may need beyond its resolved ConnectionHandle -- currently just the hub-relay address, since RoomVerbHandler's own (request, handle) shape has no way to reach "which raw session/connection this arrived on" (agent-comms#216). Every field is optional, and every call site with nothing to report supplies an empty object rather than omitting origin altogether -- only the hub-relayed dispatch path (dispatchHubRequest, hub-session.ts) has ever anything to report here. */
|
|
84
|
+
export interface RoomRequestOrigin {
|
|
85
|
+
/** This side's own dialled address for the hub this request was relayed through, when relayed and known -- the answering side's equivalent of wire-mesh-core's own TracePathOptions.localHubAddress/PathTraceLocal.hubAddress on the sending side. Read directly off HubSession's own currently-dialled URL by dispatchHubRequest's caller (HubSession.consume), the one place that both receives the raw hub session and knows this fact -- never rederived here. */
|
|
86
|
+
relayHubAddress?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Handles one already-verified-and-approved incoming request, given its authenticated connection handle and this request's own transport-level origin. Registered per params.verb (room.send, room.join, ...), never per command.verb (the shared room:member capability every ordinary membership verb rides under) -- see this file's own header comment. origin is optional here (mirroring handleRequest's own optionality below) rather than required, since most existing handlers never look at it and a good number of tests call a handler value straight out of roomVerbHandlers with just (request, handle) -- only path.trace's own handler (mesh-graph.ts) actually reads it, and does so via an optional-chained read rather than assuming it is always supplied. */
|
|
90
|
+
export type RoomVerbHandler = (
|
|
91
|
+
request: IncomingManageRequest,
|
|
92
|
+
handle: Readonly<ConnectionHandle>,
|
|
93
|
+
origin?: Readonly<RoomRequestOrigin>,
|
|
94
|
+
) => Promise<ManageOutcome>;
|
|
95
|
+
|
|
96
|
+
export interface RoomRouterOptions {
|
|
97
|
+
/** The legacy TransportEvents callbacks, still the only path for anything core/room doesn't yet have real semantics for. */
|
|
98
|
+
events: TransportEvents;
|
|
99
|
+
/** Room-verb handlers, keyed by params.verb. Empty in P3.3 -- each later phase registers the verbs it implements. */
|
|
100
|
+
handlers?: Partial<Record<string, RoomVerbHandler>>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface RoomRouter {
|
|
104
|
+
/** Handles one request that arrived over an ordinary local peer session: a legacy FRAME_VERB frame goes to the matching TransportEvents callback, and anything else is dispatched as a room verb. Exported for direct unit testing; drainSession below is the thin per-session loop wrapper real callers use. origin defaults to an empty object (nothing to report) when omitted, since an ordinary local peer session has no relay-hub address of its own to report. */
|
|
105
|
+
handleLocalRequest: (
|
|
106
|
+
request: IncomingManageRequest,
|
|
107
|
+
handle: Readonly<ConnectionHandle>,
|
|
108
|
+
origin?: Readonly<RoomRequestOrigin>,
|
|
109
|
+
) => Promise<void>;
|
|
110
|
+
/** Handles one request that arrived relayed through a hub. Only a room verb is ever dispatched: a legacy FRAME_VERB frame is answered with unsupported_verb and never decoded, so a relayed sender cannot reach the TransportEvents callbacks (mesh-state gossip such as state_sync and state_update) however it is routed here. Separate from handleLocalRequest so that the distinction is structural rather than a check each caller must remember to make first. */
|
|
111
|
+
handleRelayedRequest: (
|
|
112
|
+
request: IncomingManageRequest,
|
|
113
|
+
handle: Readonly<ConnectionHandle>,
|
|
114
|
+
origin?: Readonly<RoomRequestOrigin>,
|
|
115
|
+
) => Promise<void>;
|
|
116
|
+
/** Consumes one session's incomingManageRequests until it ends, dispatching each request via handleLocalRequest. Becomes the single consumer of that iterable from this point on -- the caller must not also iterate the same session's incomingManageRequests itself once this is called. */
|
|
117
|
+
drainSession: (
|
|
118
|
+
session: AcceptedMeshSession,
|
|
119
|
+
handle: Readonly<ConnectionHandle>,
|
|
120
|
+
) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createRoomRouter(options: RoomRouterOptions): RoomRouter {
|
|
124
|
+
/** A room verb is never carried under FRAME_VERB, so a frame reaching here is not a room request whatever its params claim, and is refused like any other unregistered verb. */
|
|
125
|
+
async function dispatchRoomVerb(
|
|
126
|
+
request: IncomingManageRequest,
|
|
127
|
+
handle: Readonly<ConnectionHandle>,
|
|
128
|
+
origin: Readonly<RoomRequestOrigin>,
|
|
129
|
+
): Promise<void> {
|
|
130
|
+
const verb = extractParamsVerb(request.command.params);
|
|
131
|
+
const handler =
|
|
132
|
+
request.command.verb !== FRAME_VERB && verb !== undefined
|
|
133
|
+
? options.handlers?.[verb]
|
|
134
|
+
: undefined;
|
|
135
|
+
if (handler === undefined) {
|
|
136
|
+
await request
|
|
137
|
+
.respond({ result: "error", code: "unsupported_verb" })
|
|
138
|
+
.catch(() => undefined);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const outcome = await handler(request, handle, origin);
|
|
142
|
+
await request.respond(outcome).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleLocalRequest(
|
|
146
|
+
request: IncomingManageRequest,
|
|
147
|
+
handle: Readonly<ConnectionHandle>,
|
|
148
|
+
origin: Readonly<RoomRequestOrigin> = {},
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
if (request.command.verb === FRAME_VERB) {
|
|
151
|
+
const message = extractMessage(request.command);
|
|
152
|
+
if (message !== undefined) {
|
|
153
|
+
routeLegacyMessage(handle, message, options.events);
|
|
154
|
+
}
|
|
155
|
+
await request.respond({ result: "ok" }).catch(() => undefined);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await dispatchRoomVerb(request, handle, origin);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function handleRelayedRequest(
|
|
162
|
+
request: IncomingManageRequest,
|
|
163
|
+
handle: Readonly<ConnectionHandle>,
|
|
164
|
+
origin: Readonly<RoomRequestOrigin> = {},
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
await dispatchRoomVerb(request, handle, origin);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
handleLocalRequest,
|
|
171
|
+
handleRelayedRequest,
|
|
172
|
+
drainSession(session, handle) {
|
|
173
|
+
void (async () => {
|
|
174
|
+
for await (const request of session.incomingManageRequests) {
|
|
175
|
+
await handleLocalRequest(request, handle);
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/room's six verifier obligations (spec/room.cddl), layered on top of verifyCapabilityToken (obligations 2, 4, and 5 -- bearer match, ordinary token-claims checks, and delegations-remaining narrowing -- already live there). This module adds the checks specific to room-shaped scopes: the token must actually carry the room:member capability the six obligations presuppose (room.cddl's own obligation 1 opens with "a room:member token's delegation chain..." -- a token minted for some other capability that happens to be scoped to a room path must not pass just because scope.kind/path line up), the chain must terminate at the correct root for the path's own shape (1), and the token's own scope must actually name the room the request claims to act on (3). Obligation 6 (refuse an unrecognised content-type/kind) is a message-handling concern, not a token-verification one, and belongs to the room verb router instead.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
verifyCapabilityToken,
|
|
7
|
+
type TokenVerdictReason,
|
|
8
|
+
type VerifyCapabilityTokenOptions,
|
|
9
|
+
} from "wire-mesh-core/domain/tokens";
|
|
10
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
11
|
+
import type {
|
|
12
|
+
CapabilityToken,
|
|
13
|
+
DeviceId,
|
|
14
|
+
TokenClaims,
|
|
15
|
+
} from "wire-mesh-core/generated/protocol";
|
|
16
|
+
import { parseRoomPath } from "./room-path.js";
|
|
17
|
+
import type { DelegationPolicy } from "./delegation-policy.js";
|
|
18
|
+
|
|
19
|
+
/** The one capability that authorises every ordinary room-membership verb (room.send/read/leave/members) -- "one resource, not several", the same pattern exec:pty already uses for its own four inner verbs. room.join/room.invite are deliberately ungated and never reach this check at all. This same string is also the outer manage-command's own verb for every room-domain command (gated or ungated): core/room's own convention puts the specific action in params.verb, with command.verb fixed to this capability name -- exported so wire-level command construction (see wire-mesh-transport.ts/mesh-store.ts) shares this one constant rather than a second, independently-typed copy of the same literal. */
|
|
20
|
+
export const ROOM_MEMBER_CAPABILITY = "room:member";
|
|
21
|
+
|
|
22
|
+
/** room:member's own issuer-side delegation policy (agent-comms#163): a member's grant must never itself become the parent of a further delegation, so room:member is unconditionally non-delegable, with no per-agent override -- there is no legitimate case for one bearer's own room membership to be redelegable when every other bearer's isn't, unlike a general-purpose capability a future issuer (device-to-user membership, DM send) might reasonably narrow per bearer. Shared by every room:member mint call site (the owner's own root grant, an invite, and a real admission) via resolveDelegationsRemaining, replacing what was previously each site's own repeated `delegationsRemaining: 0` literal. */
|
|
23
|
+
export const ROOM_MEMBER_DELEGATION_POLICY: DelegationPolicy = {
|
|
24
|
+
nonDelegable: new Set([ROOM_MEMBER_CAPABILITY]),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type RoomTokenVerdictReason =
|
|
28
|
+
| TokenVerdictReason
|
|
29
|
+
| "wrong_capability"
|
|
30
|
+
| "wrong_scope_kind"
|
|
31
|
+
| "wrong_scope_path"
|
|
32
|
+
| "wrong_chain_root";
|
|
33
|
+
|
|
34
|
+
export type RoomTokenVerdict =
|
|
35
|
+
| { ok: true; claims: TokenClaims }
|
|
36
|
+
| { ok: false; reason: RoomTokenVerdictReason };
|
|
37
|
+
|
|
38
|
+
export interface VerifyRoomTokenOptions extends Omit<
|
|
39
|
+
VerifyCapabilityTokenOptions,
|
|
40
|
+
"expectedBearer"
|
|
41
|
+
> {
|
|
42
|
+
/** The peer identity actually authenticated on the arriving connection (obligation 2) -- never a relay-asserted or gossip-derived value. Mandatory here: every room verb requires a bearer, unlike verifyCapabilityToken's own optional field for callers presenting a token to authorise themselves rather than a specific counterparty. */
|
|
43
|
+
expectedBearer: DeviceId;
|
|
44
|
+
/** The room path this request claims to act on (obligation 3) -- must equal the token's own scope.path, and its own shape determines the chain root obligation 1 requires. */
|
|
45
|
+
roomPath: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Verifies a `room:member` capability token against all six of core/room's verifier obligations. Delegates obligations 2/4/5 to verifyCapabilityToken directly; adds obligation 3 (scope.kind/scope.path must match the room being acted on) and obligation 1 (the chain's root must be the room's own owner for a named room, or the verifying identity itself for a DM -- never either named participant directly, since a DM token minted by anyone other than the verifier would let a sender self-issue authority to message a stranger unsolicited).
|
|
50
|
+
*/
|
|
51
|
+
export async function verifyRoomToken(
|
|
52
|
+
token: CapabilityToken,
|
|
53
|
+
options: VerifyRoomTokenOptions,
|
|
54
|
+
): Promise<RoomTokenVerdict> {
|
|
55
|
+
const verdict = await verifyCapabilityToken(token, {
|
|
56
|
+
identity: options.identity,
|
|
57
|
+
clock: options.clock,
|
|
58
|
+
revocation: options.revocation,
|
|
59
|
+
expectedBearer: options.expectedBearer,
|
|
60
|
+
});
|
|
61
|
+
if (!verdict.ok) {
|
|
62
|
+
return verdict;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (verdict.claims.capability !== ROOM_MEMBER_CAPABILITY) {
|
|
66
|
+
return { ok: false, reason: "wrong_capability" };
|
|
67
|
+
}
|
|
68
|
+
if (verdict.claims.scope.kind !== "room") {
|
|
69
|
+
return { ok: false, reason: "wrong_scope_kind" };
|
|
70
|
+
}
|
|
71
|
+
if (verdict.claims.scope.path !== options.roomPath) {
|
|
72
|
+
return { ok: false, reason: "wrong_scope_path" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parsed = parseRoomPath(options.roomPath);
|
|
76
|
+
const expectedRootHex =
|
|
77
|
+
parsed.kind === "owner-named"
|
|
78
|
+
? parsed.owner
|
|
79
|
+
: deviceIdToHex(options.identity.deviceId);
|
|
80
|
+
if (deviceIdToHex(verdict.rootIssuer) !== expectedRootHex) {
|
|
81
|
+
return { ok: false, reason: "wrong_chain_root" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: true, claims: verdict.claims };
|
|
85
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room-state and inviter-agent wire extensions riding room.join/room.members/room.invite's own open extension tails (P3.6/P3.8) -- agent-comms' own application data convention, since core/room itself has no concept of a display name, description, or inviter identity. Split out of MeshStore as plain functions (the originals were `private static` methods with zero `this` access) because both room-protocol.ts and room-lifecycle.ts need them.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { RoomType } from "./types.js";
|
|
6
|
+
import type { AgentIdentity, Room } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Room metadata (name/description/type) riding room-join-ok/room-members-ok's own open extension tail (P3.6), agent-comms' own convention for the "namespaced room-state extension" the design names -- core/room itself has no concept of a display name or description, so this is application data, not a wire-level field. Absent entirely for a DM path (room is undefined there; a DM has no name/description/type to report) rather than a hollow placeholder.
|
|
10
|
+
*/
|
|
11
|
+
export function roomStateExtension(room: Readonly<Room> | undefined): {
|
|
12
|
+
"room-state"?: { name: string; description: string; type: RoomType };
|
|
13
|
+
} {
|
|
14
|
+
if (room === undefined) return {};
|
|
15
|
+
return {
|
|
16
|
+
"room-state": {
|
|
17
|
+
name: room.name,
|
|
18
|
+
description: room.description,
|
|
19
|
+
type: room.type,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The receiving-side counterpart of roomStateExtension: narrows an incoming response's own "room-state" field (an unknown, since it rides the wire schema's open catchall tail) to the shape this store's own convention actually sends, or undefined for a peer running without it (an older version, or a DM's own room-join-ok, which never carries one). */
|
|
25
|
+
export function parseRoomStateExtension(
|
|
26
|
+
value: unknown,
|
|
27
|
+
): { name: string; description: string; type: RoomType } | undefined {
|
|
28
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
29
|
+
if (!("name" in value) || !("description" in value) || !("type" in value))
|
|
30
|
+
return undefined;
|
|
31
|
+
if (typeof value.name !== "string" || typeof value.description !== "string")
|
|
32
|
+
return undefined;
|
|
33
|
+
const parsedType = RoomType.safeParse(value.type);
|
|
34
|
+
if (!parsedType.success) return undefined;
|
|
35
|
+
return {
|
|
36
|
+
name: value.name,
|
|
37
|
+
description: value.description,
|
|
38
|
+
type: parsedType.data,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The inviter's own display name and cwd, riding room-invite's own open extension tail -- agent-comms' own application data, the same convention roomStateExtension already establishes for room metadata. Unlike room-state (which needs gossip on the requester's own side of room.join/members), the inviter here is always this store's own local agent record: inviteToRoom only ever succeeds for the room's real owner, which per this store's own organising fact (one bridge is one agent is one device) is always this.peerId, so the record is always this store's own registration, never a gossip-dependent lookup.
|
|
44
|
+
*/
|
|
45
|
+
export function inviterAgentExtension(inviter: Readonly<AgentIdentity>): {
|
|
46
|
+
"inviter-agent": { name: string; cwd: string };
|
|
47
|
+
} {
|
|
48
|
+
return { "inviter-agent": { name: inviter.name, cwd: inviter.cwd } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The receiving-side counterpart of inviterAgentExtension: narrows an incoming room.invite's own "inviter-agent" field to the shape this store's own convention sends, or undefined for a peer running without it (an older version) -- the caller falls back to the inviter's bare device-id in that case, the same honest degradation parseRoomStateExtension's own absent case already accepts. */
|
|
52
|
+
export function parseInviterAgentExtension(
|
|
53
|
+
value: unknown,
|
|
54
|
+
): { name: string; cwd: string } | undefined {
|
|
55
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
56
|
+
if (!("name" in value) || !("cwd" in value)) return undefined;
|
|
57
|
+
if (typeof value.name !== "string" || typeof value.cwd !== "string")
|
|
58
|
+
return undefined;
|
|
59
|
+
return { name: value.name, cwd: value.cwd };
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One grouping issuer's self display name claim (agent-comms#345 for the machine, agent-comms#359 for the user principal), kept fresh beside that issuer's proof: re-minted on the proofs' own cadence from whatever name the issuer's identity file currently holds, so a name set or cleared by any bridge on the host reaches every bridge's advert within one refresh, and the setting bridge's at once.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { IdentityPort } from "wire-mesh-core/ports/identity";
|
|
6
|
+
import type { MeshStoreIdentity } from "./mesh-store-shared.js";
|
|
7
|
+
import { MEMBERSHIP_PROOF_LIFETIME_MS } from "./membership-proof.js";
|
|
8
|
+
import { PROOF_REFRESH_INTERVAL_MS } from "./membership-proofs.js";
|
|
9
|
+
import { mintNameClaim } from "./name-claim.js";
|
|
10
|
+
|
|
11
|
+
export interface SelfNameClaimDeps {
|
|
12
|
+
/** Throws if the store has no identity yet. */
|
|
13
|
+
getIdentity: () => MeshStoreIdentity;
|
|
14
|
+
/** The issuer this instance signs its claim as, picked out of the store's identity: its user principal or its machine. */
|
|
15
|
+
issuerOf: (identity: Readonly<MeshStoreIdentity>) => IdentityPort;
|
|
16
|
+
/** The name the issuer currently asserts for itself, read from its identity file; undefined when it has none. */
|
|
17
|
+
loadName: (identity: Readonly<MeshStoreIdentity>) => string | undefined;
|
|
18
|
+
onError: (error: Error) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SelfNameClaim {
|
|
22
|
+
private claim: string | undefined;
|
|
23
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly deps: Readonly<SelfNameClaimDeps>) {}
|
|
26
|
+
|
|
27
|
+
/** The current claim, undefined while the issuer has no name or before the first claim is minted. */
|
|
28
|
+
current(): string | undefined {
|
|
29
|
+
return this.claim;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
start(): void {
|
|
33
|
+
this.stop();
|
|
34
|
+
void this.refresh();
|
|
35
|
+
this.timer = setInterval(() => {
|
|
36
|
+
void this.refresh();
|
|
37
|
+
}, PROOF_REFRESH_INTERVAL_MS);
|
|
38
|
+
this.timer.unref();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop(): void {
|
|
42
|
+
clearInterval(this.timer);
|
|
43
|
+
this.timer = undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Re-reads the issuer's name and re-mints its claim now; drops the claim when the issuer has no name. A failed mint is reported and the previous claim is kept until the next refresh. */
|
|
47
|
+
async refresh(): Promise<void> {
|
|
48
|
+
const identity = this.deps.getIdentity();
|
|
49
|
+
const name = this.deps.loadName(identity);
|
|
50
|
+
if (name === undefined) {
|
|
51
|
+
this.claim = undefined;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
this.claim = await mintNameClaim({
|
|
56
|
+
issuer: this.deps.issuerOf(identity),
|
|
57
|
+
clock: identity.clock,
|
|
58
|
+
name,
|
|
59
|
+
lifetimeMs: MEMBERSHIP_PROOF_LIFETIME_MS,
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.deps.onError(
|
|
63
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* send-outcome -- classifies what actually happened to one directed room-domain request, so a caller can tell a delivery from a message that is merely held for retry and from one the recipient refused outright. The distinction that matters is whether trying again could ever change the answer: a deadline that passed or a route that did not exist may succeed on the next attempt, while a refusal (an unverifiable token, an unknown verb, a denied decision) is the recipient's settled answer and will be repeated every time.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session";
|
|
6
|
+
import type { MessageDelivery, TransientSendFailure } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Wire error codes that name a failure to reach the recipient at all, rather than an answer from it. Everything else, including a code this version does not recognise, is treated as a refusal: a send whose failure cannot be explained is reported to its sender now rather than queued to fail again unseen. */
|
|
9
|
+
const TRANSIENT_OUTCOME_CODES: ReadonlyMap<string, TransientSendFailure> =
|
|
10
|
+
new Map([
|
|
11
|
+
["timeout", "timeout"],
|
|
12
|
+
["not_connected", "not_connected"],
|
|
13
|
+
// This side's own transport answering that it has nowhere to send the request (no session, and no gateway willing to relay for that device). Reported as not_connected because that is exactly what it means to the sender: no route to the recipient existed when the request was attempted.
|
|
14
|
+
["no_route", "not_connected"],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What became of one directed room-domain request. `undelivered` is the retryable class: the recipient never answered, so the request may still succeed later and is worth holding. `refused` is terminal: the recipient did answer, with a failure it will give again, so the sender is told now rather than waiting on a retry that cannot help.
|
|
19
|
+
*/
|
|
20
|
+
export type RoomRequestOutcome =
|
|
21
|
+
| { kind: "delivered" }
|
|
22
|
+
| { kind: "undelivered"; reason: TransientSendFailure }
|
|
23
|
+
| { kind: "refused"; code: string; message?: string };
|
|
24
|
+
|
|
25
|
+
/** Classifies a wire manage-outcome into the three states above. A non-ok outcome whose code names an unreached recipient is undelivered; every other non-ok outcome is a refusal carrying the recipient's own code, and its own message when it sent one (a rejected room.join carries the refuser's stated reason there), so the sender can be told exactly what it was refused with. */
|
|
26
|
+
export function classifyManageOutcome(
|
|
27
|
+
outcome: Readonly<ManageOutcome>,
|
|
28
|
+
): RoomRequestOutcome {
|
|
29
|
+
if (outcome.result === "ok") return { kind: "delivered" };
|
|
30
|
+
const reason = TRANSIENT_OUTCOME_CODES.get(outcome.code);
|
|
31
|
+
if (reason !== undefined) return { kind: "undelivered", reason };
|
|
32
|
+
return {
|
|
33
|
+
kind: "refused",
|
|
34
|
+
code: outcome.code,
|
|
35
|
+
...(outcome.message !== undefined && { message: outcome.message }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A one-line, human-readable rendering of a refusal for an error message or a tool result: the recipient's own code, plus the message it sent when it sent one. */
|
|
40
|
+
export function describeRefusal(
|
|
41
|
+
outcome: Readonly<{ code: string; message?: string }>,
|
|
42
|
+
): string {
|
|
43
|
+
return outcome.message === undefined
|
|
44
|
+
? outcome.code
|
|
45
|
+
: `${outcome.code}: ${outcome.message}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A one-line, human-readable rendering of what became of a message for one recipient, phrased to read as a clause after the message's own id ("read by a1b2c3", "queued for a1b2c3 (timeout), not yet delivered"). Shared by every surface that reports a delivery status, so a tool result, a terminal line and a browser line never describe the same state differently. */
|
|
49
|
+
export function describeDeliveryFor(
|
|
50
|
+
delivery: Readonly<MessageDelivery>,
|
|
51
|
+
agent: string,
|
|
52
|
+
): string {
|
|
53
|
+
switch (delivery.status) {
|
|
54
|
+
case "delivered":
|
|
55
|
+
return `delivered to ${agent}`;
|
|
56
|
+
case "read":
|
|
57
|
+
return `read by ${agent}`;
|
|
58
|
+
case "queued":
|
|
59
|
+
return `queued for ${agent} (${delivery.reason}), not yet delivered`;
|
|
60
|
+
case "refused":
|
|
61
|
+
return `refused by ${agent} (${delivery.code})`;
|
|
62
|
+
case "dropped":
|
|
63
|
+
return `dropped for ${agent}, never delivered`;
|
|
64
|
+
case "expired":
|
|
65
|
+
return `expired for ${agent}, never delivered`;
|
|
66
|
+
default:
|
|
67
|
+
return delivery satisfies never;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The termination-signal handling every bridge needs, in one place. A bridge that exits without running MeshStore.shutdown() never hands its coordinator role on, so if it held that role the mesh is left with no coordinator until the surviving peers notice and race for the port -- recoverable, but it loses whatever the coordinator process alone was doing (the stale-agent probe and the cc-peer front) for as long as the race takes.
|
|
3
|
+
*
|
|
4
|
+
* Which signals a process should answer is not in question; what differs is what it may do afterwards. A bridge that owns its own process exits. A bridge loaded into someone else's process (a plugin) must not: registering any listener for SIGINT or SIGTERM removes Node's own default terminate behaviour, so a plugin that handled the signal and then did nothing would silently swallow its host's Ctrl-C. Such a bridge re-raises instead, which reaches the host's own handler if it has one and terminates the process as usual if it does not.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The termination signals a bridge answers. SIGHUP matters as much as the other two here: a bridge started from a terminal that closes receives only SIGHUP, and without it that exit is silently ungraceful. */
|
|
8
|
+
const SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
|
|
9
|
+
|
|
10
|
+
/** The exit status a bridge reports when it shut down in answer to a signal: this is an orderly exit that did exactly what it was asked to. */
|
|
11
|
+
const CLEAN_EXIT_CODE = 0;
|
|
12
|
+
|
|
13
|
+
/** What a bridge does once its shutdown has run. "exit" ends the process and suits a bridge that owns it. "reraise" re-sends the same signal with this handler removed, so a bridge loaded into a host process leaves the decision to the host rather than exiting on its behalf. */
|
|
14
|
+
export type ShutdownDisposition = "exit" | "reraise";
|
|
15
|
+
|
|
16
|
+
export interface ShutdownSignalOptions {
|
|
17
|
+
/** The orderly shutdown itself, normally MeshStore.shutdown() plus whatever else the bridge owns. It is never expected to throw: a rejection here is reported to onError and the process still goes on to exit or re-raise. */
|
|
18
|
+
shutdown: () => Promise<void>;
|
|
19
|
+
/** See ShutdownDisposition. */
|
|
20
|
+
disposition: ShutdownDisposition;
|
|
21
|
+
/** Reports a shutdown that failed part way. Optional, since a bridge whose only sink is a protocol stream on stdout may have nowhere useful to write during its own exit. */
|
|
22
|
+
onError?: ((error: Error) => void) | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Installs one handler per termination signal, each running `shutdown` once and then exiting or re-raising per `disposition`. Registered with `once`, so a second signal of the same kind while the shutdown is still in flight takes the default action rather than starting a second one -- which is also what makes "reraise" terminate rather than loop.
|
|
27
|
+
*
|
|
28
|
+
* Returns a function that removes every handler it installed, for a caller that shuts down by some other route (a host's own lifecycle hook, or a test) and does not want the handlers outliving it.
|
|
29
|
+
*/
|
|
30
|
+
export function installShutdownSignalHandlers(
|
|
31
|
+
options: Readonly<ShutdownSignalOptions>,
|
|
32
|
+
): () => void {
|
|
33
|
+
const { shutdown, disposition, onError } = options;
|
|
34
|
+
const installed = SHUTDOWN_SIGNALS.map((signal) => {
|
|
35
|
+
const handler = (): void => {
|
|
36
|
+
void shutdown()
|
|
37
|
+
.catch((error: unknown) => {
|
|
38
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
39
|
+
})
|
|
40
|
+
.finally(() => {
|
|
41
|
+
if (disposition === "exit") {
|
|
42
|
+
process.exit(CLEAN_EXIT_CODE);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
process.kill(process.pid, signal);
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
process.once(signal, handler);
|
|
49
|
+
return { signal, handler };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
for (const { signal, handler } of installed) {
|
|
54
|
+
process.off(signal, handler);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StaleAgentChecker: periodic liveness probe, run only by the elected coordinator (agent-comms#341), over every registered agent's own PID, marking a dead process's agent offline and, separately, purging agent records that have sat offline past a fixed threshold. Split out of MeshStore purely to keep mesh-store.ts under the repo's max-lines cap; owns its own probe-interval timer exclusively (nothing outside this class ever reads or writes it).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AgentIdentity, AgentStatus, DeliveryEvent } from "./types.js";
|
|
6
|
+
import type { MeshStatePatch, PeerInfo } from "./wire-protocol.js";
|
|
7
|
+
|
|
8
|
+
/** The state and collaborators StaleAgentChecker needs from MeshStore -- the core agent/delivery/peer/identity Maps it reads and mutates directly (shared references into MeshStore's own fields), plus the two DeliveryEngine operations a status change and an offline patch need to broadcast. */
|
|
9
|
+
export interface StaleAgentCheckerDeps {
|
|
10
|
+
agents: Map<string, AgentIdentity>;
|
|
11
|
+
deliveryQueues: Map<string, DeliveryEvent[]>;
|
|
12
|
+
identityCache: Map<string, { id: string }>;
|
|
13
|
+
peerInfo: Map<string, PeerInfo>;
|
|
14
|
+
notifyRoomsOfStatus: (agentId: string, status: AgentStatus) => Promise<void>;
|
|
15
|
+
broadcastPatch: (patch: MeshStatePatch) => Promise<void>;
|
|
16
|
+
/** Raises an agent's sync revision, so the offline record outranks every snapshot taken while the agent was running. */
|
|
17
|
+
bump: (agent: AgentIdentity) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** How often the elected coordinator probes registered agent PIDs for liveness. */
|
|
21
|
+
const PROBE_INTERVAL_MS = 5000;
|
|
22
|
+
|
|
23
|
+
/** How long an agent may sit "offline" before its record is purged entirely, to prevent indefinite accumulation. */
|
|
24
|
+
const OFFLINE_PURGE_THRESHOLD_MINUTES = 30;
|
|
25
|
+
const SECONDS_PER_MINUTE = 60;
|
|
26
|
+
const MS_PER_SECOND = 1000;
|
|
27
|
+
const OFFLINE_PURGE_THRESHOLD_MS =
|
|
28
|
+
OFFLINE_PURGE_THRESHOLD_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
29
|
+
|
|
30
|
+
export class StaleAgentChecker {
|
|
31
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
32
|
+
|
|
33
|
+
constructor(private readonly deps: StaleAgentCheckerDeps) {}
|
|
34
|
+
|
|
35
|
+
/** Starts the periodic probe (while this store holds the elected coordinator role). No-op if already running. */
|
|
36
|
+
start(): void {
|
|
37
|
+
if (this.timer) return;
|
|
38
|
+
this.timer = setInterval(() => {
|
|
39
|
+
void this.probeStaleAgents();
|
|
40
|
+
}, PROBE_INTERVAL_MS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Stops the periodic probe, if running. */
|
|
44
|
+
stop(): void {
|
|
45
|
+
if (this.timer) {
|
|
46
|
+
clearInterval(this.timer);
|
|
47
|
+
this.timer = undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async probeStaleAgents(): Promise<void> {
|
|
52
|
+
const {
|
|
53
|
+
agents,
|
|
54
|
+
deliveryQueues,
|
|
55
|
+
identityCache,
|
|
56
|
+
peerInfo,
|
|
57
|
+
notifyRoomsOfStatus,
|
|
58
|
+
broadcastPatch,
|
|
59
|
+
bump,
|
|
60
|
+
} = this.deps;
|
|
61
|
+
const deadIds: string[] = [];
|
|
62
|
+
|
|
63
|
+
for (const [id, agent] of agents) {
|
|
64
|
+
if (agent.status !== "active") continue;
|
|
65
|
+
if (!this.isProcessAlive(agent.pid)) {
|
|
66
|
+
deadIds.push(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const id of deadIds) {
|
|
71
|
+
const agent = agents.get(id);
|
|
72
|
+
if (agent) {
|
|
73
|
+
agent.status = "offline";
|
|
74
|
+
bump(agent);
|
|
75
|
+
agents.set(id, agent);
|
|
76
|
+
await notifyRoomsOfStatus(id, "offline");
|
|
77
|
+
}
|
|
78
|
+
await broadcastPatch({ type: "agent_offline", agentId: id });
|
|
79
|
+
peerInfo.delete(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Also purge long-offline agents to prevent indefinite accumulation.
|
|
83
|
+
const offlineThreshold = Date.now() - OFFLINE_PURGE_THRESHOLD_MS;
|
|
84
|
+
const purgeIds: string[] = [];
|
|
85
|
+
for (const [id, agent] of agents) {
|
|
86
|
+
if (agent.status !== "offline") continue;
|
|
87
|
+
const startedAt = new Date(agent.startedAt).getTime();
|
|
88
|
+
if (startedAt < offlineThreshold) {
|
|
89
|
+
purgeIds.push(id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const id of purgeIds) {
|
|
93
|
+
agents.delete(id);
|
|
94
|
+
peerInfo.delete(id);
|
|
95
|
+
identityCache.delete(id);
|
|
96
|
+
deliveryQueues.delete(id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private isProcessAlive(pid: number): boolean {
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|