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.
Files changed (184) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/bridges/user/web/server.d.ts.map +1 -1
  4. package/dist/bridges/user/web/server.js +44 -24
  5. package/dist/bridges/user/web/server.js.map +1 -1
  6. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  10. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  14. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  15. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  16. package/dist/bridges/user/web/web-assets.js +67 -0
  17. package/dist/bridges/user/web/web-assets.js.map +1 -0
  18. package/package.json +8 -2
  19. package/src/account-cli.ts +195 -0
  20. package/src/account-join-cli.ts +191 -0
  21. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  22. package/src/bridges/cc-peer/bridge.ts +138 -0
  23. package/src/bridges/cc-peer/default-front.ts +58 -0
  24. package/src/bridges/cc-peer/front-controller.ts +160 -0
  25. package/src/bridges/cc-peer/front-relay.ts +191 -0
  26. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  27. package/src/bridges/cc-peer/front.ts +59 -0
  28. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  29. package/src/bridges/cc-peer/run.ts +100 -0
  30. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  31. package/src/bridges/claude-code/channel.ts +318 -0
  32. package/src/bridges/codex/tool.ts +132 -0
  33. package/src/bridges/mcp/server.ts +137 -0
  34. package/src/bridges/opencode/plugin.ts +130 -0
  35. package/src/bridges/pi/extension.ts +330 -0
  36. package/src/bridges/registry.ts +17 -0
  37. package/src/bridges/user/cli.ts +87 -0
  38. package/src/bridges/user/controller.ts +345 -0
  39. package/src/bridges/user/tui.ts +300 -0
  40. package/src/bridges/user/web/actions.ts +173 -0
  41. package/src/bridges/user/web/contract.ts +222 -0
  42. package/src/bridges/user/web/dist/bundle.js +49 -0
  43. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  44. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  45. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  46. package/src/bridges/user/web/dist/index.html +21 -0
  47. package/src/bridges/user/web/dist/manifest.json +26 -0
  48. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  49. package/src/bridges/user/web/dist/sw.js +1 -0
  50. package/src/bridges/user/web/event-publisher.ts +114 -0
  51. package/src/bridges/user/web/port-discovery.ts +49 -0
  52. package/src/bridges/user/web/reads.ts +35 -0
  53. package/src/bridges/user/web/router.ts +158 -0
  54. package/src/bridges/user/web/server.ts +782 -0
  55. package/src/bridges/user/web/vite.config.ts +71 -0
  56. package/src/bridges/user/web/web-access.ts +404 -0
  57. package/src/bridges/user/web/web-assets.ts +90 -0
  58. package/src/bridges/user/web/web-beacon.ts +69 -0
  59. package/src/bridges/user/web/web-console-static.ts +90 -0
  60. package/src/cli-main.ts +635 -0
  61. package/src/cli.ts +13 -0
  62. package/src/core/account-bundle.ts +264 -0
  63. package/src/core/account-join.ts +289 -0
  64. package/src/core/account-ledger-crypto.ts +154 -0
  65. package/src/core/account-ledger-store.ts +66 -0
  66. package/src/core/account-ledger.ts +385 -0
  67. package/src/core/agent-details.ts +216 -0
  68. package/src/core/agent-list-format.ts +97 -0
  69. package/src/core/agent-registry.ts +282 -0
  70. package/src/core/agent-table.ts +58 -0
  71. package/src/core/atomic-file.ts +46 -0
  72. package/src/core/bind-retry.ts +38 -0
  73. package/src/core/bridge-mesh.ts +176 -0
  74. package/src/core/bridge.ts +718 -0
  75. package/src/core/capability-ask.ts +162 -0
  76. package/src/core/capability-bubble-up.ts +237 -0
  77. package/src/core/comms-store.ts +130 -0
  78. package/src/core/connection-approval.ts +134 -0
  79. package/src/core/connection-code-tool.ts +92 -0
  80. package/src/core/connection-code.ts +296 -0
  81. package/src/core/coordinator-role.ts +224 -0
  82. package/src/core/data-directory.ts +32 -0
  83. package/src/core/data-frame-routing.ts +309 -0
  84. package/src/core/default-command.ts +12 -0
  85. package/src/core/delegation-policy.ts +41 -0
  86. package/src/core/delivery-engine.ts +649 -0
  87. package/src/core/device-membership-verification.ts +125 -0
  88. package/src/core/device-membership.ts +130 -0
  89. package/src/core/directory-admission.ts +155 -0
  90. package/src/core/discovery-mdns.ts +247 -0
  91. package/src/core/discovery-tailscale.ts +224 -0
  92. package/src/core/discovery.ts +303 -0
  93. package/src/core/display-name.ts +73 -0
  94. package/src/core/dm-grant-actions.ts +72 -0
  95. package/src/core/dm-send-delegation.ts +73 -0
  96. package/src/core/dm-token-verification.ts +76 -0
  97. package/src/core/election-sessions.ts +135 -0
  98. package/src/core/error-reporting.ts +85 -0
  99. package/src/core/first-contact.ts +242 -0
  100. package/src/core/gateway-trust-actions.ts +184 -0
  101. package/src/core/gateway-trust.ts +305 -0
  102. package/src/core/gossip-directory.ts +276 -0
  103. package/src/core/gossip-extensions.ts +123 -0
  104. package/src/core/group-proofs.ts +205 -0
  105. package/src/core/handshake.ts +354 -0
  106. package/src/core/hub-forwarding.ts +24 -0
  107. package/src/core/hub-link.ts +176 -0
  108. package/src/core/hub-session.ts +374 -0
  109. package/src/core/identity-store.ts +715 -0
  110. package/src/core/identity.ts +489 -0
  111. package/src/core/index.ts +65 -0
  112. package/src/core/issuer-grouping.ts +165 -0
  113. package/src/core/issuer-identity-file.ts +187 -0
  114. package/src/core/lan-web-actions.ts +34 -0
  115. package/src/core/lan-web-uis.ts +55 -0
  116. package/src/core/ledger-lock.ts +81 -0
  117. package/src/core/listener-registry.ts +133 -0
  118. package/src/core/loopback-address.ts +22 -0
  119. package/src/core/machine-identity.ts +77 -0
  120. package/src/core/machine-list-groups.ts +53 -0
  121. package/src/core/mcp-registry-retry.ts +69 -0
  122. package/src/core/mcp-registry-version.ts +18 -0
  123. package/src/core/membership-proof.ts +139 -0
  124. package/src/core/membership-proofs.ts +149 -0
  125. package/src/core/mesh-graph-trace-tool.ts +81 -0
  126. package/src/core/mesh-graph.ts +89 -0
  127. package/src/core/mesh-network-actions.ts +183 -0
  128. package/src/core/mesh-store-options.ts +23 -0
  129. package/src/core/mesh-store-shared.ts +134 -0
  130. package/src/core/mesh-store.ts +1114 -0
  131. package/src/core/name-claim.ts +145 -0
  132. package/src/core/naming-actions.ts +90 -0
  133. package/src/core/naming.ts +138 -0
  134. package/src/core/nanoid.ts +20 -0
  135. package/src/core/network-interfaces.ts +24 -0
  136. package/src/core/package-version.ts +34 -0
  137. package/src/core/peer-lifecycle.ts +377 -0
  138. package/src/core/peer-link-error.ts +41 -0
  139. package/src/core/peer-versions.ts +98 -0
  140. package/src/core/pending-connection.ts +53 -0
  141. package/src/core/petnames.ts +121 -0
  142. package/src/core/pgp-keyserver.ts +32 -0
  143. package/src/core/pid-lock-file.ts +122 -0
  144. package/src/core/push-manager.ts +76 -0
  145. package/src/core/random-id.ts +9 -0
  146. package/src/core/receiver-set.ts +42 -0
  147. package/src/core/relay-role.ts +182 -0
  148. package/src/core/relay-selection.ts +120 -0
  149. package/src/core/relay-server.ts +198 -0
  150. package/src/core/relay-uplink.ts +78 -0
  151. package/src/core/request-timeouts.ts +37 -0
  152. package/src/core/room-discovery.ts +48 -0
  153. package/src/core/room-lifecycle.ts +838 -0
  154. package/src/core/room-lookup.ts +41 -0
  155. package/src/core/room-messaging.ts +247 -0
  156. package/src/core/room-notice-log.ts +61 -0
  157. package/src/core/room-path.ts +84 -0
  158. package/src/core/room-protocol.ts +893 -0
  159. package/src/core/room-router.ts +180 -0
  160. package/src/core/room-token-verification.ts +85 -0
  161. package/src/core/room-wire-extensions.ts +60 -0
  162. package/src/core/self-name-claim.ts +67 -0
  163. package/src/core/send-outcome.ts +69 -0
  164. package/src/core/shutdown-signals.ts +57 -0
  165. package/src/core/stale-agent-checker.ts +108 -0
  166. package/src/core/store.ts +704 -0
  167. package/src/core/token-text.ts +40 -0
  168. package/src/core/tool.ts +853 -0
  169. package/src/core/transport-events.ts +73 -0
  170. package/src/core/transport-queries.ts +54 -0
  171. package/src/core/transport.ts +367 -0
  172. package/src/core/types.ts +550 -0
  173. package/src/core/user-identity.ts +290 -0
  174. package/src/core/vapid.ts +81 -0
  175. package/src/core/version-check.ts +147 -0
  176. package/src/core/version-report-actions.ts +79 -0
  177. package/src/core/web-beacon.ts +31 -0
  178. package/src/core/web-push.ts +322 -0
  179. package/src/core/wire-mesh-core-version.ts +25 -0
  180. package/src/core/wire-mesh-identity.ts +36 -0
  181. package/src/core/wire-mesh-transport-options.ts +82 -0
  182. package/src/core/wire-mesh-transport.ts +1007 -0
  183. package/src/core/wire-protocol.ts +153 -0
  184. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * CommsTool's gateway-trust action handlers (agent-comms#156, extended by agent-comms#187's principal-keyed allowlist, agent-comms#193's tool-level `principal` flag and agent-comms#343's `machine` flag), split out of tool.ts to keep that file under the repo's max-lines cap, the same reason agent-registry.ts, delivery-engine.ts, and their siblings were split from mesh-store.ts. Free functions over MeshOnlyFeatures's own gateway-trust methods rather than class methods, since CommsTool owns no state of its own for this concern: every one of these is a pure translation from a CommsAction to a CommsResult against whatever store methods are present.
3
+ */
4
+ import { isDeviceIdHex } from "./room-path.js";
5
+ import type { CommsAction } from "./types.js";
6
+ import type { CommsResult, MeshOnlyFeatures } from "./tool.js";
7
+ import { idWithNames, type Namer } from "./naming.js";
8
+
9
+ /** The slice of MeshOnlyFeatures the three functions below actually need, named so this file doesn't repeat the same Pick inline at every signature. */
10
+ export type GatewayTrustStore = Pick<
11
+ MeshOnlyFeatures,
12
+ | "addTrustedGateway"
13
+ | "removeTrustedGateway"
14
+ | "listTrustedGateways"
15
+ | "addTrustedGatewayPrincipal"
16
+ | "removeTrustedGatewayPrincipal"
17
+ | "listTrustedGatewayPrincipals"
18
+ | "addTrustedGatewayMachine"
19
+ | "removeTrustedGatewayMachine"
20
+ | "listTrustedGatewayMachines"
21
+ | "listVerifiedMembers"
22
+ >;
23
+
24
+ /** The result for an id named as both a principal and a machine: the two are different kinds of issuer, so one id is at most one of them. */
25
+ function principalOrMachine(): CommsResult {
26
+ return {
27
+ content: "Pass principal or machine, not both.",
28
+ isError: true,
29
+ };
30
+ }
31
+
32
+ /** The result for an id that is not a full device-id. Trusting one would persist an entry that matches no real device, principal or machine, yet still count towards hasAny and so start this side advertising on the hub. */
33
+ function notADeviceId(device: string): CommsResult {
34
+ return {
35
+ content: `${JSON.stringify(device)} is not a device-id: pass the full 64-character hex id (whoami prints this side's).`,
36
+ isError: true,
37
+ };
38
+ }
39
+
40
+ /** Uniform "gateway trust isn't available on this store" result, mirroring tool.ts's own notMeshBacked helper for the other MeshOnlyFeatures methods. */
41
+ function gatewayTrustUnavailable(): CommsResult {
42
+ return {
43
+ content: "Gateway trust is not available on this store.",
44
+ isError: true,
45
+ };
46
+ }
47
+
48
+ /** Trusts action.device: as a user principal (GatewayTrust.addPrincipal, agent-comms#187) when action.principal is true, as a machine (GatewayTrust.addMachine, agent-comms#343) when action.machine is true, or as a bare remote device-id (the original agent-comms#156 behaviour) otherwise. */
49
+ export function gatewayTrust(
50
+ store: Readonly<GatewayTrustStore>,
51
+ action: CommsAction & { action: "gateway_trust" },
52
+ ): CommsResult {
53
+ if (action.principal === true && action.machine === true) {
54
+ return principalOrMachine();
55
+ }
56
+ if (!isDeviceIdHex(action.device.toLowerCase())) {
57
+ return notADeviceId(action.device);
58
+ }
59
+ if (action.machine === true) {
60
+ if (!store.addTrustedGatewayMachine) return gatewayTrustUnavailable();
61
+ store.addTrustedGatewayMachine(action.device);
62
+ return {
63
+ content: `Trusted remote machine ${action.device}: every device it vouches for is now reachable.`,
64
+ isError: false,
65
+ };
66
+ }
67
+ if (action.principal === true) {
68
+ if (!store.addTrustedGatewayPrincipal) return gatewayTrustUnavailable();
69
+ store.addTrustedGatewayPrincipal(action.device);
70
+ return {
71
+ content: `Trusted remote gateway principal ${action.device}.`,
72
+ isError: false,
73
+ };
74
+ }
75
+ if (!store.addTrustedGateway) return gatewayTrustUnavailable();
76
+ store.addTrustedGateway(action.device);
77
+ return {
78
+ content: `Trusted remote gateway device ${action.device}.`,
79
+ isError: false,
80
+ };
81
+ }
82
+
83
+ /** Withdraws trust from action.device: from the principal allowlist (GatewayTrust.removePrincipal, agent-comms#187) when action.principal is true, from the machine allowlist (GatewayTrust.removeMachine, agent-comms#343, revoking every device it vouches for at once) when action.machine is true, or from the bare-device allowlist (the original agent-comms#156 behaviour) otherwise. */
84
+ export function gatewayUntrust(
85
+ store: Readonly<GatewayTrustStore>,
86
+ action: CommsAction & { action: "gateway_untrust" },
87
+ ): CommsResult {
88
+ if (action.principal === true && action.machine === true) {
89
+ return principalOrMachine();
90
+ }
91
+ if (
92
+ !isDeviceIdHex(action.device.toLowerCase()) &&
93
+ !listedForUntrust(store, action)
94
+ ) {
95
+ return notADeviceId(action.device);
96
+ }
97
+ if (action.machine === true) {
98
+ if (!store.removeTrustedGatewayMachine) return gatewayTrustUnavailable();
99
+ store.removeTrustedGatewayMachine(action.device);
100
+ return {
101
+ content: `Untrusted remote machine ${action.device}: no device is reachable through it any more.`,
102
+ isError: false,
103
+ };
104
+ }
105
+ if (action.principal === true) {
106
+ if (!store.removeTrustedGatewayPrincipal) return gatewayTrustUnavailable();
107
+ store.removeTrustedGatewayPrincipal(action.device);
108
+ return {
109
+ content: `Untrusted remote gateway principal ${action.device}.`,
110
+ isError: false,
111
+ };
112
+ }
113
+ if (!store.removeTrustedGateway) return gatewayTrustUnavailable();
114
+ store.removeTrustedGateway(action.device);
115
+ return {
116
+ content: `Untrusted remote gateway device ${action.device}.`,
117
+ isError: false,
118
+ };
119
+ }
120
+
121
+ /** Whether action.device is already in the set gateway_untrust would remove it from, so an entry an earlier version persisted without checking its shape can still be withdrawn through the tool. */
122
+ function listedForUntrust(
123
+ store: Readonly<GatewayTrustStore>,
124
+ action: CommsAction & { action: "gateway_untrust" },
125
+ ): boolean {
126
+ const listed =
127
+ action.machine === true
128
+ ? store.listTrustedGatewayMachines?.()
129
+ : action.principal === true
130
+ ? store.listTrustedGatewayPrincipals?.()
131
+ : store.listTrustedGateways?.();
132
+ return listed?.includes(action.device.toLowerCase()) === true;
133
+ }
134
+
135
+ /** Reports every currently trusted remote device-id, principal (agent-comms#187) and machine (agent-comms#343) together, and the devices reachable through a trusted principal or machine. Listing is never mutually exclusive between the sets, so, unlike gatewayTrust/gatewayUntrust above, this needs no flag of its own. Each entry shows the full id, which gateway_untrust takes, then the display name namer gives it (agent-comms#345). */
136
+ export function gatewayListTrusted(
137
+ store: Readonly<GatewayTrustStore>,
138
+ namer: Namer,
139
+ ): CommsResult {
140
+ if (!store.listTrustedGateways) return gatewayTrustUnavailable();
141
+ const devices = store.listTrustedGateways();
142
+ const principals = store.listTrustedGatewayPrincipals?.() ?? [];
143
+ const machines = store.listTrustedGatewayMachines?.() ?? [];
144
+ const members = store.listVerifiedMembers?.() ?? [];
145
+ const sections: string[] = [];
146
+ const section = (title: string, lines: readonly string[]): void => {
147
+ if (lines.length > 0) {
148
+ sections.push(
149
+ `${title}:\n${lines.map((line) => ` ${line}`).join("\n")}`,
150
+ );
151
+ }
152
+ };
153
+ const entry = (id: string): string => idWithNames(id, namer);
154
+ section("Trusted remote gateway devices", devices.map(entry));
155
+ section("Trusted remote gateway principals", principals.map(entry));
156
+ section("Trusted remote machines", machines.map(entry));
157
+ section(
158
+ "Devices trusted through a principal",
159
+ members
160
+ .filter((member) => member.kind === "principal")
161
+ .map(
162
+ (member) =>
163
+ `${entry(member.device)} (vouched for by ${namer(member.issuer)})`,
164
+ ),
165
+ );
166
+ section(
167
+ "Devices trusted through a machine",
168
+ members
169
+ .filter((member) => member.kind === "machine")
170
+ .map(
171
+ (member) => `${entry(member.device)} (runs on ${namer(member.issuer)})`,
172
+ ),
173
+ );
174
+ if (sections.length === 0) {
175
+ return {
176
+ content: "No remote gateway devices, principals or machines are trusted.",
177
+ isError: false,
178
+ };
179
+ }
180
+ return {
181
+ content: sections.join("\n"),
182
+ isError: false,
183
+ };
184
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * GatewayTrust -- the cross-machine trust boundary (agent-comms#156, agent-comms#153's third leg): an allowlist of remote device-ids this machine's gateway will advertise its local agents to, accept forwarded hub traffic from, and route outbound hub requests to. Deny-all by default: empty until an operator explicitly trusts at least one remote device, the same no-CA pin-the-key model ordinary peer connections already use.
3
+ *
4
+ * Shared by every store on the machine when constructed with an identity location (agent-comms#186, made machine-wide by agent-comms#293): the trusted set lives in one JSON file per identity directory (identity-store.ts's loadGatewayTrust), because who may see and reach this user's agents from another machine is an operator decision about the machine, and every store on it advertises itself under that decision, including each Claude Code session the default cc-peer front handles, which has no operator of its own to run gateway_trust. Every process on the machine reads and writes that one file, so each read below first checks whether another process has replaced it (gatewayTrustStamp) and reloads if so, and each change reloads, applies itself, and writes back in full, so an add made in one bridge is seen by the others on their next check. Two processes changing the file within the same instant can still lose one of the two changes, since there is no cross-process lock; changes are rare operator actions, so that window is accepted. Constructed with no location, this class keeps the original v1 FederationManager.trustedFingerprints precedent (retired with federation.ts, commit 4232b08): in-memory only, never touching disk, which is what most tests use.
5
+ *
6
+ * Keyed by individual device-id, not by "one entry per remote machine": wire-mesh-core's relay-hub protocol (relay-hub.ts, gossip-frame, relay-data-frame) carries no field identifying which remote gateway connection a given directory entry or relayed request actually originated from -- only the entry/request's own device-id, which may be an ordinary local peer forwarded on a remote machine's behalf rather than that machine's own coordinator. Gating per individual device-id is therefore the finest-grained, and only wire-protocol-honest, trust boundary actually implementable without a wire-mesh-core protocol change (deliberately out of scope here, matching agent-comms#156's own "gating the hub itself is out of scope" framing) -- confirmed as the intended granularity by hub-session.ts's own pre-existing isStateMutatingMessage doc comment, which already named this exact gap as "agent-comms#156's own future deliverable" of "per-peer" admission control. An operator who wants every local peer on a remote machine reachable trusts each of that machine's device-ids individually, not just its coordinator's.
7
+ *
8
+ * Machine-keyed trust (agent-comms#343) is the same shape as the principal's: a machine is another grouping issuer (machine-identity.ts), so trusting one makes every device its gossiped proofs vouch for reachable, and untrusting it cuts all of them off at once, which is how every bridge one account runs on a host is revoked in one act (the key is per OS account, machine-identity.ts). Principals and machines are separate sets because they answer different questions (whose device, which host), and a device may be vouched for by one of each.
9
+ *
10
+ * Principal-keyed trust (agent-comms#187): alongside the bare-device allowlist above, an operator may also trust a user-principal device-id (user-identity.ts) directly -- the root a remote peer's own dm:send-style delegation chain can terminate at, verified the same way device-membership-verification.ts already verifies a device's own group:member chain (`verifyCapabilityToken`'s chain-walk and its `rootIssuer` output). This is purely a second, parallel allowlist: `isTrusted`/`add`/`remove`/`list` keep checking only the bare-device set, unchanged, for a peer with no principal at all -- `isTrustedFor` is the additive entrypoint a caller who has already chain-verified a token uses to decide trust from that verified bearer and chain-root pair, accepting either a directly trusted device or a bearer rooted at a trusted principal. Persisted alongside the bare-device set (agent-comms#186's own persistence, extended here per that issue's own "agent-comms#187 covers what gets stored" framing): loadGatewayTrust/saveGatewayTrust now carry both sets.
11
+ */
12
+ import type { Clock } from "wire-mesh-core/ports/clock";
13
+ import { createSystemClock } from "wire-mesh-core/adapters/system-clock";
14
+ import type { IdentitySlot, LoadedGatewayTrust } from "./identity-store.js";
15
+ import {
16
+ gatewayTrustStamp,
17
+ loadGatewayTrust,
18
+ saveGatewayTrust,
19
+ } from "./identity-store.js";
20
+
21
+ const MS_PER_SECOND = 1000;
22
+ const TRUST_FILE_RECHECK_INTERVAL_SECONDS = 1;
23
+
24
+ /**
25
+ * How long a read decision may rest on the last look at the trust file before the file is looked at again.
26
+ *
27
+ * Every directory entry a node merges asks the trust boundary several questions, so a hub node answers them thousands of times a second, and each used to cost a `stat`. This bound makes the cost one `stat` per interval. It is the longest another process's revocation can go unseen by a read, so it is chosen against the slowest thing a revocation already waits for: a device merged into the directory stays there until the next presence re-advertisement, many seconds on, so a one second bound adds no delay a user could observe to that path while leaving the check far below the rate at which it was being run. Writes never rely on it: each re-reads the file first, so one process cannot overwrite another's change.
28
+ */
29
+ export const TRUST_FILE_RECHECK_INTERVAL_MS =
30
+ TRUST_FILE_RECHECK_INTERVAL_SECONDS * MS_PER_SECOND;
31
+
32
+ /** The read-only slice of GatewayTrust every consumer of the trust boundary actually needs (WireMeshTransport, HubSession, hub-forwarding.ts) -- named so call sites that only ever read trust decisions, never mutate them, don't repeat the same `Pick<GatewayTrust, "isTrusted" | "hasAny" | "isTrustedPrincipal" | "isTrustedFor">` inline at every field/parameter that takes one. */
33
+ export type GatewayTrustReader = Pick<
34
+ GatewayTrust,
35
+ | "isTrusted"
36
+ | "isReachable"
37
+ | "isVouchedBy"
38
+ | "hasAny"
39
+ | "isTrustedPrincipal"
40
+ | "isTrustedFor"
41
+ | "listPrincipals"
42
+ | "listMachines"
43
+ | "noteVerifiedMember"
44
+ >;
45
+
46
+ /** Which kind of grouping issuer vouches for a device: its user principal or its machine. */
47
+ export type GroupKind = "principal" | "machine";
48
+
49
+ /** A grouping issuer that vouched for a device: its kind and its device-id (lowercase hex). */
50
+ export interface Voucher {
51
+ kind: GroupKind;
52
+ issuer: string;
53
+ }
54
+
55
+ /** A device reachable only because a trusted issuer vouches for it, and that issuer. */
56
+ export interface VerifiedMember extends Voucher {
57
+ device: string;
58
+ }
59
+
60
+ /** What a GatewayTrust is built from: where to persist (none for an in-memory boundary) and the clock its trust-file rechecks are paced by. */
61
+ export interface GatewayTrustOptions {
62
+ location?: Readonly<Pick<IdentitySlot, "dir" | "dataDir">> | undefined;
63
+ clock?: Clock | undefined;
64
+ }
65
+
66
+ export class GatewayTrust {
67
+ private readonly trusted = new Set<string>();
68
+ private readonly trustedIssuers: Readonly<Record<GroupKind, Set<string>>> = {
69
+ principal: new Set<string>(),
70
+ machine: new Set<string>(),
71
+ };
72
+ /** Devices whose gossiped membership proof was verified against a trusted issuer, keyed by memberKey (a device can hold one of each kind), with that issuer and when the proof lapses. In memory only: a proof is short-lived and re-verified from the next advert, so nothing here is worth persisting. */
73
+ private readonly verifiedMembers = new Map<
74
+ string,
75
+ VerifiedMember & { expiresAt: number }
76
+ >();
77
+ private readonly location:
78
+ Readonly<Pick<IdentitySlot, "dir" | "dataDir">> | undefined;
79
+ /** gatewayTrustStamp as of the last load or write this instance made, so refresh() can tell when another process has replaced the file since. */
80
+ private stamp: string | undefined;
81
+ /** When the file's stamp was last compared, so reads can skip the comparison inside TRUST_FILE_RECHECK_INTERVAL_MS. */
82
+ private checkedAt: number | undefined;
83
+ private readonly clock: Clock;
84
+
85
+ /** Constructs the trust boundary, optionally bound to an identity location for persistence (agent-comms#186); any IdentitySlot serves as one, since only its directory matters. See this class's own doc comment for what a location does and doesn't change. Given a location, immediately loads whatever devices and principals are already trusted into the in-memory sets. */
86
+ constructor(options: Readonly<GatewayTrustOptions> = {}) {
87
+ this.location = options.location;
88
+ this.clock = options.clock ?? createSystemClock();
89
+ this.load();
90
+ }
91
+
92
+ /** Replaces the in-memory sets with whatever the file currently holds, recording the file's stamp. A no-op without a location. Verified members are left alone: they are in memory only and are dropped on their own when their principal stops being trusted. */
93
+ private load(): void {
94
+ if (this.location === undefined) return;
95
+ this.checkedAt = this.clock.now();
96
+ this.stamp = gatewayTrustStamp(this.location);
97
+ const loaded = loadGatewayTrust(this.location);
98
+ this.trusted.clear();
99
+ this.trustedIssuers.principal.clear();
100
+ this.trustedIssuers.machine.clear();
101
+ for (const deviceHex of loaded.devices) {
102
+ this.trusted.add(deviceHex);
103
+ }
104
+ for (const principalHex of loaded.principals) {
105
+ this.trustedIssuers.principal.add(principalHex);
106
+ }
107
+ for (const machineHex of loaded.machines) {
108
+ this.trustedIssuers.machine.add(machineHex);
109
+ }
110
+ }
111
+
112
+ /** Reloads from disk when another process has replaced the file since this instance last looked, looking at most once per TRUST_FILE_RECHECK_INTERVAL_MS. Called first by every method that only reads the sets, so a trust decision is never older than the file by more than that bound. */
113
+ private refresh(): void {
114
+ if (this.location === undefined) return;
115
+ const now = this.clock.now();
116
+ if (
117
+ this.checkedAt !== undefined &&
118
+ now - this.checkedAt < TRUST_FILE_RECHECK_INTERVAL_MS
119
+ ) {
120
+ return;
121
+ }
122
+ this.refreshNow();
123
+ }
124
+
125
+ /** Looks at the file now, whatever the last look was. Every method that changes the sets starts here, because it rewrites the whole file from them: starting from anything older would erase another process's change. */
126
+ private refreshNow(): void {
127
+ if (this.location === undefined) return;
128
+ this.checkedAt = this.clock.now();
129
+ if (gatewayTrustStamp(this.location) !== this.stamp) this.load();
130
+ }
131
+
132
+ /** Marks a remote device-id (hex, case-insensitive) as trusted: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. Idempotent. Persists the updated set when this instance was constructed with a location. */
133
+ add(deviceHex: string): void {
134
+ this.refreshNow();
135
+ this.trusted.add(deviceHex.toLowerCase());
136
+ this.persist();
137
+ }
138
+
139
+ /** Withdraws a previously trusted device-id (hex, case-insensitive). A no-op if it was never trusted. Requests already in flight are unaffected; a directory entry merged from the hub because of this trust alone leaves the transport's view on its next read (KnownDevices, gossip-directory.ts). Persists the updated set when this instance was constructed with a location. */
140
+ remove(deviceHex: string): void {
141
+ this.refreshNow();
142
+ this.trusted.delete(deviceHex.toLowerCase());
143
+ this.persist();
144
+ }
145
+
146
+ /** Writes the complete current trusted device, principal and machine sets back to the shared trust file, if this instance was constructed with a location. A no-op for the in-memory-only case. */
147
+ private persist(): void {
148
+ if (this.location === undefined) return;
149
+ const trust: LoadedGatewayTrust = {
150
+ devices: [...this.trusted],
151
+ principals: [...this.trustedIssuers.principal],
152
+ machines: [...this.trustedIssuers.machine],
153
+ };
154
+ saveGatewayTrust(this.location, trust);
155
+ this.stamp = gatewayTrustStamp(this.location);
156
+ }
157
+
158
+ /** Every currently trusted device-id, lowercase hex, in insertion order. */
159
+ list(): string[] {
160
+ this.refresh();
161
+ return [...this.trusted];
162
+ }
163
+
164
+ /** Whether the given device-id (hex, case-insensitive) is trusted by id: on the bare-device allowlist. A device trusted only because a principal or machine vouches for it (noteVerifiedMember) is not, on purpose: its proof is a claim that anyone who has read the device's gossiped advert can repeat, so it earns only what isReachable grants. */
165
+ isTrusted(deviceHex: string): boolean {
166
+ this.refresh();
167
+ return this.trusted.has(deviceHex.toLowerCase());
168
+ }
169
+
170
+ /** Whether this side may merge the device's gossiped directory entry and route requests to it: trusted by id, or a verified member of a trusted principal or machine. Requests routed to a device are still authenticated end to end by the session inside the relay, so a false claim can misroute one (which then fails its handshake) but not read or forge one. */
171
+ isReachable(deviceHex: string): boolean {
172
+ this.refresh();
173
+ const key = deviceHex.toLowerCase();
174
+ return (
175
+ this.trusted.has(key) ||
176
+ this.isVerifiedMember(key, "principal") ||
177
+ this.isVerifiedMember(key, "machine")
178
+ );
179
+ }
180
+
181
+ /** Whether the device (hex, case-insensitive) is currently a verified member through a trusted issuer of the given kind: its proof of that kind has not lapsed and its issuer is still trusted. Unlike isReachable, this answers for one kind alone, so a device reachable through its principal is not taken to hold a live machine record too. */
182
+ isVouchedBy(deviceHex: string, kind: GroupKind): boolean {
183
+ this.refresh();
184
+ return this.isVerifiedMember(deviceHex.toLowerCase(), kind);
185
+ }
186
+
187
+ /** Records that deviceHex presented a membership proof, valid until expiresAt (epoch ms), verified against voucher (agent-comms#266, agent-comms#343). From then until the proof lapses, or the voucher's issuer stops being trusted, isReachable treats the device as reachable. A later proof from the same kind of issuer never shortens an earlier one's window. The caller has already verified the proof: this class does no cryptography. */
188
+ noteVerifiedMember(
189
+ deviceHex: string,
190
+ voucher: Readonly<Voucher>,
191
+ expiresAt: number,
192
+ ): void {
193
+ const device = deviceHex.toLowerCase();
194
+ const now = Date.now();
195
+ for (const [key, member] of this.verifiedMembers) {
196
+ if (member.expiresAt <= now) this.verifiedMembers.delete(key);
197
+ }
198
+ const key = memberKey(device, voucher.kind);
199
+ const existing = this.verifiedMembers.get(key);
200
+ this.verifiedMembers.set(key, {
201
+ device,
202
+ kind: voucher.kind,
203
+ issuer: voucher.issuer.toLowerCase(),
204
+ expiresAt: Math.max(expiresAt, existing?.expiresAt ?? 0),
205
+ });
206
+ }
207
+
208
+ /** Every device currently reachable because a trusted principal or machine vouches for it, once per vouching issuer. Excludes proofs that have lapsed and members whose issuer is no longer trusted. */
209
+ listVerifiedMembers(): VerifiedMember[] {
210
+ this.refresh();
211
+ return [...this.verifiedMembers.values()]
212
+ .filter((member) => this.isVerifiedMember(member.device, member.kind))
213
+ .map(({ device, kind, issuer }) => ({ device, kind, issuer }));
214
+ }
215
+
216
+ private isVerifiedMember(device: string, kind: GroupKind): boolean {
217
+ const member = this.verifiedMembers.get(memberKey(device, kind));
218
+ return (
219
+ member !== undefined &&
220
+ member.expiresAt > Date.now() &&
221
+ this.trustedIssuers[kind].has(member.issuer)
222
+ );
223
+ }
224
+
225
+ private addIssuer(kind: GroupKind, deviceHex: string): void {
226
+ this.refreshNow();
227
+ this.trustedIssuers[kind].add(deviceHex.toLowerCase());
228
+ this.persist();
229
+ }
230
+
231
+ /** Withdraws trust from one issuer and, in the same act, from every device reachable only through it; the transport's directory drops those devices on its next read (KnownDevices, gossip-directory.ts). */
232
+ private removeIssuer(kind: GroupKind, deviceHex: string): void {
233
+ this.refreshNow();
234
+ const issuer = deviceHex.toLowerCase();
235
+ this.trustedIssuers[kind].delete(issuer);
236
+ for (const [key, member] of this.verifiedMembers) {
237
+ if (member.kind === kind && member.issuer === issuer) {
238
+ this.verifiedMembers.delete(key);
239
+ }
240
+ }
241
+ this.persist();
242
+ }
243
+
244
+ /** Marks a remote user-principal device-id (hex, case-insensitive; user-identity.ts) as trusted (agent-comms#187): a peer presenting a token whose delegation chain roots at this principal is trusted via `isTrustedFor` below, without that peer's own bare device-id ever needing individual trust, and a device whose gossiped proof this principal vouches for becomes reachable. Idempotent, and entirely independent of the bare-device allowlist `add` manages. Persists the updated set when this instance was constructed with a location. */
245
+ addPrincipal(deviceHex: string): void {
246
+ this.addIssuer("principal", deviceHex);
247
+ }
248
+
249
+ /** Withdraws a previously trusted principal (hex, case-insensitive), and every device reachable only through it. A no-op if it was never trusted. Persists the updated set when this instance was constructed with a location. */
250
+ removePrincipal(deviceHex: string): void {
251
+ this.removeIssuer("principal", deviceHex);
252
+ }
253
+
254
+ /** Every currently trusted principal device-id, lowercase hex, in insertion order. */
255
+ listPrincipals(): string[] {
256
+ this.refresh();
257
+ return [...this.trustedIssuers.principal];
258
+ }
259
+
260
+ /** Whether the given device-id (hex, case-insensitive) is currently trusted as a user principal. */
261
+ isTrustedPrincipal(deviceHex: string): boolean {
262
+ this.refresh();
263
+ return this.trustedIssuers.principal.has(deviceHex.toLowerCase());
264
+ }
265
+
266
+ /** Marks a remote machine device-id (hex, case-insensitive; machine-identity.ts) as trusted (agent-comms#343): every device whose gossiped proof this machine vouches for becomes reachable, without being trusted individually. Idempotent. Persists the updated set when this instance was constructed with a location. */
267
+ addMachine(deviceHex: string): void {
268
+ this.addIssuer("machine", deviceHex);
269
+ }
270
+
271
+ /** Withdraws a previously trusted machine (hex, case-insensitive), and in the same act every device reachable only because that machine vouched for it, revoking that machine key's bridges on the host. A device also trusted by id or through a trusted principal stays reachable by that route. A no-op if the machine was never trusted. Persists the updated set when this instance was constructed with a location. */
272
+ removeMachine(deviceHex: string): void {
273
+ this.removeIssuer("machine", deviceHex);
274
+ }
275
+
276
+ /** Every currently trusted machine device-id, lowercase hex, in insertion order. */
277
+ listMachines(): string[] {
278
+ this.refresh();
279
+ return [...this.trustedIssuers.machine];
280
+ }
281
+
282
+ /**
283
+ * Whether a peer is trusted, given a token's own bearer and chain-root device-ids (hex, case-insensitive) a caller has already chain-verified via `verifyCapabilityToken` (the same `rootIssuer` output device-membership-verification.ts's own chain check already relies on). Passes when the bearer itself is directly trusted (the existing bare-device path, unmodified for a peer with no principal at all), OR when the chain's root is a trusted principal -- so a device sub-delegated from an admitted principal (mirroring agent-comms#161's own "principal admits its own devices" pattern) is trusted without ever being individually allowlisted. This class performs no cryptographic verification itself; the caller supplies already-verified device-ids, keeping GatewayTrust the same plain allowlist it always was.
284
+ */
285
+ isTrustedFor(bearerHex: string, rootIssuerHex: string): boolean {
286
+ return this.isTrusted(bearerHex) || this.isTrustedPrincipal(rootIssuerHex);
287
+ }
288
+
289
+ /**
290
+ * Whether at least one remote device, principal or machine is currently trusted: the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device, principal or machine. Once true, an advertisement still reaches every hub-connected peer, trusted or not; the per-device isTrusted()/isTrustedFor() checks above are what keep this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters.
291
+ */
292
+ hasAny(): boolean {
293
+ this.refresh();
294
+ return (
295
+ this.trusted.size > 0 ||
296
+ this.trustedIssuers.principal.size > 0 ||
297
+ this.trustedIssuers.machine.size > 0
298
+ );
299
+ }
300
+ }
301
+
302
+ /** verifiedMembers' key: one entry per device per kind of issuer. */
303
+ function memberKey(device: string, kind: GroupKind): string {
304
+ return `${kind}:${device}`;
305
+ }