agent-comms 8.15.9 → 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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a caller-supplied room identifier against the rooms this store knows of, replicated or merely discovered -- every room-touching tool action (send/join_room/leave_room/read_room/invite/kick/destroy_room) documents its room/target parameter as accepting a room's plain local `name`, matching the README's own usage examples (e.g. `target: "code-review"`) and createRoom's own confirmation message, which surfaces the name back to the caller rather than the owner-qualified id. MeshStore's real room identity is `<owner-hex>/<local-name>` (room-path.ts's ownerNamedRoomPath), a value no caller can construct from a bare name alone, so this is the one place that gap gets closed -- every local-facing RoomLifecycle/RoomMessaging method resolves a name the same way here rather than each reimplementing (or forgetting to reimplement) its own lookup. Wire-protocol-received room paths (room-protocol.ts's own verb handlers) never need this: those always arrive as the real path a remote peer already resolved on its own side.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CommsError } from "./store.js";
|
|
6
|
+
|
|
7
|
+
/** The two fields of a room that name resolution needs: its real id and the plain local name a caller may use instead. A full `Room` satisfies this, and so does a room known only from a gossiped hosted-room advert. */
|
|
8
|
+
export interface RoomRef {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves `roomIdOrName` against every room the caller knows of: rooms replicated into this store and rooms other devices merely advertise as hosted, since list_rooms shows both under their bare `name`. The same room appearing in both sources (same id) is one candidate, not two.
|
|
15
|
+
*
|
|
16
|
+
* Returns `roomIdOrName` unchanged when it already is a known room's id, or when no known room has that name (leaving the caller's own not-found or remote-join handling to fire exactly as it would for any unrecognised id). Resolves to the matching room's id when exactly one candidate's `name` equals `roomIdOrName`. Throws AMBIGUOUS_ROOM_NAME, naming every candidate id, when more than one candidate shares that name: distinct owners can legitimately pick the same local name, so silently picking one would be a correctness bug, not a convenience.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveRoomId(
|
|
19
|
+
candidates: Readonly<Iterable<RoomRef>>,
|
|
20
|
+
roomIdOrName: string,
|
|
21
|
+
): string {
|
|
22
|
+
const byId = new Map<string, RoomRef>();
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
if (!byId.has(candidate.id)) byId.set(candidate.id, candidate);
|
|
25
|
+
}
|
|
26
|
+
if (byId.has(roomIdOrName)) return roomIdOrName;
|
|
27
|
+
|
|
28
|
+
const matches: RoomRef[] = [];
|
|
29
|
+
for (const candidate of byId.values()) {
|
|
30
|
+
if (candidate.name === roomIdOrName) matches.push(candidate);
|
|
31
|
+
}
|
|
32
|
+
const [match, ...rest] = matches;
|
|
33
|
+
if (match === undefined) return roomIdOrName;
|
|
34
|
+
if (rest.length > 0) {
|
|
35
|
+
throw new CommsError(
|
|
36
|
+
`Room name ${JSON.stringify(roomIdOrName)} is ambiguous (matches ${matches.map((candidate) => candidate.id).join(", ")}); address it by its full room id instead`,
|
|
37
|
+
"AMBIGUOUS_ROOM_NAME",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return match.id;
|
|
41
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoomMessaging — the sending side of room messages and DMs: sendRoomMessage/sendDm fan out one directed, wire-authenticated room.send per member via RoomProtocol's own retry-queueing sendRoomRequestToMember (P3.5), and readRoomMessages reads back a room's local history. Split out of mesh-store.ts to reduce it under the repo's max-lines cap.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { bytesFromHex, bytesToHex } from "wire-mesh-core/domain/device-id";
|
|
6
|
+
import { dmRoomPath } from "./room-path.js";
|
|
7
|
+
import { loadRoomTokens } from "./identity-store.js";
|
|
8
|
+
import { randomId } from "./random-id.js";
|
|
9
|
+
import { recordRoomSendNotice } from "./room-notice-log.js";
|
|
10
|
+
import { CommsError } from "./store.js";
|
|
11
|
+
import { describeRefusal } from "./send-outcome.js";
|
|
12
|
+
import type { RoomRequestOutcome } from "./send-outcome.js";
|
|
13
|
+
import type { MeshStoreIdentity } from "./mesh-store-shared.js";
|
|
14
|
+
import type { RoomProtocol } from "./room-protocol.js";
|
|
15
|
+
import type { CapabilityToken } from "wire-mesh-core/generated/protocol";
|
|
16
|
+
import type {
|
|
17
|
+
AgentIdentity,
|
|
18
|
+
DmMessage,
|
|
19
|
+
MessageDelivery,
|
|
20
|
+
Room,
|
|
21
|
+
RoomMessage,
|
|
22
|
+
StreamingBehavior,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
import type {
|
|
25
|
+
MemberDelivery,
|
|
26
|
+
SentDm,
|
|
27
|
+
SentRoomMessage,
|
|
28
|
+
SendRoomMessageOptions,
|
|
29
|
+
} from "./comms-store.js";
|
|
30
|
+
|
|
31
|
+
/** Turns one member's send outcome into the delivery state its sender is told about. A refusal is not represented here at all: a refused send raises rather than resolving, so this only ever maps the two states a resolved send can be in. */
|
|
32
|
+
function deliveryFromOutcome(
|
|
33
|
+
outcome: Readonly<Exclude<RoomRequestOutcome, { kind: "refused" }>>,
|
|
34
|
+
): MessageDelivery {
|
|
35
|
+
return outcome.kind === "delivered"
|
|
36
|
+
? { status: "delivered" }
|
|
37
|
+
: { status: "queued", reason: outcome.reason };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The state and collaborators RoomMessaging needs from MeshStore. rooms/messages/dms are direct references into MeshStore's own fields; roomProtocol is the already-constructed instance (construction order: ... -\> roomProtocol -\> roomMessaging -\> ...), narrowed to what sending a message or DM ever needs; resolveAgent is AgentRegistry's own getAgent (deferred the same lazy-closure way DeliveryEngine's sendRoomRequestToMember closure is, since AgentRegistry doesn't exist yet at RoomMessaging's own construction point) rather than a bare `agents.get` lookup, so sendDm's own existence check also resolves a gossip-discovered agent (a remote, hub-learned one included, agent-comms#155) that will never appear in the agents map directly. */
|
|
41
|
+
export interface RoomMessagingDeps {
|
|
42
|
+
rooms: Map<string, Room>;
|
|
43
|
+
messages: Map<string, RoomMessage[]>;
|
|
44
|
+
dms: Map<string, DmMessage[]>;
|
|
45
|
+
requireIdentity: () => MeshStoreIdentity;
|
|
46
|
+
roomProtocol: Pick<RoomProtocol, "sendRoomRequestToMember">;
|
|
47
|
+
/** Runs the requester half of the DM consent flow against a counterpart device (RoomLifecycle.requestDmAccess), persisting the room:member token it is granted. Resolves once the counterpart admits this device, rejects if it refuses or never answers. */
|
|
48
|
+
requestDmAccess: (counterpart: string) => Promise<void>;
|
|
49
|
+
/** Resolves a room id or plain room name to a real room id (RoomLifecycle.resolveRoomId), against rooms this store holds and rooms other devices advertise as hosted, so a name means the same room here as it does to join_room. Throws AMBIGUOUS_ROOM_NAME when the name is shared by more than one room. */
|
|
50
|
+
resolveRoomId: (roomIdOrName: string) => string;
|
|
51
|
+
resolveAgent: (id: string) => Promise<AgentIdentity | undefined>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class RoomMessaging {
|
|
55
|
+
constructor(private readonly deps: RoomMessagingDeps) {}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sends a room message via a real, wire-authenticated room.send fan-out (P3.5): one directed request per member, each carrying this sender's own persisted room:member token, rather than the legacy broadcastPatch's full-state replication. A member unreachable right now is queued for retry (see sendRoomRequestToMember/flushPendingRoomRequests) instead of blocking or failing the whole send -- delivery to any one recipient is independent of every other.
|
|
59
|
+
*
|
|
60
|
+
* durable, when true, additionally records this same message as a room-notice in the sender's own oplog via recordRoomSendNotice (P5, agent-comms#50) -- deliberately opt-in per call, not automatic: matching this codebase's own design principle that delivery and durable catch-up are the same artifact only when a caller actually opts a message into it. A caller that wants an offline member to be able to catch up on this specific message later passes true; every other send stays exactly as before.
|
|
61
|
+
*
|
|
62
|
+
* Returns the message alongside one delivery state per remote member, so the caller can tell who actually received it from who it is merely queued for. A member that refuses the message outright raises: a refusal is the member's settled answer, not a state to report as pending, and a fan-out whose members cannot all be reported honestly is not a successful send.
|
|
63
|
+
*/
|
|
64
|
+
async sendRoomMessage(
|
|
65
|
+
roomIdOrName: string,
|
|
66
|
+
from: string,
|
|
67
|
+
content: string,
|
|
68
|
+
options?: SendRoomMessageOptions,
|
|
69
|
+
): Promise<SentRoomMessage> {
|
|
70
|
+
const { replyTo, streamingBehavior, durable } = options ?? {};
|
|
71
|
+
const roomId = this.deps.resolveRoomId(roomIdOrName);
|
|
72
|
+
const room = this.deps.rooms.get(roomId);
|
|
73
|
+
if (!room)
|
|
74
|
+
throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
|
|
75
|
+
if (!room.members.includes(from))
|
|
76
|
+
throw new CommsError(`Not a member of ${roomId}`, "NOT_MEMBER");
|
|
77
|
+
|
|
78
|
+
const { slot, clock, identity, dataStorage } = this.deps.requireIdentity();
|
|
79
|
+
const token = loadRoomTokens(slot)[roomId];
|
|
80
|
+
if (token === undefined) {
|
|
81
|
+
throw new CommsError(`No room:member token for ${roomId}`, "NOT_MEMBER");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (durable === true) {
|
|
85
|
+
await recordRoomSendNotice(
|
|
86
|
+
{ identity, clock, storage: dataStorage },
|
|
87
|
+
{
|
|
88
|
+
room: roomId,
|
|
89
|
+
token,
|
|
90
|
+
contentType: "text/plain",
|
|
91
|
+
content: new TextEncoder().encode(content),
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const messageId = randomId();
|
|
97
|
+
const id = bytesToHex(messageId);
|
|
98
|
+
const message: RoomMessage = {
|
|
99
|
+
id,
|
|
100
|
+
from,
|
|
101
|
+
room: roomId,
|
|
102
|
+
content,
|
|
103
|
+
timestamp: new Date().toISOString(),
|
|
104
|
+
readBy: [from],
|
|
105
|
+
...(replyTo !== undefined && { replyTo }),
|
|
106
|
+
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const arr = this.deps.messages.get(roomId) ?? [];
|
|
110
|
+
arr.push(message);
|
|
111
|
+
this.deps.messages.set(roomId, arr);
|
|
112
|
+
|
|
113
|
+
const params: Record<string, unknown> = {
|
|
114
|
+
verb: "room.send",
|
|
115
|
+
"message-id": messageId,
|
|
116
|
+
"sent-at": clock.now(),
|
|
117
|
+
text: content,
|
|
118
|
+
...(replyTo !== undefined && {
|
|
119
|
+
refs: [{ id: bytesFromHex(replyTo), relation: "reply" }],
|
|
120
|
+
}),
|
|
121
|
+
...(streamingBehavior !== undefined && {
|
|
122
|
+
"streaming-behavior": streamingBehavior,
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const deliveries: MemberDelivery[] = [];
|
|
127
|
+
for (const memberId of room.members) {
|
|
128
|
+
if (memberId === from) continue;
|
|
129
|
+
const outcome = await this.deps.roomProtocol.sendRoomRequestToMember(
|
|
130
|
+
memberId,
|
|
131
|
+
roomId,
|
|
132
|
+
token,
|
|
133
|
+
params,
|
|
134
|
+
);
|
|
135
|
+
// A refusal is reported as this member's own delivery state rather than raised, unlike sendDm's single recipient: delivery to any one member is independent of every other, so one member's refusal must neither stop the message reaching the rest nor discard what became of them.
|
|
136
|
+
deliveries.push({
|
|
137
|
+
member: memberId,
|
|
138
|
+
delivery:
|
|
139
|
+
outcome.kind === "refused"
|
|
140
|
+
? { status: "refused", code: describeRefusal(outcome) }
|
|
141
|
+
: deliveryFromOutcome(outcome),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { message, deliveries };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async readRoomMessages(
|
|
149
|
+
roomIdOrName: string,
|
|
150
|
+
since?: string,
|
|
151
|
+
): Promise<RoomMessage[]> {
|
|
152
|
+
await Promise.resolve();
|
|
153
|
+
const roomId = this.deps.resolveRoomId(roomIdOrName);
|
|
154
|
+
const arr = this.deps.messages.get(roomId) ?? [];
|
|
155
|
+
if (since === undefined || since === "") return [...arr];
|
|
156
|
+
return arr.filter((m) => m.timestamp > since);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sends a DM via the same wire-authenticated room.send fan-out sendRoomMessage uses (P3.5): a DM is just a dm-shaped room path with exactly one other member, so it rides the identical mechanism rather than a separate one. Self-DM is the one exception -- a purely local scratchpad note that never leaves the process, so it needs no token and no wire round trip at all, and is delivered the moment it is recorded.
|
|
161
|
+
*
|
|
162
|
+
* Returns the message alongside what actually became of it, so a caller can never mistake a message merely held for retry for one the recipient received. A recipient that refuses the message raises SEND_REFUSED naming its own reason: a DM has exactly one recipient, so its refusal is the whole send failing, and no retry will change it.
|
|
163
|
+
*/
|
|
164
|
+
async sendDm(
|
|
165
|
+
from: string,
|
|
166
|
+
to: string,
|
|
167
|
+
content: string,
|
|
168
|
+
streamingBehavior?: StreamingBehavior,
|
|
169
|
+
): Promise<SentDm> {
|
|
170
|
+
if (to !== from) {
|
|
171
|
+
const recipient = await this.deps.resolveAgent(to);
|
|
172
|
+
if (!recipient)
|
|
173
|
+
throw new CommsError(`Agent ${to} not found`, "AGENT_NOT_FOUND");
|
|
174
|
+
if (recipient.visibility === "ghost")
|
|
175
|
+
throw new CommsError(`Cannot DM agent ${to}`, "AGENT_NOT_FOUND");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A cross-device DM needs a room:member token for the pair's dm path. The first message to a counterpart has none yet, so obtain one via the consent flow before anything is recorded or sent: a refused or unanswered request must leave no trace of a message that never went out.
|
|
179
|
+
const token =
|
|
180
|
+
to === from ? undefined : await this.dmRoomMemberToken(from, to);
|
|
181
|
+
|
|
182
|
+
const messageId = randomId();
|
|
183
|
+
const id = bytesToHex(messageId);
|
|
184
|
+
const message: DmMessage = {
|
|
185
|
+
id,
|
|
186
|
+
from,
|
|
187
|
+
to,
|
|
188
|
+
content,
|
|
189
|
+
timestamp: new Date().toISOString(),
|
|
190
|
+
readBy: [from],
|
|
191
|
+
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Self-DM is a purely local scratchpad note -- it never leaves the process, so it needs no room-path and dmRoomPath's own a===b refusal (a path naming the same device twice is not a valid DM path at all) correctly does not apply.
|
|
195
|
+
const key = to === from ? `self:${from}` : dmRoomPath(from, to);
|
|
196
|
+
const arr = this.deps.dms.get(key) ?? [];
|
|
197
|
+
arr.push(message);
|
|
198
|
+
this.deps.dms.set(key, arr);
|
|
199
|
+
|
|
200
|
+
// A self-DM never leaves the process, so it is already exactly as delivered as it will ever be by the time it is recorded above.
|
|
201
|
+
if (token === undefined)
|
|
202
|
+
return { message, delivery: { status: "delivered" } };
|
|
203
|
+
|
|
204
|
+
const { clock } = this.deps.requireIdentity();
|
|
205
|
+
const params: Record<string, unknown> = {
|
|
206
|
+
verb: "room.send",
|
|
207
|
+
"message-id": messageId,
|
|
208
|
+
"sent-at": clock.now(),
|
|
209
|
+
text: content,
|
|
210
|
+
...(streamingBehavior !== undefined && {
|
|
211
|
+
"streaming-behavior": streamingBehavior,
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
const outcome = await this.deps.roomProtocol.sendRoomRequestToMember(
|
|
215
|
+
to,
|
|
216
|
+
key,
|
|
217
|
+
token,
|
|
218
|
+
params,
|
|
219
|
+
);
|
|
220
|
+
if (outcome.kind === "refused") {
|
|
221
|
+
throw new CommsError(
|
|
222
|
+
`DM to ${to} refused (${describeRefusal(outcome)})`,
|
|
223
|
+
"SEND_REFUSED",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { message, delivery: deliveryFromOutcome(outcome) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The room:member token this device holds for its DM path with `to`, requesting DM access from `to` first when none is persisted yet. Throws NOT_MEMBER if the request resolves without a token having been persisted, since no send can be authenticated without one. */
|
|
231
|
+
private async dmRoomMemberToken(
|
|
232
|
+
from: string,
|
|
233
|
+
to: string,
|
|
234
|
+
): Promise<CapabilityToken> {
|
|
235
|
+
const key = dmRoomPath(from, to);
|
|
236
|
+
const { slot } = this.deps.requireIdentity();
|
|
237
|
+
const existing = loadRoomTokens(slot)[key];
|
|
238
|
+
if (existing !== undefined) return existing;
|
|
239
|
+
|
|
240
|
+
await this.deps.requestDmAccess(to);
|
|
241
|
+
const granted = loadRoomTokens(slot)[key];
|
|
242
|
+
if (granted === undefined) {
|
|
243
|
+
throw new CommsError(`No room:member token for ${key}`, "NOT_MEMBER");
|
|
244
|
+
}
|
|
245
|
+
return granted;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records a room.send as a durable, catch-up-able room-notice in the sender's own oplog -- P5's mint-and-append half (agent-comms#50), riding wire-mesh-core's createRoomNotice + appendOwnEntry directly. Deliberately opt-in per call, not wired into sendRoomMessage's own default path: a caller decides per-message whether durability is worth the extra local write, matching the plan's own "delivery and durable catch-up are the same artifact, opt-in per message" design. Wiring this into a bridge's own send path, and the actual catch-up policy (when to send data-have, which peers' logs to track), stay their own separate, still-open piece.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { encode as cborEncode, cdeEncodeOptions } from "cbor2";
|
|
6
|
+
import { createRoomNotice } from "wire-mesh-core/domain/room";
|
|
7
|
+
import { appendOwnEntry } from "wire-mesh-core/domain/data-sync";
|
|
8
|
+
import type {
|
|
9
|
+
CapabilityToken,
|
|
10
|
+
DataHaveFrame,
|
|
11
|
+
MessageRef,
|
|
12
|
+
RoomPath,
|
|
13
|
+
} from "wire-mesh-core/generated/protocol";
|
|
14
|
+
import type { IdentityPort } from "wire-mesh-core/ports/identity";
|
|
15
|
+
import type { Clock } from "wire-mesh-core/ports/clock";
|
|
16
|
+
import type { KeyValueStorage } from "wire-mesh-core/ports/storage";
|
|
17
|
+
import { randomId } from "./random-id.js";
|
|
18
|
+
|
|
19
|
+
export interface RecordRoomSendNoticeDeps {
|
|
20
|
+
identity: IdentityPort;
|
|
21
|
+
clock: Clock;
|
|
22
|
+
storage: KeyValueStorage;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RecordRoomSendNoticeOptions {
|
|
26
|
+
room: RoomPath;
|
|
27
|
+
/** The sender's own room:member token for `room`, embedded in full in the minted notice -- see createRoomNotice's own token field for why. */
|
|
28
|
+
token: CapabilityToken;
|
|
29
|
+
contentType: string;
|
|
30
|
+
content: Uint8Array<ArrayBuffer>;
|
|
31
|
+
refs?: readonly MessageRef[];
|
|
32
|
+
validUntil?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Mints a self-certifying room-notice for this send, CBOR-encodes it (cbor2's own deterministic-encoding preset, matching handshake.ts's established convention for every other wire-mesh value this codebase serialises), and appends it to the sender's own oplog. Returns the new sequence number and the data-have frame announcing it -- sending that frame to any peer is the caller's own policy, not this function's. */
|
|
36
|
+
export async function recordRoomSendNotice(
|
|
37
|
+
deps: Readonly<RecordRoomSendNoticeDeps>,
|
|
38
|
+
options: Readonly<RecordRoomSendNoticeOptions>,
|
|
39
|
+
): Promise<{ seq: number; haveFrame: DataHaveFrame }> {
|
|
40
|
+
const notice = await createRoomNotice({
|
|
41
|
+
identity: deps.identity,
|
|
42
|
+
clock: deps.clock,
|
|
43
|
+
room: options.room,
|
|
44
|
+
token: options.token,
|
|
45
|
+
noticeId: randomId(),
|
|
46
|
+
contentType: options.contentType,
|
|
47
|
+
content: options.content,
|
|
48
|
+
...(options.refs !== undefined ? { refs: options.refs } : {}),
|
|
49
|
+
...(options.validUntil !== undefined
|
|
50
|
+
? { validUntil: options.validUntil }
|
|
51
|
+
: {}),
|
|
52
|
+
});
|
|
53
|
+
// cbor2's encode() returns Uint8Array<ArrayBufferLike>; appendOwnEntry needs the narrower Uint8Array<ArrayBuffer> every other wire-mesh-core byte-string field already uses. Uint8Array.from copies into a fresh, plain ArrayBuffer-backed array rather than asserting the existing buffer's type.
|
|
54
|
+
const encoded: Uint8Array<ArrayBuffer> = Uint8Array.from(
|
|
55
|
+
cborEncode(notice, cdeEncodeOptions),
|
|
56
|
+
);
|
|
57
|
+
return appendOwnEntry(
|
|
58
|
+
{ identity: deps.identity, storage: deps.storage },
|
|
59
|
+
encoded,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room paths — the naming and parsing convention core/room's device-keyed membership uses, mirroring wire-mesh spec/room.cddl exactly: an owner-named room is `<owner-hex>/<local-name>`, trust rooted at the owner named in the path; a DM is the bytewise-ascending sorted pair `<lower-hex>+<higher-hex>`, trust rooted at the verifier itself rather than either named party (see room.cddl's own comments for why that asymmetry is load-bearing, not incidental).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DEVICE_ID_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
6
|
+
const LOCAL_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
7
|
+
const SLUG_INVALID_RUN = /[^A-Za-z0-9_-]+/g;
|
|
8
|
+
|
|
9
|
+
/** Whether value is a full device-id in its canonical text form: 64 lowercase hex characters. */
|
|
10
|
+
export function isDeviceIdHex(value: string): boolean {
|
|
11
|
+
return DEVICE_ID_HEX_PATTERN.test(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assertDeviceIdHex(value: string, label: string): void {
|
|
15
|
+
if (!isDeviceIdHex(value)) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`expected ${label} to be a 64-character lowercase hex device-id, got ${JSON.stringify(value)}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `<owner-hex>/<local-name>`. localName must already satisfy [A-Za-z0-9_-]+ -- run an untrusted name through slugRoomName first. */
|
|
23
|
+
export function ownerNamedRoomPath(owner: string, localName: string): string {
|
|
24
|
+
assertDeviceIdHex(owner, "owner");
|
|
25
|
+
if (!LOCAL_NAME_PATTERN.test(localName)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`expected localName to match [A-Za-z0-9_-]+, got ${JSON.stringify(localName)}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return `${owner}/${localName}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `<lower-hex>+<higher-hex>`, the bytewise-ascending sorted pair. Refuses a==b outright: a path naming the same device twice is not a valid DM path at all, and self-DM is a purely local scratchpad note that needs no path (see mesh-store.ts's own sendDm). */
|
|
34
|
+
export function dmRoomPath(a: string, b: string): string {
|
|
35
|
+
assertDeviceIdHex(a, "a");
|
|
36
|
+
assertDeviceIdHex(b, "b");
|
|
37
|
+
if (a === b) {
|
|
38
|
+
throw new Error(`a DM room path cannot name the same device twice (${a})`);
|
|
39
|
+
}
|
|
40
|
+
return a < b ? `${a}+${b}` : `${b}+${a}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ParsedRoomPath =
|
|
44
|
+
| { kind: "owner-named"; owner: string; localName: string }
|
|
45
|
+
| { kind: "dm"; participants: [string, string] };
|
|
46
|
+
|
|
47
|
+
/** Parses a room-path back into its owner-named or DM shape. Throws on anything matching neither -- there is no third path shape. */
|
|
48
|
+
export function parseRoomPath(path: string): ParsedRoomPath {
|
|
49
|
+
const slashIndex = path.indexOf("/");
|
|
50
|
+
if (slashIndex !== -1) {
|
|
51
|
+
const owner = path.slice(0, slashIndex);
|
|
52
|
+
const localName = path.slice(slashIndex + 1);
|
|
53
|
+
if (
|
|
54
|
+
DEVICE_ID_HEX_PATTERN.test(owner) &&
|
|
55
|
+
LOCAL_NAME_PATTERN.test(localName)
|
|
56
|
+
) {
|
|
57
|
+
return { kind: "owner-named", owner, localName };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const plusIndex = path.indexOf("+");
|
|
61
|
+
if (plusIndex !== -1) {
|
|
62
|
+
const first = path.slice(0, plusIndex);
|
|
63
|
+
const second = path.slice(plusIndex + 1);
|
|
64
|
+
if (
|
|
65
|
+
DEVICE_ID_HEX_PATTERN.test(first) &&
|
|
66
|
+
DEVICE_ID_HEX_PATTERN.test(second) &&
|
|
67
|
+
first !== second
|
|
68
|
+
) {
|
|
69
|
+
return { kind: "dm", participants: [first, second] };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`${JSON.stringify(path)} is not a valid room-path`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sanitises an arbitrary name (e.g. a directory basename) into a valid room-path localName by replacing every run of characters outside [A-Za-z0-9_-] with a single hyphen. Throws if nothing valid remains. */
|
|
76
|
+
export function slugRoomName(name: string): string {
|
|
77
|
+
const slug = name.replace(SLUG_INVALID_RUN, "-").replace(/^-+|-+$/g, "");
|
|
78
|
+
if (slug.length === 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${JSON.stringify(name)} has no valid room-name characters to slug`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return slug;
|
|
84
|
+
}
|