agent-comms 8.15.9 → 8.15.11

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 (189) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridges/user/web/server.d.ts.map +1 -1
  5. package/dist/bridges/user/web/server.js +44 -24
  6. package/dist/bridges/user/web/server.js.map +1 -1
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  10. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  14. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  15. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  16. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  17. package/dist/bridges/user/web/web-assets.js +67 -0
  18. package/dist/bridges/user/web/web-assets.js.map +1 -0
  19. package/dist/test/pi-manifest-packed.integration.test.d.ts +2 -0
  20. package/dist/test/pi-manifest-packed.integration.test.d.ts.map +1 -0
  21. package/dist/test/pi-manifest-packed.integration.test.js +68 -0
  22. package/dist/test/pi-manifest-packed.integration.test.js.map +1 -0
  23. package/package.json +8 -2
  24. package/src/account-cli.ts +195 -0
  25. package/src/account-join-cli.ts +191 -0
  26. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  27. package/src/bridges/cc-peer/bridge.ts +138 -0
  28. package/src/bridges/cc-peer/default-front.ts +58 -0
  29. package/src/bridges/cc-peer/front-controller.ts +160 -0
  30. package/src/bridges/cc-peer/front-relay.ts +191 -0
  31. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  32. package/src/bridges/cc-peer/front.ts +59 -0
  33. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  34. package/src/bridges/cc-peer/run.ts +100 -0
  35. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  36. package/src/bridges/claude-code/channel.ts +318 -0
  37. package/src/bridges/codex/tool.ts +132 -0
  38. package/src/bridges/mcp/server.ts +137 -0
  39. package/src/bridges/opencode/plugin.ts +130 -0
  40. package/src/bridges/pi/extension.ts +330 -0
  41. package/src/bridges/registry.ts +17 -0
  42. package/src/bridges/user/cli.ts +87 -0
  43. package/src/bridges/user/controller.ts +345 -0
  44. package/src/bridges/user/tui.ts +300 -0
  45. package/src/bridges/user/web/actions.ts +173 -0
  46. package/src/bridges/user/web/contract.ts +222 -0
  47. package/src/bridges/user/web/dist/bundle.js +49 -0
  48. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  49. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  50. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  51. package/src/bridges/user/web/dist/index.html +21 -0
  52. package/src/bridges/user/web/dist/manifest.json +26 -0
  53. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  54. package/src/bridges/user/web/dist/sw.js +1 -0
  55. package/src/bridges/user/web/event-publisher.ts +114 -0
  56. package/src/bridges/user/web/port-discovery.ts +49 -0
  57. package/src/bridges/user/web/reads.ts +35 -0
  58. package/src/bridges/user/web/router.ts +158 -0
  59. package/src/bridges/user/web/server.ts +782 -0
  60. package/src/bridges/user/web/vite.config.ts +71 -0
  61. package/src/bridges/user/web/web-access.ts +404 -0
  62. package/src/bridges/user/web/web-assets.ts +90 -0
  63. package/src/bridges/user/web/web-beacon.ts +69 -0
  64. package/src/bridges/user/web/web-console-static.ts +90 -0
  65. package/src/cli-main.ts +635 -0
  66. package/src/cli.ts +13 -0
  67. package/src/core/account-bundle.ts +264 -0
  68. package/src/core/account-join.ts +289 -0
  69. package/src/core/account-ledger-crypto.ts +154 -0
  70. package/src/core/account-ledger-store.ts +66 -0
  71. package/src/core/account-ledger.ts +385 -0
  72. package/src/core/agent-details.ts +216 -0
  73. package/src/core/agent-list-format.ts +97 -0
  74. package/src/core/agent-registry.ts +282 -0
  75. package/src/core/agent-table.ts +58 -0
  76. package/src/core/atomic-file.ts +46 -0
  77. package/src/core/bind-retry.ts +38 -0
  78. package/src/core/bridge-mesh.ts +176 -0
  79. package/src/core/bridge.ts +718 -0
  80. package/src/core/capability-ask.ts +162 -0
  81. package/src/core/capability-bubble-up.ts +237 -0
  82. package/src/core/comms-store.ts +130 -0
  83. package/src/core/connection-approval.ts +134 -0
  84. package/src/core/connection-code-tool.ts +92 -0
  85. package/src/core/connection-code.ts +296 -0
  86. package/src/core/coordinator-role.ts +224 -0
  87. package/src/core/data-directory.ts +32 -0
  88. package/src/core/data-frame-routing.ts +309 -0
  89. package/src/core/default-command.ts +12 -0
  90. package/src/core/delegation-policy.ts +41 -0
  91. package/src/core/delivery-engine.ts +649 -0
  92. package/src/core/device-membership-verification.ts +125 -0
  93. package/src/core/device-membership.ts +130 -0
  94. package/src/core/directory-admission.ts +155 -0
  95. package/src/core/discovery-mdns.ts +247 -0
  96. package/src/core/discovery-tailscale.ts +224 -0
  97. package/src/core/discovery.ts +303 -0
  98. package/src/core/display-name.ts +73 -0
  99. package/src/core/dm-grant-actions.ts +72 -0
  100. package/src/core/dm-send-delegation.ts +73 -0
  101. package/src/core/dm-token-verification.ts +76 -0
  102. package/src/core/election-sessions.ts +135 -0
  103. package/src/core/error-reporting.ts +85 -0
  104. package/src/core/first-contact.ts +242 -0
  105. package/src/core/gateway-trust-actions.ts +184 -0
  106. package/src/core/gateway-trust.ts +305 -0
  107. package/src/core/gossip-directory.ts +276 -0
  108. package/src/core/gossip-extensions.ts +123 -0
  109. package/src/core/group-proofs.ts +205 -0
  110. package/src/core/handshake.ts +354 -0
  111. package/src/core/hub-forwarding.ts +24 -0
  112. package/src/core/hub-link.ts +176 -0
  113. package/src/core/hub-session.ts +374 -0
  114. package/src/core/identity-store.ts +715 -0
  115. package/src/core/identity.ts +489 -0
  116. package/src/core/index.ts +65 -0
  117. package/src/core/issuer-grouping.ts +165 -0
  118. package/src/core/issuer-identity-file.ts +187 -0
  119. package/src/core/lan-web-actions.ts +34 -0
  120. package/src/core/lan-web-uis.ts +55 -0
  121. package/src/core/ledger-lock.ts +81 -0
  122. package/src/core/listener-registry.ts +133 -0
  123. package/src/core/loopback-address.ts +22 -0
  124. package/src/core/machine-identity.ts +77 -0
  125. package/src/core/machine-list-groups.ts +53 -0
  126. package/src/core/mcp-registry-retry.ts +69 -0
  127. package/src/core/mcp-registry-version.ts +18 -0
  128. package/src/core/membership-proof.ts +139 -0
  129. package/src/core/membership-proofs.ts +149 -0
  130. package/src/core/mesh-graph-trace-tool.ts +81 -0
  131. package/src/core/mesh-graph.ts +89 -0
  132. package/src/core/mesh-network-actions.ts +183 -0
  133. package/src/core/mesh-store-options.ts +23 -0
  134. package/src/core/mesh-store-shared.ts +134 -0
  135. package/src/core/mesh-store.ts +1114 -0
  136. package/src/core/name-claim.ts +145 -0
  137. package/src/core/naming-actions.ts +90 -0
  138. package/src/core/naming.ts +138 -0
  139. package/src/core/nanoid.ts +20 -0
  140. package/src/core/network-interfaces.ts +24 -0
  141. package/src/core/package-version.ts +34 -0
  142. package/src/core/peer-lifecycle.ts +377 -0
  143. package/src/core/peer-link-error.ts +41 -0
  144. package/src/core/peer-versions.ts +98 -0
  145. package/src/core/pending-connection.ts +53 -0
  146. package/src/core/petnames.ts +121 -0
  147. package/src/core/pgp-keyserver.ts +32 -0
  148. package/src/core/pid-lock-file.ts +122 -0
  149. package/src/core/push-manager.ts +76 -0
  150. package/src/core/random-id.ts +9 -0
  151. package/src/core/receiver-set.ts +42 -0
  152. package/src/core/relay-role.ts +182 -0
  153. package/src/core/relay-selection.ts +120 -0
  154. package/src/core/relay-server.ts +198 -0
  155. package/src/core/relay-uplink.ts +78 -0
  156. package/src/core/request-timeouts.ts +37 -0
  157. package/src/core/room-discovery.ts +48 -0
  158. package/src/core/room-lifecycle.ts +838 -0
  159. package/src/core/room-lookup.ts +41 -0
  160. package/src/core/room-messaging.ts +247 -0
  161. package/src/core/room-notice-log.ts +61 -0
  162. package/src/core/room-path.ts +84 -0
  163. package/src/core/room-protocol.ts +893 -0
  164. package/src/core/room-router.ts +180 -0
  165. package/src/core/room-token-verification.ts +85 -0
  166. package/src/core/room-wire-extensions.ts +60 -0
  167. package/src/core/self-name-claim.ts +67 -0
  168. package/src/core/send-outcome.ts +69 -0
  169. package/src/core/shutdown-signals.ts +57 -0
  170. package/src/core/stale-agent-checker.ts +108 -0
  171. package/src/core/store.ts +704 -0
  172. package/src/core/token-text.ts +40 -0
  173. package/src/core/tool.ts +853 -0
  174. package/src/core/transport-events.ts +73 -0
  175. package/src/core/transport-queries.ts +54 -0
  176. package/src/core/transport.ts +367 -0
  177. package/src/core/types.ts +550 -0
  178. package/src/core/user-identity.ts +290 -0
  179. package/src/core/vapid.ts +81 -0
  180. package/src/core/version-check.ts +147 -0
  181. package/src/core/version-report-actions.ts +79 -0
  182. package/src/core/web-beacon.ts +31 -0
  183. package/src/core/web-push.ts +322 -0
  184. package/src/core/wire-mesh-core-version.ts +25 -0
  185. package/src/core/wire-mesh-identity.ts +36 -0
  186. package/src/core/wire-mesh-transport-options.ts +82 -0
  187. package/src/core/wire-mesh-transport.ts +1007 -0
  188. package/src/core/wire-protocol.ts +153 -0
  189. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Persistent user-principal identity: a single keypair scoped to this machine account rather than any one (harness, cwd) bridge slot, the root issuer agent-comms#160 introduces, so a user principal can mint capability tokens to the devices it owns, independent of which bridge process happens to be running. See identity-store.ts for the per-slot device identity every bridge already has; this is deliberately a separate, shared identity, not a variant of it.
3
+ *
4
+ * Stored at ~/.agent-comms/user-identity.json (mode 0600), sibling to but distinct from any identity-<harness>--<cwd>.json slot file and to the machine identity (machine-identity.ts). Loading, creation and renewal are issuer-identity-file.ts's, shared with the machine identity; this module adds the principal's own issued-grant bookkeeping to the same record.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { resolveDataDir } from "./data-directory.js";
10
+ import type { AccountContents } from "./account-bundle.js";
11
+ import { certifyKeyPair } from "./identity.js";
12
+ import type { PeerIdentity } from "./identity.js";
13
+ import { CommsError } from "./store.js";
14
+ import {
15
+ isStoredIssuerKey,
16
+ issuerKeyRecord,
17
+ issuerPeerIdentity,
18
+ loadOrCreateIssuerIdentity,
19
+ readIssuerRecord,
20
+ writeIssuerRecord,
21
+ type StoredIssuerKey,
22
+ } from "./issuer-identity-file.js";
23
+
24
+ export interface UserIdentityOptions {
25
+ /** Directory override for tests; defaults to ~/.agent-comms, the same base directory identity-store.ts's own per-slot files live in. */
26
+ dir?: string;
27
+ }
28
+
29
+ interface StoredUserIdentity extends StoredIssuerKey {
30
+ /** The name the account asserts for itself (agent-comms#359), signed by the user key into every device's advert. Belongs to the account, so it is exported with the key and handed over by a join, unlike the writer nonce. */
31
+ displayName?: string;
32
+ /** This machine's writer nonce for the account's replicated grant ledger (account-ledger.ts), base64. Belongs to the machine, not the account: it is never exported with the key, since two machines appending under one nonce would share a writer log and fork it. */
33
+ ledgerWriter?: string;
34
+ /** The token-id (base64) of each group:member grant this principal issued, keyed by the admitted device's hex id, as builds before the replicated ledger kept them. Read only to migrate them into the ledger on first start (account-ledger-store.ts), then removed. */
35
+ issuedDeviceGrants?: Record<string, string>;
36
+ /** The token-id (base64) of each dm:send grant this principal issued, keyed by the bearer's hex id, as builds before the replicated ledger kept them. Migrated and removed exactly like issuedDeviceGrants. */
37
+ issuedDmGrants?: Record<string, string>;
38
+ }
39
+
40
+ function isStringRecord(value: unknown): value is Record<string, string> {
41
+ if (typeof value !== "object" || value === null) return false;
42
+ return Object.values(value).every((v) => typeof v === "string");
43
+ }
44
+
45
+ function isStoredUserIdentity(value: unknown): value is StoredUserIdentity {
46
+ if (!isStoredIssuerKey(value)) return false;
47
+ if (
48
+ "issuedDeviceGrants" in value &&
49
+ !isStringRecord(value.issuedDeviceGrants)
50
+ )
51
+ return false;
52
+ if ("issuedDmGrants" in value && !isStringRecord(value.issuedDmGrants))
53
+ return false;
54
+ if ("ledgerWriter" in value && typeof value.ledgerWriter !== "string")
55
+ return false;
56
+ if ("displayName" in value && typeof value.displayName !== "string")
57
+ return false;
58
+ return true;
59
+ }
60
+
61
+ /** The directory the user identity and every other per-account file on this machine live in. */
62
+ export function userIdentityDir(
63
+ options?: Readonly<UserIdentityOptions>,
64
+ ): string {
65
+ return resolveDataDir({ dir: options?.dir });
66
+ }
67
+
68
+ function userIdentityFile(options?: Readonly<UserIdentityOptions>): string {
69
+ return path.join(userIdentityDir(options), "user-identity.json");
70
+ }
71
+
72
+ function writeStoredUserIdentity(
73
+ file: string,
74
+ stored: Readonly<StoredUserIdentity>,
75
+ ): void {
76
+ writeIssuerRecord(file, stored);
77
+ }
78
+
79
+ /** Reads this principal's raw stored record, or undefined if the file is missing; throws if it exists but is unusable (issuer-identity-file.ts). Used by the issued-grant functions below to read-modify-write only their own field, leaving the persisted key material exactly as it is. */
80
+ function readStoredUserIdentity(file: string): StoredUserIdentity | undefined {
81
+ return readIssuerRecord(file, isStoredUserIdentity);
82
+ }
83
+
84
+ /**
85
+ * Loads the persisted user-principal identity, creating it on first use (issuer-identity-file.ts: exclusive create, renewal in place that keeps the issued-grant bookkeeping, an unusable file refused rather than regenerated).
86
+ */
87
+ export function loadOrCreateUserIdentity(
88
+ options?: Readonly<UserIdentityOptions>,
89
+ ): PeerIdentity {
90
+ return loadOrCreateIssuerIdentity(
91
+ userIdentityFile(options),
92
+ isStoredUserIdentity,
93
+ );
94
+ }
95
+
96
+ /** Reads the persisted record, throwing if the principal has never been created: every ledger function below needs the key it belongs to. */
97
+ function requireStoredUserIdentity(file: string): StoredUserIdentity {
98
+ const stored = readStoredUserIdentity(file);
99
+ if (stored === undefined) {
100
+ throw new Error(
101
+ `no user identity persisted yet; call loadOrCreateUserIdentity first (${file})`,
102
+ );
103
+ }
104
+ return stored;
105
+ }
106
+
107
+ /** This machine's writer nonce for the account ledger, generated and persisted on first use. Two processes racing to create it can each end up with a different one; that is harmless, since each writes its own writer log and the ledger is the union of every writer log the account key recognises. What must never happen is two machines sharing one, which is why it is not part of an exported account. */
108
+ export function loadOrCreateLedgerWriterNonce(
109
+ options: Readonly<UserIdentityOptions> | undefined,
110
+ generate: () => Uint8Array,
111
+ ): Uint8Array<ArrayBuffer> {
112
+ const file = userIdentityFile(options);
113
+ const stored = requireStoredUserIdentity(file);
114
+ if (stored.ledgerWriter !== undefined) {
115
+ return Uint8Array.from(Buffer.from(stored.ledgerWriter, "base64"));
116
+ }
117
+ const nonce = Uint8Array.from(generate());
118
+ writeStoredUserIdentity(file, {
119
+ ...stored,
120
+ ledgerWriter: Buffer.from(nonce).toString("base64"),
121
+ });
122
+ return nonce;
123
+ }
124
+
125
+ /** One grant a build before the replicated ledger recorded in user-identity.json. */
126
+ export interface LegacyIssuedGrant {
127
+ kind: "device" | "dm";
128
+ subject: string;
129
+ tokenId: Uint8Array<ArrayBuffer>;
130
+ }
131
+
132
+ function legacyEntries(
133
+ kind: LegacyIssuedGrant["kind"],
134
+ record: Readonly<Record<string, string>> | undefined,
135
+ ): LegacyIssuedGrant[] {
136
+ return Object.entries(record ?? {}).map(([subject, encoded]) => ({
137
+ kind,
138
+ subject,
139
+ tokenId: Uint8Array.from(Buffer.from(encoded, "base64")),
140
+ }));
141
+ }
142
+
143
+ /** Every issued grant a pre-ledger build left in user-identity.json, still waiting to be migrated into the replicated ledger. Empty once migrated, or for an identity created by this build. */
144
+ export function readLegacyIssuedGrants(
145
+ options: Readonly<UserIdentityOptions> | undefined,
146
+ ): LegacyIssuedGrant[] {
147
+ const stored = requireStoredUserIdentity(userIdentityFile(options));
148
+ return [
149
+ ...legacyEntries("device", stored.issuedDeviceGrants),
150
+ ...legacyEntries("dm", stored.issuedDmGrants),
151
+ ];
152
+ }
153
+
154
+ function withoutMigrated(
155
+ record: Readonly<Record<string, string>> | undefined,
156
+ kind: LegacyIssuedGrant["kind"],
157
+ migrated: readonly Readonly<LegacyIssuedGrant>[],
158
+ ): Record<string, string> | undefined {
159
+ if (record === undefined) return undefined;
160
+ const done = new Set(
161
+ migrated
162
+ .filter((grant) => grant.kind === kind)
163
+ .map(
164
+ (grant) =>
165
+ `${grant.subject}:${Buffer.from(grant.tokenId).toString("base64")}`,
166
+ ),
167
+ );
168
+ const remaining = Object.entries(record).filter(
169
+ ([subject, encoded]) => !done.has(`${subject}:${encoded}`),
170
+ );
171
+ return remaining.length === 0 ? undefined : Object.fromEntries(remaining);
172
+ }
173
+
174
+ /** Removes exactly the given legacy grants from user-identity.json once they are in the ledger. The file is re-read first, so a grant written meanwhile by an older build still running on this machine stays for the next migration rather than being lost. */
175
+ export function clearLegacyIssuedGrants(
176
+ options: Readonly<UserIdentityOptions> | undefined,
177
+ migrated: readonly Readonly<LegacyIssuedGrant>[],
178
+ ): void {
179
+ const file = userIdentityFile(options);
180
+ const { issuedDeviceGrants, issuedDmGrants, ...rest } =
181
+ requireStoredUserIdentity(file);
182
+ const device = withoutMigrated(issuedDeviceGrants, "device", migrated);
183
+ const dm = withoutMigrated(issuedDmGrants, "dm", migrated);
184
+ writeStoredUserIdentity(file, {
185
+ ...rest,
186
+ ...(device === undefined ? {} : { issuedDeviceGrants: device }),
187
+ ...(dm === undefined ? {} : { issuedDmGrants: dm }),
188
+ });
189
+ }
190
+
191
+ /** The name this account asserts for itself, or undefined when it has none or the account has not been created yet. Read from disk on every call, so a name set by any bridge on this machine is seen by all of them. */
192
+ export function loadUserDisplayName(
193
+ options?: Readonly<UserIdentityOptions>,
194
+ ): string | undefined {
195
+ return readStoredUserIdentity(userIdentityFile(options))?.displayName;
196
+ }
197
+
198
+ /** Sets (or, given undefined, clears) the name this account asserts for itself, keeping the key material and every other field. Throws if the account has not been created yet. */
199
+ export function saveUserDisplayName(
200
+ options: Readonly<UserIdentityOptions> | undefined,
201
+ displayName: string | undefined,
202
+ ): void {
203
+ const file = userIdentityFile(options);
204
+ const next: StoredUserIdentity = { ...requireStoredUserIdentity(file) };
205
+ if (displayName === undefined) delete next.displayName;
206
+ else next.displayName = displayName;
207
+ writeStoredUserIdentity(file, next);
208
+ }
209
+
210
+ /** What an export or a join carries of the account: its PEM private key and the name it asserts for itself, if any. Throws if the principal has never been created. */
211
+ export function readAccountContents(
212
+ options: Readonly<UserIdentityOptions> | undefined,
213
+ ): AccountContents {
214
+ const { privateKey, displayName } = requireStoredUserIdentity(
215
+ userIdentityFile(options),
216
+ );
217
+ return {
218
+ privateKey,
219
+ ...(displayName === undefined ? {} : { displayName }),
220
+ };
221
+ }
222
+
223
+ /** Where a replaced account's record is kept when a different account is imported over it: its key is still the only one that can revoke what it issued, so it is set aside rather than destroyed. */
224
+ export function replacedUserIdentityFile(
225
+ options: Readonly<UserIdentityOptions> | undefined,
226
+ replaced: Readonly<PeerIdentity>,
227
+ ): string {
228
+ return path.join(
229
+ userIdentityDir(options),
230
+ `user-identity.replaced-${Buffer.from(replaced.deviceId).toString("hex")}.json`,
231
+ );
232
+ }
233
+
234
+ /** Keeps `record`, the account being replaced in `currentFile`, at `replacedFile`. A record already there for the same key is from an earlier import that replaced this same account; it is overwritten with the current one, which may carry a renewed certificate, written to a temporary file and renamed into place so the set-aside record is never seen half-written; a file there that does not parse holds no usable record and is overwritten too. A record there holding a different key would be lost by overwriting it, and the file name derives from the key, so that file is damaged: CommsError ACCOUNT_SET_ASIDE_CONFLICT names both files rather than touching either. */
235
+ function setAside(
236
+ replacedFile: string,
237
+ record: Readonly<StoredUserIdentity>,
238
+ currentFile: string,
239
+ ): void {
240
+ const earlier = readStoredUserIdentity(replacedFile);
241
+ if (earlier !== undefined && earlier.privateKey !== record.privateKey) {
242
+ throw new CommsError(
243
+ `Cannot set aside the account in ${currentFile}: ${replacedFile} already exists and holds a different key. Move one of them away and import again.`,
244
+ "ACCOUNT_SET_ASIDE_CONFLICT",
245
+ );
246
+ }
247
+ writeIssuerRecord(replacedFile, record);
248
+ }
249
+
250
+ /**
251
+ * Makes this machine hold the account whose contents are given, the deliberate copy that lets one principal span machines. The key is re-certified here (the device-id, which is the account's identity, depends only on the key). The name the account asserts for itself comes with it; an import carrying none leaves the name of the account already held untouched. This machine's own ledger writer nonce is kept, since it belongs to the machine, not the account. A different account already held here is moved aside to replacedUserIdentityFile first, never overwritten (see setAside for a record already set aside there); importing the account already held changes nothing but that name. Returns the imported identity and, when one was replaced, where the old record now is.
252
+ */
253
+ export function importAccountKey(
254
+ options: Readonly<UserIdentityOptions> | undefined,
255
+ contents: Readonly<AccountContents>,
256
+ ): { identity: PeerIdentity; replacedFile?: string } {
257
+ const file = userIdentityFile(options);
258
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
259
+ const imported = certifyKeyPair(contents.privateKey);
260
+ const named = (record: StoredUserIdentity): StoredUserIdentity => ({
261
+ ...record,
262
+ ...(contents.displayName === undefined
263
+ ? {}
264
+ : { displayName: contents.displayName }),
265
+ });
266
+ const existing = readStoredUserIdentity(file);
267
+ if (existing === undefined) {
268
+ writeStoredUserIdentity(file, named(issuerKeyRecord(imported)));
269
+ return { identity: imported };
270
+ }
271
+ const current = issuerPeerIdentity(existing);
272
+ if (Buffer.from(current.deviceId).equals(Buffer.from(imported.deviceId))) {
273
+ if (contents.displayName !== undefined) {
274
+ writeStoredUserIdentity(file, named(existing));
275
+ }
276
+ return { identity: current };
277
+ }
278
+ const replacedFile = replacedUserIdentityFile(options, current);
279
+ setAside(replacedFile, existing, file);
280
+ writeStoredUserIdentity(
281
+ file,
282
+ named({
283
+ ...issuerKeyRecord(imported),
284
+ ...(existing.ledgerWriter === undefined
285
+ ? {}
286
+ : { ledgerWriter: existing.ledgerWriter }),
287
+ }),
288
+ );
289
+ return { identity: imported, replacedFile };
290
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * VAPID key generation for Web Push (RFC 8292).
3
+ *
4
+ * Generates a P-256 EC key pair once per process lifetime and caches it
5
+ * in memory. The public key is shared with the PWA client so it can
6
+ * create push subscriptions; the private key signs push requests.
7
+ */
8
+
9
+ import * as crypto from "node:crypto";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface VapidKeys {
16
+ /** Base64url-encoded uncompressed P-256 public key (65 bytes). */
17
+ publicKey: string;
18
+ /** Base64url-encoded P-256 private key (32 bytes). */
19
+ privateKey: string;
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Cached singleton
24
+ // ---------------------------------------------------------------------------
25
+
26
+ let cachedKeys: VapidKeys | undefined;
27
+
28
+ /** Length in bytes of an uncompressed P-256 public key point (0x04 prefix + 32-byte X + 32-byte Y). */
29
+ const P256_PUBLIC_KEY_BYTES = 65;
30
+
31
+ /** Length in bytes of a P-256 private scalar. */
32
+ const P256_PRIVATE_KEY_BYTES = 32;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Implementation
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Generate or return cached VAPID keys.
40
+ *
41
+ * Uses Node.js `crypto.generateKeyPairSync` with P-256, extracts the
42
+ * raw 65-byte uncompressed public point and the 32-byte private scalar,
43
+ * and base64url-encodes both per RFC 8291.
44
+ */
45
+ export function generateVapidKeys(): VapidKeys {
46
+ if (cachedKeys) return cachedKeys;
47
+
48
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
49
+ namedCurve: "P-256",
50
+ });
51
+
52
+ // Export public key as raw DER, extract the 65-byte uncompressed point.
53
+ // SPKI DER for P-256 has a 26-byte header before the 65-byte point.
54
+ const pubDer = publicKey.export({ type: "spki", format: "der" });
55
+ const publicKeyBuffer = pubDer.subarray(
56
+ pubDer.length - P256_PUBLIC_KEY_BYTES,
57
+ );
58
+
59
+ // Export private key as PKCS8 DER, extract the 32-byte private scalar.
60
+ // PKCS8 DER for P-256 has a 39-byte header before the 32-byte scalar.
61
+ // The scalar is the last 32 bytes (after the leading zero pad byte).
62
+ const privDer = privateKey.export({ type: "pkcs8", format: "der" });
63
+ const privateKeyBuffer = privDer.subarray(
64
+ privDer.length - P256_PRIVATE_KEY_BYTES,
65
+ );
66
+
67
+ cachedKeys = {
68
+ publicKey: base64url(publicKeyBuffer),
69
+ privateKey: base64url(privateKeyBuffer),
70
+ };
71
+
72
+ return cachedKeys;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Helpers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function base64url(buffer: Buffer): string {
80
+ return buffer.toString("base64url");
81
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Detects when a newer agent-comms release exists on npm than the one currently running -- see issue #166. A stale checkout (e.g. a manually pulled git checkout left behind while npm moves on) surfaces its own drift via CommsTool's whoami/update output (see tool.ts) rather than silently splitting mesh generations. Every failure mode (offline, registry down, timeout, malformed response) is swallowed here: a bridge with no network access behaves exactly as if no newer release were known, never as an error.
3
+ */
4
+
5
+ /** Minimal shape of a fetch() call, injectable so tests never need a real network round trip or a global fetch stub. */
6
+ export type FetchLike = (
7
+ input: string,
8
+ init?: Readonly<RequestInit>,
9
+ ) => Promise<Response>;
10
+
11
+ const NPM_REGISTRY_LATEST_VERSION_URL =
12
+ "https://registry.npmjs.org/agent-comms/latest";
13
+
14
+ /** How long a single npm registry lookup may take before it's treated as a failure -- generous for a slow connection, short enough that a hung request can never pile up across successive interval checks. */
15
+ const REGISTRY_FETCH_TIMEOUT_MS = 5000;
16
+
17
+ const MS_PER_SECOND = 1000;
18
+ const SECONDS_PER_MINUTE = 60;
19
+ const MINUTES_PER_HOUR = 60;
20
+ const CHECK_INTERVAL_HOURS = 6;
21
+
22
+ /** How often VersionDriftChecker re-checks npm once started. Deliberately slow: this is a background courtesy warning that piggybacks on whoami/update output (see tool.ts), not a thing any caller waits on, so there's no benefit to checking more often than a bridge process is likely to stay running for. */
23
+ export const DEFAULT_CHECK_INTERVAL_MS =
24
+ CHECK_INTERVAL_HOURS * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND;
25
+
26
+ interface NpmRegistryLatestResponse {
27
+ version: string;
28
+ }
29
+
30
+ function isNpmRegistryLatestResponse(
31
+ value: unknown,
32
+ ): value is NpmRegistryLatestResponse {
33
+ if (typeof value !== "object" || value === null) return false;
34
+ if (!("version" in value)) return false;
35
+ return typeof value.version === "string";
36
+ }
37
+
38
+ /** Fetches the "latest" dist-tag version currently published to npm, or undefined on any failure (offline, timeout, non-2xx, malformed body) -- callers never need their own try/catch around this. */
39
+ export async function fetchLatestPublishedVersion(options?: {
40
+ registryUrl?: string;
41
+ fetchImpl?: FetchLike;
42
+ }): Promise<string | undefined> {
43
+ const { registryUrl = NPM_REGISTRY_LATEST_VERSION_URL, fetchImpl = fetch } =
44
+ options ?? {};
45
+ try {
46
+ const response = await fetchImpl(registryUrl, {
47
+ signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
48
+ });
49
+ if (!response.ok) return undefined;
50
+ const body: unknown = await response.json();
51
+ if (!isNpmRegistryLatestResponse(body)) return undefined;
52
+ return body.version;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ const VERSION_CORE_PART_COUNT = 3;
59
+
60
+ /** Parses a "x.y.z[-prerelease][+build]" version string's numeric major/minor/patch parts, ignoring any prerelease/build suffix -- agent-comms releases are always plain x.y.z (semantic-release's own default), so a fuller semver-precedence comparator (prerelease ordering, build-metadata rules) would be speculative generality with no real input that needs it. Returns undefined for a string that isn't three dot-separated non-negative integers. */
61
+ function parseVersionCore(
62
+ version: string,
63
+ ): [number, number, number] | undefined {
64
+ const core = version.split(/[+-]/, 1)[0] ?? "";
65
+ const parts = core.split(".");
66
+ if (parts.length !== VERSION_CORE_PART_COUNT) return undefined;
67
+ const major = Number(parts[0]);
68
+ const minor = Number(parts[1]);
69
+ const patch = Number(parts[2]);
70
+ const isValid = [major, minor, patch].every(
71
+ (part) => Number.isInteger(part) && part >= 0,
72
+ );
73
+ if (!isValid) return undefined;
74
+ return [major, minor, patch];
75
+ }
76
+
77
+ /** True when latest is a strictly greater release than current, by major.minor.patch precedence. Returns false for either string that doesn't parse as a plain x.y.z version -- a malformed comparison has no safe "yes, warn" answer. */
78
+ export function isNewerVersion(current: string, latest: string): boolean {
79
+ const currentCore = parseVersionCore(current);
80
+ const latestCore = parseVersionCore(latest);
81
+ if (currentCore === undefined || latestCore === undefined) return false;
82
+ const [currentMajor, currentMinor, currentPatch] = currentCore;
83
+ const [latestMajor, latestMinor, latestPatch] = latestCore;
84
+ if (latestMajor !== currentMajor) return latestMajor > currentMajor;
85
+ if (latestMinor !== currentMinor) return latestMinor > currentMinor;
86
+ return latestPatch > currentPatch;
87
+ }
88
+
89
+ export interface VersionDriftCheckerOptions {
90
+ currentVersion: string;
91
+ intervalMs?: number;
92
+ fetchLatestVersion?: () => Promise<string | undefined>;
93
+ }
94
+
95
+ /**
96
+ * Periodically (and once immediately on start()) compares the running version against npm's published "latest" dist-tag, caching the most recent successful lookup so CommsTool's whoami/update output can surface a "newer release available" note without either of them ever waiting on a network call. A failed check leaves the previously cached result untouched -- see this file's own header for why a failure is never surfaced as an error.
97
+ */
98
+ export class VersionDriftChecker {
99
+ private readonly currentVersion: string;
100
+ private readonly intervalMs: number;
101
+ private readonly fetchLatestVersion: () => Promise<string | undefined>;
102
+ private latestKnownVersion: string | undefined;
103
+ private timer: ReturnType<typeof setInterval> | undefined;
104
+
105
+ constructor(options: Readonly<VersionDriftCheckerOptions>) {
106
+ this.currentVersion = options.currentVersion;
107
+ this.intervalMs = options.intervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
108
+ this.fetchLatestVersion =
109
+ options.fetchLatestVersion ?? (async () => fetchLatestPublishedVersion());
110
+ }
111
+
112
+ /** Runs one check immediately (fire-and-forget -- start() itself never waits on the network) and schedules a recurring check every intervalMs. The interval is unref'd so it can never keep a bridge process alive on its own. No-op if already started. */
113
+ start(): void {
114
+ if (this.timer !== undefined) return;
115
+ void this.check();
116
+ this.timer = setInterval(() => {
117
+ void this.check();
118
+ }, this.intervalMs);
119
+ this.timer.unref();
120
+ }
121
+
122
+ /** Stops the recurring check, if running. Safe to call even if start() was never called. */
123
+ stop(): void {
124
+ if (this.timer !== undefined) {
125
+ clearInterval(this.timer);
126
+ this.timer = undefined;
127
+ }
128
+ }
129
+
130
+ /** The most recently observed newer release, or undefined when none is known -- either no successful check has completed yet, or the running version is already current (or ahead). */
131
+ getNewerVersionIfAny(): string | undefined {
132
+ if (this.latestKnownVersion === undefined) return undefined;
133
+ return isNewerVersion(this.currentVersion, this.latestKnownVersion)
134
+ ? this.latestKnownVersion
135
+ : undefined;
136
+ }
137
+
138
+ /** Runs a single check, tolerating a rejection from a caller-supplied fetchLatestVersion as well as the soft-failure (undefined) contract fetchLatestPublishedVersion's own default already guarantees -- start()'s own fire-and-forget call, and the recurring interval callback, both rely on this never throwing regardless of which fetch implementation is wired in. */
139
+ private async check(): Promise<void> {
140
+ try {
141
+ const latest = await this.fetchLatestVersion();
142
+ if (latest !== undefined) this.latestKnownVersion = latest;
143
+ } catch {
144
+ // Swallowed deliberately -- see this file's own header on failing soft.
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * CommsTool's version-reporting formatting and the query_version action handler (agent-comms#198) -- split out of tool.ts purely to keep that file under the repo's max-lines cap, the same reason gateway-trust-actions.ts and its siblings were split out before it. Free functions over MeshOnlyFeatures's own version 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 whatever the store/local process knows into either a CommsResult or a formatted line, mirroring gateway-trust-actions.ts's own shape.
3
+ */
4
+ import type { CommsAction } from "./types.js";
5
+ import type { CommsResult, MeshOnlyFeatures } from "./tool.js";
6
+ import { notMeshBacked } from "./tool.js";
7
+ import { getOwnPackageVersion } from "./package-version.js";
8
+ import { getWireMeshCoreVersion } from "./wire-mesh-core-version.js";
9
+
10
+ /** The slice of MeshOnlyFeatures the functions below actually need. */
11
+ export type VersionReportStore = Pick<
12
+ MeshOnlyFeatures,
13
+ | "queryVersion"
14
+ | "getPeerAgentCommsVersion"
15
+ | "getPeerWireMeshCoreVersion"
16
+ | "getPeerCcPeerVersion"
17
+ >;
18
+
19
+ /** Answers query_version by asking the target device for its own, currently-running wire-mesh-core version live, right now -- the cache-bust counterpart to whatever it last gossiped. */
20
+ export async function handleQueryVersion(
21
+ store: Readonly<VersionReportStore>,
22
+ action: CommsAction & { action: "query_version" },
23
+ ): Promise<CommsResult> {
24
+ if (!store.queryVersion) return notMeshBacked("query_version");
25
+ const result = await store.queryVersion(action.device);
26
+ if ("error" in result) {
27
+ return {
28
+ content: `Failed to query ${action.device}'s version: ${result.error}`,
29
+ isError: true,
30
+ };
31
+ }
32
+ return {
33
+ content: `${action.device} is running wire-mesh-core ${result.version}.`,
34
+ isError: false,
35
+ };
36
+ }
37
+
38
+ /** This process's own wire-mesh-core version line, plus a cc-peer version line when getCcPeerVersion answers one -- whoami's own trailing version lines, appended after its existing "Version: ..." line. */
39
+ export function formatSelfVersionLines(
40
+ getCcPeerVersion: (() => string | undefined) | undefined,
41
+ ): string[] {
42
+ const lines = [`Wire-mesh-core: ${getWireMeshCoreVersion()}`];
43
+ const ccPeerVersion = getCcPeerVersion?.();
44
+ if (ccPeerVersion !== undefined) lines.push(`Cc-peer: ${ccPeerVersion}`);
45
+ return lines;
46
+ }
47
+
48
+ /** The trailing ", wireMeshCore=..., ccPeer=..." fragment update's own single summary line appends after its existing "version=..." field. */
49
+ export function formatSelfVersionSuffix(
50
+ getCcPeerVersion: (() => string | undefined) | undefined,
51
+ ): string {
52
+ const ccPeerVersion = getCcPeerVersion?.();
53
+ const ccPeerSuffix =
54
+ ccPeerVersion !== undefined ? `, ccPeer=${ccPeerVersion}` : "";
55
+ return `, wireMeshCore=${getWireMeshCoreVersion()}${ccPeerSuffix}`;
56
+ }
57
+
58
+ /** One listed agent's own "agent-comms X, wire-mesh-core Y[, cc-peer Z]" summary for list_agents -- read locally (this process's own installed versions) for the requester's own entry, or from whatever deviceId last gossiped for every other listed agent. "unknown" stands in for a value neither source has (a peer that hasn't gossiped yet, or is running an agent-comms/wire-mesh-core predating this feature) -- cc-peer is omitted entirely rather than shown as "unknown", since it is only ever present at all while that specific device is actually fronting/bridging cc-peer. */
59
+ export function formatListedAgentVersions(
60
+ store: Readonly<VersionReportStore>,
61
+ deviceId: string,
62
+ isSelf: boolean,
63
+ getCcPeerVersion: (() => string | undefined) | undefined,
64
+ ): string {
65
+ const agentCommsVersion = isSelf
66
+ ? getOwnPackageVersion()
67
+ : store.getPeerAgentCommsVersion?.(deviceId);
68
+ const wireMeshCoreVersion = isSelf
69
+ ? getWireMeshCoreVersion()
70
+ : store.getPeerWireMeshCoreVersion?.(deviceId);
71
+ const ccPeerVersion = isSelf
72
+ ? getCcPeerVersion?.()
73
+ : store.getPeerCcPeerVersion?.(deviceId);
74
+ return [
75
+ `agent-comms ${agentCommsVersion ?? "unknown"}`,
76
+ `wire-mesh-core ${wireMeshCoreVersion ?? "unknown"}`,
77
+ ...(ccPeerVersion !== undefined ? [`cc-peer ${ccPeerVersion}`] : []),
78
+ ].join(", ");
79
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The LAN web UI beacon's own type and payload (agent-comms#346, receiving side agent-comms#353): the one datagram shape both the sender (bridges/user/web/web-beacon.ts) and every listener on the first-contact port agree on, kept in core so first-contact can recognise it without depending on a bridge.
3
+ */
4
+
5
+ /** The beacon type, distinct from first-contact's own `agent-comms-beacon`. */
6
+ export const WEB_BEACON_TYPE = "agent-comms-web-beacon";
7
+
8
+ /** What a beacon carries: the broadcasting bridge's peer id and the port its web UI listens on, and nothing else. The receiver learns the host from the datagram's source address, and the access token is never advertised, so seeing the beacon grants no access. */
9
+ export interface WebBeaconPayload {
10
+ type: typeof WEB_BEACON_TYPE;
11
+ peerId: string;
12
+ webPort: number;
13
+ }
14
+
15
+ /** The exclusive upper bound of the UDP port space: a port is a 16-bit integer. */
16
+ const MAX_UDP_PORT = 0x10000;
17
+
18
+ /** Narrows an untrusted datagram body into a WebBeaconPayload. */
19
+ export function isWebBeaconPayload(
20
+ value: unknown,
21
+ ): value is Readonly<WebBeaconPayload> {
22
+ if (typeof value !== "object" || value === null) return false;
23
+ if (!("type" in value) || value.type !== WEB_BEACON_TYPE) return false;
24
+ if (!("peerId" in value) || typeof value.peerId !== "string") return false;
25
+ if (!("webPort" in value) || typeof value.webPort !== "number") return false;
26
+ return (
27
+ Number.isInteger(value.webPort) &&
28
+ value.webPort > 0 &&
29
+ value.webPort < MAX_UDP_PORT
30
+ );
31
+ }