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,264 @@
1
+ /**
2
+ * The account key in transit (agent-comms#344): the one deliberate way a user principal's private key leaves the machine it lives on, so a second machine can hold the same account and, through the replicated grant ledger, see and revoke everything the first minted. The key only ever travels sealed: under a passphrase for an export the person carries themselves, or under a key derived from a one-time account invite for a join, never as plaintext.
3
+ */
4
+
5
+ import {
6
+ createCipheriv,
7
+ createDecipheriv,
8
+ hkdfSync,
9
+ randomBytes,
10
+ scryptSync,
11
+ } from "node:crypto";
12
+ import { z } from "zod";
13
+ import { parseDisplayName } from "./display-name.js";
14
+ import { CommsError } from "./store.js";
15
+
16
+ /** The text an exported bundle starts with, so a file can be recognised for what it is before any decryption is attempted. */
17
+ export const ACCOUNT_BUNDLE_PREFIX = "agent-comms-account:v1:";
18
+
19
+ /** scrypt's cost as OWASP's password storage guidance recommends it (N = 2^17, r = 8, p = 1): slow enough that guessing a passphrase against a stolen bundle is expensive, fast enough for a once-per-machine export or import. */
20
+ const SCRYPT_COST_LOG2 = 17;
21
+ const SCRYPT_COST = 2 ** SCRYPT_COST_LOG2;
22
+ const SCRYPT_BLOCK_SIZE = 8;
23
+ const SCRYPT_PARALLELISM = 1;
24
+ /** scrypt needs 128 * N * r bytes of memory; Node refuses by default above 32 MiB, so the limit is raised to exactly twice what these parameters need. */
25
+ const SCRYPT_BYTES_PER_UNIT = 128;
26
+ const SCRYPT_MAX_MEMORY =
27
+ 2 * SCRYPT_BYTES_PER_UNIT * SCRYPT_COST * SCRYPT_BLOCK_SIZE;
28
+ /** A 128-bit salt, the size NIST SP 800-132 sets as the minimum. */
29
+ const SALT_BYTES = 16;
30
+ const KEY_BYTES = 32;
31
+ /** GCM's specified IV length. */
32
+ const IV_BYTES = 12;
33
+ /** The shortest passphrase an export accepts. Twelve characters is the length NCSC and NIST guidance treat as the floor for a passphrase protecting something offline-attackable; the scrypt cost above does the rest. */
34
+ export const MIN_ACCOUNT_PASSPHRASE_LENGTH = 12;
35
+
36
+ const INVITE_KEY_INFO = "agent-comms/account-invite/v1";
37
+ const BUNDLE_AAD = Buffer.from(ACCOUNT_BUNDLE_PREFIX);
38
+
39
+ const base64url = z.string().regex(/^[A-Za-z0-9_-]*$/);
40
+
41
+ /** What an account export or a join carries: the PEM private key and the name the account asserts for itself, if it has one. */
42
+ export interface AccountContents {
43
+ privateKey: string;
44
+ displayName?: string;
45
+ }
46
+
47
+ const accountContentsSchema = z
48
+ .object({ privateKey: z.string(), displayName: z.string().optional() })
49
+ .strict();
50
+
51
+ /** The contents as the plaintext a bundle or a join seals. */
52
+ function contentsText(contents: Readonly<AccountContents>): string {
53
+ return JSON.stringify(contents);
54
+ }
55
+
56
+ /** The contents a sealed plaintext holds. Throws INVALID_BUNDLE for a plaintext that is not account contents or whose name breaks the display-name rules, since a name from outside is shown to people. */
57
+ function parseContents(plaintext: string, damaged: string): AccountContents {
58
+ let decoded: unknown;
59
+ try {
60
+ decoded = JSON.parse(plaintext);
61
+ } catch {
62
+ throw new CommsError(damaged, "INVALID_BUNDLE");
63
+ }
64
+ const contents = accountContentsSchema.safeParse(decoded);
65
+ if (
66
+ !contents.success ||
67
+ (contents.data.displayName !== undefined &&
68
+ parseDisplayName(contents.data.displayName) !== contents.data.displayName)
69
+ ) {
70
+ throw new CommsError(damaged, "INVALID_BUNDLE");
71
+ }
72
+ return {
73
+ privateKey: contents.data.privateKey,
74
+ ...(contents.data.displayName === undefined
75
+ ? {}
76
+ : { displayName: contents.data.displayName }),
77
+ };
78
+ }
79
+
80
+ const passphraseEnvelopeSchema = z
81
+ .object({
82
+ kdf: z.literal("scrypt"),
83
+ n: z.number().int().positive(),
84
+ r: z.number().int().positive(),
85
+ p: z.number().int().positive(),
86
+ salt: base64url,
87
+ iv: base64url,
88
+ tag: base64url,
89
+ ct: base64url,
90
+ })
91
+ .strict();
92
+
93
+ const inviteEnvelopeSchema = z
94
+ .object({ iv: base64url, tag: base64url, ct: base64url })
95
+ .strict();
96
+
97
+ function seal(
98
+ key: Buffer,
99
+ plaintext: string,
100
+ ): { iv: string; tag: string; ct: string } {
101
+ const iv = randomBytes(IV_BYTES);
102
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
103
+ cipher.setAAD(BUNDLE_AAD);
104
+ const ct = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
105
+ return {
106
+ iv: iv.toString("base64url"),
107
+ tag: cipher.getAuthTag().toString("base64url"),
108
+ ct: ct.toString("base64url"),
109
+ };
110
+ }
111
+
112
+ /** The plaintext of a sealed envelope, or undefined if the key is wrong or the envelope was altered. */
113
+ function open(
114
+ key: Buffer,
115
+ envelope: Readonly<{ iv: string; tag: string; ct: string }>,
116
+ ): string | undefined {
117
+ const decipher = createDecipheriv(
118
+ "aes-256-gcm",
119
+ key,
120
+ Buffer.from(envelope.iv, "base64url"),
121
+ );
122
+ decipher.setAAD(BUNDLE_AAD);
123
+ decipher.setAuthTag(Buffer.from(envelope.tag, "base64url"));
124
+ try {
125
+ return Buffer.concat([
126
+ decipher.update(Buffer.from(envelope.ct, "base64url")),
127
+ decipher.final(),
128
+ ]).toString("utf-8");
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ function passphraseKey(
135
+ passphrase: string,
136
+ salt: Buffer,
137
+ cost: Readonly<{ n: number; r: number; p: number }>,
138
+ ): Buffer {
139
+ return scryptSync(passphrase, salt, KEY_BYTES, {
140
+ N: cost.n,
141
+ r: cost.r,
142
+ p: cost.p,
143
+ maxmem: SCRYPT_MAX_MEMORY,
144
+ });
145
+ }
146
+
147
+ /** Seals the account's contents under a passphrase, as one line of text. Throws WEAK_PASSPHRASE for a passphrase shorter than MIN_ACCOUNT_PASSPHRASE_LENGTH. */
148
+ export function sealAccountBundle(
149
+ contents: Readonly<AccountContents>,
150
+ passphrase: string,
151
+ ): string {
152
+ if (passphrase.length < MIN_ACCOUNT_PASSPHRASE_LENGTH) {
153
+ throw new CommsError(
154
+ `An account export passphrase must be at least ${String(MIN_ACCOUNT_PASSPHRASE_LENGTH)} characters`,
155
+ "WEAK_PASSPHRASE",
156
+ );
157
+ }
158
+ const salt = randomBytes(SALT_BYTES);
159
+ const cost = { n: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM };
160
+ const envelope = {
161
+ kdf: "scrypt",
162
+ ...cost,
163
+ salt: salt.toString("base64url"),
164
+ ...seal(passphraseKey(passphrase, salt, cost), contentsText(contents)),
165
+ };
166
+ return `${ACCOUNT_BUNDLE_PREFIX}${Buffer.from(JSON.stringify(envelope)).toString("base64url")}`;
167
+ }
168
+
169
+ /** The account contents a bundle holds. Throws INVALID_BUNDLE for text that is not a bundle this build can read, and WRONG_PASSPHRASE when the passphrase does not open it. The cost parameters are read from the bundle but capped at this build's own, so a crafted bundle cannot make an import run for hours. */
170
+ export function openAccountBundle(
171
+ bundle: string,
172
+ passphrase: string,
173
+ ): AccountContents {
174
+ const text = bundle.trim();
175
+ if (!text.startsWith(ACCOUNT_BUNDLE_PREFIX)) {
176
+ throw new CommsError("Not an agent-comms account bundle", "INVALID_BUNDLE");
177
+ }
178
+ let decoded: unknown;
179
+ try {
180
+ decoded = JSON.parse(
181
+ Buffer.from(
182
+ text.slice(ACCOUNT_BUNDLE_PREFIX.length),
183
+ "base64url",
184
+ ).toString("utf-8"),
185
+ );
186
+ } catch {
187
+ throw new CommsError("The account bundle is damaged", "INVALID_BUNDLE");
188
+ }
189
+ const envelope = passphraseEnvelopeSchema.safeParse(decoded);
190
+ if (
191
+ !envelope.success ||
192
+ envelope.data.n > SCRYPT_COST ||
193
+ envelope.data.r > SCRYPT_BLOCK_SIZE ||
194
+ envelope.data.p > SCRYPT_PARALLELISM
195
+ ) {
196
+ throw new CommsError("The account bundle is damaged", "INVALID_BUNDLE");
197
+ }
198
+ const key = passphraseKey(
199
+ passphrase,
200
+ Buffer.from(envelope.data.salt, "base64url"),
201
+ envelope.data,
202
+ );
203
+ const plaintext = open(key, envelope.data);
204
+ if (plaintext === undefined) {
205
+ throw new CommsError(
206
+ "The passphrase does not open this account bundle",
207
+ "WRONG_PASSPHRASE",
208
+ );
209
+ }
210
+ return parseContents(plaintext, "The account bundle is damaged");
211
+ }
212
+
213
+ /** The fields of an account invite both ends know: the invite's own single-use nonce is the secret, and the expiry and issuing device are bound in so a sealed key cannot be replayed under a different invite. */
214
+ export interface AccountInviteSecret {
215
+ code: string;
216
+ expiresAt: string;
217
+ deviceId: string;
218
+ }
219
+
220
+ function inviteKey(invite: Readonly<AccountInviteSecret>): Buffer {
221
+ return Buffer.from(
222
+ hkdfSync(
223
+ "sha256",
224
+ Buffer.from(invite.code),
225
+ Buffer.from(`${invite.expiresAt}:${invite.deviceId}`),
226
+ INVITE_KEY_INFO,
227
+ KEY_BYTES,
228
+ ),
229
+ );
230
+ }
231
+
232
+ /** Seals the account's contents for the machine redeeming `invite`. The invite's nonce carries enough entropy to key it directly, with no passphrase stretching needed. */
233
+ export function sealAccountKeyForInvite(
234
+ contents: Readonly<AccountContents>,
235
+ invite: Readonly<AccountInviteSecret>,
236
+ ): string {
237
+ return Buffer.from(
238
+ JSON.stringify(seal(inviteKey(invite), contentsText(contents))),
239
+ ).toString("base64url");
240
+ }
241
+
242
+ /** The account contents sealed for `invite`. Throws INVALID_BUNDLE when the text does not open under that invite. */
243
+ export function openAccountKeyFromInvite(
244
+ sealed: string,
245
+ invite: Readonly<AccountInviteSecret>,
246
+ ): AccountContents {
247
+ let decoded: unknown;
248
+ try {
249
+ decoded = JSON.parse(Buffer.from(sealed, "base64url").toString("utf-8"));
250
+ } catch {
251
+ throw new CommsError("The sealed account key is damaged", "INVALID_BUNDLE");
252
+ }
253
+ const envelope = inviteEnvelopeSchema.safeParse(decoded);
254
+ const plaintext = envelope.success
255
+ ? open(inviteKey(invite), envelope.data)
256
+ : undefined;
257
+ if (plaintext === undefined) {
258
+ throw new CommsError(
259
+ "The sealed account key does not open under this invite",
260
+ "INVALID_BUNDLE",
261
+ );
262
+ }
263
+ return parseContents(plaintext, "The sealed account key is damaged");
264
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Joining a machine to an account (agent-comms#344): one deliberate act per new machine. A machine holding the account issues an account invite, an ordinary connection code (connection-code.ts) it also remembers as an invite; the person carries it to the new machine, which redeems it. Redeeming trusts the issuing device exactly as any connection code does, then asks that device, over the mesh's end-to-end authenticated request path (directly or relayed by the hub), for the account key, which comes back sealed under a key only the invite's holder can derive. The new machine then holds the account: its devices mint membership proofs as the account, so every machine already trusting the account's principal covers them with no per-pair step, and it replicates and can revoke from the account's grant ledger.
3
+ *
4
+ * The issuing device enforces the invite for real, not only the redeemer: it answers each invite once and never after its expiry, so a copied invite is worth nothing once used or stale, and it refuses to issue one that would stay valid for longer than an ordinary connection code (DEFAULT_CONNECTION_CODE_TTL_MS), which is already sized for a person relaying it by hand, since an invite is the account key to whoever redeems it first. Invites live only in the issuing store's memory, so a restart of that store before the invite is used cancels it.
5
+ *
6
+ * A join can carry a label for the joining machine. The issuing device validates it and keeps it as its own petname for the joiner's machine (naming.ts), so the machine the person just admitted is listed by the name they typed without a second step. The label and the machine id it names are the joiner's own assertion, which is acceptable only because whoever redeems an invite already receives the whole account; the petname is nothing but the issuing person's own view and is never gossiped.
7
+ *
8
+ * Neither half is an agent_comms tool action: an invite hands over the account key and a join replaces the machine's account, so both are `agent-comms account invite` and `agent-comms account join`, run by the person at the terminal (account-cli.ts), where nothing an agent is told can trigger them and the invite is never written into an agent's transcript.
9
+ */
10
+
11
+ import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
12
+ import type { IncomingManageRequest } from "wire-mesh-core/domain/mesh-session";
13
+ import type { ManageOutcome } from "wire-mesh-core/domain/mesh-session";
14
+ import {
15
+ openAccountKeyFromInvite,
16
+ sealAccountKeyForInvite,
17
+ type AccountInviteSecret,
18
+ } from "./account-bundle.js";
19
+ import { openAccountLedger } from "./account-ledger-store.js";
20
+ import {
21
+ DEFAULT_CONNECTION_CODE_TTL_MS,
22
+ type ConnectionCodeLedger,
23
+ type GenerateConnectionCodeOptions,
24
+ type RedeemConnectionCodeOptions,
25
+ } from "./connection-code.js";
26
+ import type { GatewayTrust } from "./gateway-trust.js";
27
+ import type { MeshStoreIdentity } from "./mesh-store-shared.js";
28
+ import { parseDisplayName } from "./display-name.js";
29
+ import { isDeviceIdHex } from "./room-path.js";
30
+ import { CommsError, requireDisplayName } from "./store.js";
31
+ import type { MeshTransport } from "./transport.js";
32
+ import type { ConnectionCode } from "./types.js";
33
+ import { importAccountKey, readAccountContents } from "./user-identity.js";
34
+ import { toIdentityPort } from "./wire-mesh-identity.js";
35
+
36
+ /** The request verb an invite's redeemer sends its issuer (params.verb), and the capability verb the manage-command carries only to satisfy manage-command.verb's grammar: the handler checks the invite itself, never a token. */
37
+ export const ACCOUNT_JOIN_VERB = "account.join";
38
+ const ACCOUNT_JOIN_CAPABILITY_VERB = "account:join";
39
+
40
+ /** The outcomes sendRoomRequest gives when this side has no route to the device yet, as opposed to an answer from the device. */
41
+ export const NO_ROUTE_CODES: ReadonlySet<string> = new Set([
42
+ "no_route",
43
+ "not_connected",
44
+ ]);
45
+ const MS_PER_SECOND = 1000;
46
+ /** How long a join waits for a route to the issuing device to come up: a hub dial and its first directory exchange take seconds, so this is generous without leaving a person waiting on an unreachable issuer for long. */
47
+ const JOIN_ROUTE_WAIT_SECONDS = 30;
48
+ export const JOIN_ROUTE_WAIT_MS = JOIN_ROUTE_WAIT_SECONDS * MS_PER_SECOND;
49
+ /** Between attempts to reach the issuing device: short against the wait, long enough not to spin. */
50
+ export const JOIN_ROUTE_RETRY_MS = 250;
51
+
52
+ /** What became of an invite: answered with the account key, or expired unanswered. */
53
+ export type AccountInviteOutcome = "redeemed" | "expired";
54
+
55
+ export interface AccountInvite {
56
+ code: ConnectionCode;
57
+ /** Settles once the invite has been answered or has expired, whichever comes first. */
58
+ settled: Promise<AccountInviteOutcome>;
59
+ }
60
+
61
+ interface PendingInvite {
62
+ secret: AccountInviteSecret;
63
+ settle: (outcome: AccountInviteOutcome) => void;
64
+ }
65
+
66
+ export interface AccountJoinDeps {
67
+ requireIdentity: () => MeshStoreIdentity;
68
+ requireTransport: () => MeshTransport;
69
+ getPeerId: () => string;
70
+ connectionCodes: ConnectionCodeLedger;
71
+ gatewayTrust: GatewayTrust;
72
+ /** Re-evaluates whether the store should hold a hub session, after trust changes. */
73
+ reconsiderHub: () => void;
74
+ /** Redeems a connection code the ordinary way (freshness, single use, optional signature) and trusts the device it vouches for. */
75
+ redeemConnectionCode: (
76
+ candidate: Readonly<ConnectionCode>,
77
+ options: Readonly<RedeemConnectionCodeOptions>,
78
+ ) => Promise<unknown>;
79
+ /** Labels deviceHex as name in the viewer's own petnames (Naming.setPetname). */
80
+ setPetname: (deviceHex: string, name: string) => string;
81
+ /** Re-attaches the store to the account it now holds, so its own proofs and ledger switch at once. */
82
+ setIdentity: (identity: MeshStoreIdentity) => void;
83
+ }
84
+
85
+ export interface AccountJoinOptions extends RedeemConnectionCodeOptions {
86
+ /** What the issuing person calls this machine: kept by the issuing device as its petname for it. Must satisfy the display-name rules. */
87
+ label?: string;
88
+ }
89
+
90
+ export interface AccountJoinResult {
91
+ /** The account's principal id (hex) this machine now holds. */
92
+ principal: string;
93
+ /** Where the account this machine held before was set aside, when it held a different one. */
94
+ replacedFile?: string;
95
+ }
96
+
97
+ /** The label a join request asks the issuer to keep for the joiner's machine: undefined when it asks for none, "invalid" when it asks for one that is not a usable label for a full machine id (a label with no machine id included). */
98
+ function joinerPetname(
99
+ params: Readonly<Record<string, unknown>>,
100
+ ): { machine: string; label: string } | undefined | "invalid" {
101
+ const label: unknown = "label" in params ? params.label : undefined;
102
+ const machine: unknown = "machine" in params ? params.machine : undefined;
103
+ if (label === undefined && machine === undefined) return undefined;
104
+ if (
105
+ typeof label !== "string" ||
106
+ typeof machine !== "string" ||
107
+ !isDeviceIdHex(machine.toLowerCase()) ||
108
+ parseDisplayName(label) !== label
109
+ ) {
110
+ return "invalid";
111
+ }
112
+ return { machine, label };
113
+ }
114
+
115
+ export class AccountJoin {
116
+ /** Invites this store issued that are neither answered nor known to have expired, keyed by their nonce. */
117
+ private readonly invites = new Map<string, PendingInvite>();
118
+
119
+ constructor(private readonly deps: Readonly<AccountJoinDeps>) {}
120
+
121
+ /** Issues an account invite vouching for this store's own device, valid for options.ttlMs (at most DEFAULT_CONNECTION_CODE_TTL_MS, the default). Throws INVITE_TTL_TOO_LONG for a longer one. Also trusts the account's own principal, so this machine holds a hub session the joining machine can reach it through, and admits the joined machine's devices once their proofs name the account. */
122
+ async invite(
123
+ options: Readonly<Omit<GenerateConnectionCodeOptions, "now">> = {},
124
+ ): Promise<AccountInvite> {
125
+ const ttlMs = options.ttlMs ?? DEFAULT_CONNECTION_CODE_TTL_MS;
126
+ if (ttlMs > DEFAULT_CONNECTION_CODE_TTL_MS) {
127
+ throw new CommsError(
128
+ `An account invite can stay valid for at most ${String(DEFAULT_CONNECTION_CODE_TTL_MS)} ms, since whoever redeems it first takes the account key`,
129
+ "INVITE_TTL_TOO_LONG",
130
+ );
131
+ }
132
+ const { clock } = this.deps.requireIdentity();
133
+ this.pruneExpired();
134
+ const code = await this.deps.connectionCodes.generate(
135
+ this.deps.getPeerId(),
136
+ { ...options, ttlMs, now: clock.now() },
137
+ );
138
+ const settled = new Promise<AccountInviteOutcome>((resolve) => {
139
+ this.invites.set(code.code, {
140
+ secret: {
141
+ code: code.code,
142
+ expiresAt: code.expiresAt,
143
+ deviceId: code.deviceId,
144
+ },
145
+ settle: resolve,
146
+ });
147
+ });
148
+ // Settles `settled` on expiry even when nothing else calls in to prune; unref'd so a pending invite never keeps a process alive by itself.
149
+ setTimeout(() => {
150
+ this.pruneExpired();
151
+ }, ttlMs).unref();
152
+ this.trustOwnAccount();
153
+ return { code, settled };
154
+ }
155
+
156
+ /** Answers an account.join request: the account key and name sealed for the named invite, once, and only before the invite expires. Every other request, including a second use of the same invite, is refused without saying whether the invite ever existed. */
157
+ readonly handleJoinRequest = async (
158
+ request: Readonly<IncomingManageRequest>,
159
+ ): Promise<ManageOutcome> => {
160
+ this.pruneExpired();
161
+ const { params } = request.command;
162
+ const code: unknown = "code" in params ? params.code : undefined;
163
+ const invite =
164
+ typeof code === "string" ? this.invites.get(code) : undefined;
165
+ if (invite === undefined) {
166
+ return Promise.resolve({ result: "error", code: "invalid_invite" });
167
+ }
168
+ const petname = joinerPetname(params);
169
+ if (petname === "invalid") {
170
+ return Promise.resolve({ result: "error", code: "invalid_label" });
171
+ }
172
+ // Before the invite is spent, so a petname file that cannot be written fails the request with the invite still usable rather than losing the invite and the label together.
173
+ if (petname !== undefined) {
174
+ this.deps.setPetname(petname.machine, petname.label);
175
+ }
176
+ this.invites.delete(invite.secret.code);
177
+ const { userIdentityOptions } = this.deps.requireIdentity();
178
+ const sealed = sealAccountKeyForInvite(
179
+ readAccountContents(userIdentityOptions),
180
+ invite.secret,
181
+ );
182
+ invite.settle("redeemed");
183
+ return Promise.resolve({ result: "ok", sealed });
184
+ };
185
+
186
+ /** Drops every invite whose expiry has passed by this store's clock, settling each as expired. */
187
+ private pruneExpired(): void {
188
+ const now = this.deps.requireIdentity().clock.now();
189
+ for (const [nonce, invite] of this.invites) {
190
+ if (Date.parse(invite.secret.expiresAt) > now) continue;
191
+ this.invites.delete(nonce);
192
+ invite.settle("expired");
193
+ }
194
+ }
195
+
196
+ /** Redeems an account invite and makes this machine hold the account it names. Throws INVALID_NAME for an unusable options.label before anything is redeemed, INVITE_REFUSED when the issuing device will not answer it (used, expired, cancelled by a restart, unreachable, or unwilling to take the label), and whatever redeeming the connection code itself throws before that. Redeeming trusts the issuing device so the key request can reach it; when the join then fails, that trust is withdrawn again unless the device was already trusted before, so a refused or unanswered invite leaves this machine trusting no one new. */
197
+ async join(
198
+ candidate: Readonly<ConnectionCode>,
199
+ options: Readonly<AccountJoinOptions> = {},
200
+ ): Promise<AccountJoinResult> {
201
+ const { label, ...redeemOptions } = options;
202
+ const named =
203
+ label === undefined
204
+ ? undefined
205
+ : requireDisplayName(label, "A machine label");
206
+ const trustedBefore = this.deps.gatewayTrust.isTrusted(candidate.deviceId);
207
+ await this.deps.redeemConnectionCode(candidate, redeemOptions);
208
+ try {
209
+ return await this.takeAccount(candidate, named);
210
+ } catch (error) {
211
+ if (!trustedBefore) {
212
+ this.deps.gatewayTrust.remove(candidate.deviceId);
213
+ this.deps.reconsiderHub();
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ /** The half of join after the invite's issuer is trusted: fetch the account key from it and switch this machine to that account. */
220
+ private async takeAccount(
221
+ candidate: Readonly<ConnectionCode>,
222
+ label: string | undefined,
223
+ ): Promise<AccountJoinResult> {
224
+ const outcome = await this.requestAccountKey(candidate, label);
225
+ const sealed: unknown =
226
+ outcome.result === "ok" ? outcome.sealed : undefined;
227
+ if (typeof sealed !== "string") {
228
+ throw new CommsError(
229
+ `The issuing device refused the account invite (${outcome.result === "error" ? (outcome.message ?? outcome.code) : "no key in its answer"})`,
230
+ "INVITE_REFUSED",
231
+ );
232
+ }
233
+ const contents = openAccountKeyFromInvite(sealed, candidate);
234
+ const current = this.deps.requireIdentity();
235
+ const { identity, replacedFile } = importAccountKey(
236
+ current.userIdentityOptions,
237
+ contents,
238
+ );
239
+ this.deps.setIdentity({
240
+ ...current,
241
+ userIdentity: await toIdentityPort(identity),
242
+ accountLedger: await openAccountLedger({
243
+ userIdentityOptions: current.userIdentityOptions,
244
+ userIdentity: identity,
245
+ clock: current.clock,
246
+ }),
247
+ });
248
+ this.trustOwnAccount();
249
+ const principal = deviceIdToHex(Uint8Array.from(identity.deviceId));
250
+ return replacedFile === undefined
251
+ ? { principal }
252
+ : { principal, replacedFile };
253
+ }
254
+
255
+ /** Asks the invite's issuing device for the account key. Redeeming the invite has only just trusted that device, so the route to it (a hub session this store opens because it now trusts someone) may still be coming up: a request that found no route yet is retried until JOIN_ROUTE_WAIT_MS has passed, and any answer from the device itself is returned as it is. */
256
+ private async requestAccountKey(
257
+ invite: Readonly<ConnectionCode>,
258
+ label: string | undefined,
259
+ ): Promise<ManageOutcome> {
260
+ const { clock, machineIdentity } = this.deps.requireIdentity();
261
+ const labelling =
262
+ label === undefined
263
+ ? {}
264
+ : { label, machine: deviceIdToHex(machineIdentity.deviceId) };
265
+ const deadline = clock.now() + JOIN_ROUTE_WAIT_MS;
266
+ for (;;) {
267
+ const outcome = await this.deps.requireTransport().sendRoomRequest(
268
+ invite.deviceId,
269
+ {
270
+ verb: ACCOUNT_JOIN_CAPABILITY_VERB,
271
+ params: { verb: ACCOUNT_JOIN_VERB, code: invite.code, ...labelling },
272
+ },
273
+ { kind: "node" },
274
+ );
275
+ const noRouteYet =
276
+ outcome.result === "error" && NO_ROUTE_CODES.has(outcome.code);
277
+ if (!noRouteYet || clock.now() >= deadline) return outcome;
278
+ await new Promise<void>((resolve) => {
279
+ setTimeout(resolve, JOIN_ROUTE_RETRY_MS);
280
+ });
281
+ }
282
+ }
283
+
284
+ private trustOwnAccount(): void {
285
+ const { userIdentity } = this.deps.requireIdentity();
286
+ this.deps.gatewayTrust.addPrincipal(deviceIdToHex(userIdentity.deviceId));
287
+ this.deps.reconsiderHub();
288
+ }
289
+ }