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,165 @@
1
+ /**
2
+ * Which grouping issuer each known device belongs to, for one kind of issuer: the machine (agent-comms#343, list_agents' machine-grouped output) or the user principal (agent-comms#266). This device's own issuer is known directly; every other device's is read from the proof of that kind in its gossiped agent/self advert and identified cryptographically (membership-proof.ts's identifyMembershipProofIssuer), so a device is placed under an issuer only when that issuer's key signed a current proof for it. A device with no such proof (a hidden agent, which sends no agent/self advert, or a bridge that predates the proof) has no issuer here.
3
+ *
4
+ * The same pass reads each issuer's self display name claim (agent-comms#345, agent-comms#359) from the adverts of the devices it vouches for, and keeps a name only when the claim is signed by that very issuer's key: the issuer is authentic, the content is its own choice.
5
+ *
6
+ * Grouping is display, not trust: a proof names who vouches for a device id, not that the device gossiping it is that device, so nothing here is used to authorise anything. Identifying a proof is cryptography, so verdicts are remembered per proof text until the proof lapses, and the cache is bounded. For the same reason a failure to identify one proof is reported and leaves that device unplaced rather than failing the listing it feeds.
7
+ */
8
+
9
+ import {
10
+ readMembershipProof,
11
+ type MembershipProofField,
12
+ } from "./gossip-directory.js";
13
+ import type { IdentifiedMembershipProof } from "./membership-proof.js";
14
+ import type { NameClaimVerdict } from "./name-claim.js";
15
+
16
+ /** The most proofs, and separately the most name claims, whose verdict is remembered. */
17
+ const VERDICT_CACHE_LIMIT = 1024;
18
+
19
+ export interface IssuerGroupingDeps {
20
+ /** Which agent/self field carries the proofs of the issuer kind this grouping places devices under. */
21
+ field: MembershipProofField;
22
+ /** The name claim a peer gossiped for this kind of issuer, read from its advert. */
23
+ readNameClaim: (
24
+ advert: Readonly<Record<string, unknown>>,
25
+ ) => string | undefined;
26
+ /** Every device this side has heard gossip from, with its latest advert. */
27
+ listKnownDevices: () => readonly {
28
+ deviceId: string;
29
+ advert: Readonly<Record<string, unknown>>;
30
+ }[];
31
+ /** Identifies which issuer a proof shows vouching for a device (MembershipProofs.identify). Called only once getMachineId answers, so the store's identity is attached. */
32
+ identify: (
33
+ claim: Readonly<{ proof: string; deviceHex: string }>,
34
+ ) => Promise<IdentifiedMembershipProof>;
35
+ /** This device's own id (hex). */
36
+ getPeerId: () => string;
37
+ /** This device's own issuer's id (hex), undefined before the store's identity is attached. */
38
+ getOwnIssuerId: () => string | undefined;
39
+ /** Where a proof that could not be identified at all (identify rejected) is reported. */
40
+ onError: (error: Error) => void;
41
+ /** Verifies a gossiped name claim about subject (name-claim.ts's verifyNameClaim, as this node sees it). */
42
+ verifyName: (
43
+ claim: Readonly<{ claim: string; subject: string }>,
44
+ ) => Promise<NameClaimVerdict>;
45
+ /** This device's own issuer's name, read locally. */
46
+ getOwnName: () => string | undefined;
47
+ }
48
+
49
+ interface CachedName {
50
+ subject: string;
51
+ name: string | undefined;
52
+ until: number;
53
+ }
54
+
55
+ interface CachedVerdict {
56
+ deviceHex: string;
57
+ /** The issuer the proof names, undefined for a proof that did not verify. */
58
+ issuer: string | undefined;
59
+ /** When the verdict stops holding (epoch ms): the proof's own expiry, or never for a refusal, since the same text can never become valid later. */
60
+ until: number;
61
+ }
62
+
63
+ export class IssuerGrouping {
64
+ private readonly verdicts = new Map<string, CachedVerdict>();
65
+ private readonly names = new Map<string, CachedName>();
66
+
67
+ constructor(private readonly deps: Readonly<IssuerGroupingDeps>) {}
68
+
69
+ /** The issuer (hex) of each device this side can place under one, keyed by device id: this device's own, and every gossiped device with a current, valid proof of this kind. */
70
+ async issuersByDevice(): Promise<Map<string, string>> {
71
+ const issuers = new Map<string, string>();
72
+ const own = this.deps.getOwnIssuerId();
73
+ // Without an identity attached there is no clock or revocation view to identify a proof against, and no own issuer either.
74
+ if (own === undefined) return issuers;
75
+ issuers.set(this.deps.getPeerId(), own);
76
+ for (const { deviceId, advert } of this.deps.listKnownDevices()) {
77
+ if (issuers.has(deviceId)) continue;
78
+ const proof = readMembershipProof(advert, this.deps.field);
79
+ if (proof === undefined) continue;
80
+ const issuer = await this.issuerFor(proof, deviceId);
81
+ if (issuer !== undefined) issuers.set(deviceId, issuer);
82
+ }
83
+ return issuers;
84
+ }
85
+
86
+ /** The self display name of each issuer this side can place a device under, keyed by issuer id: this device's own from its file, every other one from a claim its own key signed, gossiped by a device it vouches for. */
87
+ async issuerNames(): Promise<Map<string, string>> {
88
+ const names = new Map<string, string>();
89
+ const issuers = await this.issuersByDevice();
90
+ const own = this.deps.getOwnIssuerId();
91
+ const ownName = this.deps.getOwnName();
92
+ if (own !== undefined && ownName !== undefined) names.set(own, ownName);
93
+ for (const { deviceId, advert } of this.deps.listKnownDevices()) {
94
+ const issuer = issuers.get(deviceId);
95
+ if (issuer === undefined || names.has(issuer)) continue;
96
+ const claim = this.deps.readNameClaim(advert);
97
+ if (claim === undefined) continue;
98
+ const name = await this.nameFor(claim, issuer);
99
+ if (name !== undefined) names.set(issuer, name);
100
+ }
101
+ return names;
102
+ }
103
+
104
+ private async nameFor(
105
+ claim: string,
106
+ subject: string,
107
+ ): Promise<string | undefined> {
108
+ const cached = this.names.get(claim);
109
+ if (cached?.subject === subject && cached.until > Date.now()) {
110
+ return cached.name;
111
+ }
112
+ const verdict = await this.deps.verifyName({ claim, subject });
113
+ remember(
114
+ this.names,
115
+ claim,
116
+ verdict.ok
117
+ ? { subject, name: verdict.name, until: verdict.expires }
118
+ : { subject, name: undefined, until: Number.POSITIVE_INFINITY },
119
+ );
120
+ return verdict.ok ? verdict.name : undefined;
121
+ }
122
+
123
+ private async issuerFor(
124
+ proof: string,
125
+ deviceHex: string,
126
+ ): Promise<string | undefined> {
127
+ const cached = this.verdicts.get(proof);
128
+ if (cached?.deviceHex === deviceHex && cached.until > Date.now()) {
129
+ return cached.issuer;
130
+ }
131
+ let verdict: IdentifiedMembershipProof;
132
+ try {
133
+ verdict = await this.deps.identify({ proof, deviceHex });
134
+ } catch (err) {
135
+ // Not remembered: the failure is in this side's own view (a revocation view that threw, say), not a verdict on the proof, so the next listing tries again.
136
+ this.deps.onError(
137
+ new Error(
138
+ `could not identify the ${this.deps.field} proof of ${deviceHex}`,
139
+ {
140
+ cause: err,
141
+ },
142
+ ),
143
+ );
144
+ return undefined;
145
+ }
146
+ remember(
147
+ this.verdicts,
148
+ proof,
149
+ verdict.ok
150
+ ? { deviceHex, issuer: verdict.issuerHex, until: verdict.expires }
151
+ : { deviceHex, issuer: undefined, until: Number.POSITIVE_INFINITY },
152
+ );
153
+ return verdict.ok ? verdict.issuerHex : undefined;
154
+ }
155
+ }
156
+
157
+ /** Records verdict under key, evicting the oldest entry once the cache holds more than VERDICT_CACHE_LIMIT. */
158
+ function remember<V>(cache: Map<string, V>, key: string, verdict: V): void {
159
+ cache.delete(key);
160
+ cache.set(key, verdict);
161
+ if (cache.size > VERDICT_CACHE_LIMIT) {
162
+ const oldest = cache.keys().next();
163
+ if (oldest.done !== true) cache.delete(oldest.value);
164
+ }
165
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * A persisted grouping-issuer keypair: one locally generated key per file, the shape the user principal (user-identity.ts, agent-comms#160) and the machine identity (machine-identity.ts, agent-comms#343) both take. Neither is ever a live mesh peer, so neither needs identity-store.ts's PID-probed slot lock; the only race that matters is which process's key wins at first creation, which an exclusive ("wx") create settles.
3
+ *
4
+ * The key is never derived from anything about the host (platform UUIDs, serials, hostnames): those are linkable, cloned with disk images and swapped with hardware. Moving an identity to a rebuilt host is a deliberate copy of its file, the SSH host-key model.
5
+ *
6
+ * Each file is written with owner-only permissions (0600) inside a 0700 directory, and every write is atomic (atomic-file.ts), since another bridge may be reading the file at that moment: a reader sees a complete record or none, so a file that exists but cannot be parsed is never one of this module's own writes in progress. It is refused loudly rather than replaced with a fresh key, because replacing it silently changes the issuer's id and every peer that trusted the old one loses every device it vouched for. Fields an owning module adds beside the key material (issued-grant bookkeeping, a display name) are preserved by every write here: renewal spreads the stored record before replacing the key fields.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { createFileExclusive, writeFileAtomic } from "./atomic-file.js";
12
+ import {
13
+ generateIdentity,
14
+ certifyKeyPair,
15
+ getCertificateFingerprint,
16
+ deriveDeviceId,
17
+ CERTIFICATE_VALIDITY_MS,
18
+ } from "./identity.js";
19
+ import type { PeerIdentity } from "./identity.js";
20
+
21
+ /** Renewal margin is one twelfth of the certificate's total validity period, matching identity-store.ts's own renewal margin so every identity ages out on the same schedule. */
22
+ const RENEWAL_MARGIN_FRACTION = 12;
23
+ const RENEWAL_MARGIN_MS = CERTIFICATE_VALIDITY_MS / RENEWAL_MARGIN_FRACTION;
24
+
25
+ /** Owner-only read and write: the file holds a private key. */
26
+ const IDENTITY_FILE_MODE = 0o600;
27
+ /** Owner-only directory, matching identity-store.ts's own base directory. */
28
+ const IDENTITY_DIR_MODE = 0o700;
29
+
30
+ /** The key material every issuer identity file holds. An owning module's own fields sit beside these in the same JSON object. */
31
+ export interface StoredIssuerKey {
32
+ privateKey: string;
33
+ certificate: string;
34
+ expiresAt: string;
35
+ }
36
+
37
+ /** Whether value holds the key-material fields every issuer identity record carries. */
38
+ export function isStoredIssuerKey(value: unknown): value is StoredIssuerKey {
39
+ if (typeof value !== "object" || value === null) return false;
40
+ if (
41
+ !("privateKey" in value) ||
42
+ !("certificate" in value) ||
43
+ !("expiresAt" in value)
44
+ )
45
+ return false;
46
+ return (
47
+ typeof value.privateKey === "string" &&
48
+ typeof value.certificate === "string" &&
49
+ typeof value.expiresAt === "string"
50
+ );
51
+ }
52
+
53
+ /** Narrows a caught value to Node's own errno-carrying Error subtype, so a specific error code (e.g. ENOENT) can be checked without an `as` assertion. */
54
+ function isErrnoException(value: unknown): value is NodeJS.ErrnoException {
55
+ return value instanceof Error && "code" in value;
56
+ }
57
+
58
+ /** Reads the file's raw contents, or undefined if it does not exist yet. Any other filesystem error (permissions, a directory in its place) is surfaced rather than treated as absent. */
59
+ function readRawFile(file: string): string | undefined {
60
+ try {
61
+ return fs.readFileSync(file, "utf-8");
62
+ } catch (err) {
63
+ if (isErrnoException(err) && err.code === "ENOENT") return undefined;
64
+ throw err;
65
+ }
66
+ }
67
+
68
+ /** The file's parsed record, or undefined when the file does not exist. The owning module supplies a guard for its own full record shape. Throws when the file exists but is not JSON or fails the guard (see the module comment for why that is never repaired here). */
69
+ export function readIssuerRecord<T>(
70
+ file: string,
71
+ guard: (value: unknown) => value is T,
72
+ ): T | undefined {
73
+ const raw = readRawFile(file);
74
+ return raw === undefined ? undefined : parseIssuerRecord(file, raw, guard);
75
+ }
76
+
77
+ function parseIssuerRecord<T>(
78
+ file: string,
79
+ raw: string,
80
+ guard: (value: unknown) => value is T,
81
+ ): T {
82
+ let parsed: unknown;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch (err) {
86
+ throw unusableRecord(file, err);
87
+ }
88
+ if (!guard(parsed)) throw unusableRecord(file);
89
+ return parsed;
90
+ }
91
+
92
+ /** The error for an identity file that exists but holds no usable record. Moving it aside is the operator's decision, since the next load then mints a new key under a new id. */
93
+ function unusableRecord(file: string, cause?: unknown): Error {
94
+ return new Error(
95
+ `${file} exists but does not hold a usable identity record; move it aside to mint a new key, which changes this identity's id for every peer that trusts it`,
96
+ { cause },
97
+ );
98
+ }
99
+
100
+ function serializeRecord(stored: Readonly<object>): string {
101
+ return `${JSON.stringify(stored, null, 2)}\n`;
102
+ }
103
+
104
+ /** Replaces the file with record in full, owner-only. Callers read the current record first and spread it, so no field another writer owns is lost. */
105
+ export function writeIssuerRecord(
106
+ file: string,
107
+ record: Readonly<StoredIssuerKey>,
108
+ ): void {
109
+ writeFileAtomic(file, serializeRecord(record), IDENTITY_FILE_MODE);
110
+ }
111
+
112
+ /** The identity a stored key record describes: the key, its certificate, and the fingerprint and device-id derived from them. */
113
+ export function issuerPeerIdentity(
114
+ stored: Readonly<StoredIssuerKey>,
115
+ ): PeerIdentity {
116
+ return {
117
+ privateKey: stored.privateKey,
118
+ certificate: stored.certificate,
119
+ fingerprint: getCertificateFingerprint(stored.certificate),
120
+ deviceId: deriveDeviceId(stored.privateKey),
121
+ };
122
+ }
123
+
124
+ /** The key record that persists identity, with a fresh expiry stamp. */
125
+ export function issuerKeyRecord(
126
+ identity: Readonly<PeerIdentity>,
127
+ ): StoredIssuerKey {
128
+ return {
129
+ privateKey: identity.privateKey,
130
+ certificate: identity.certificate,
131
+ expiresAt: new Date(Date.now() + CERTIFICATE_VALIDITY_MS).toISOString(),
132
+ };
133
+ }
134
+
135
+ /** Renews a stored identity nearing certificate expiry by re-certifying its existing key pair, so its device-id is unchanged (see identity.ts's certifyKeyPair for why a fresh key pair would be wrong). Every other field in the record is kept. Returns the identity unchanged when it is not yet near expiry. */
136
+ function renewIfNeeded(
137
+ file: string,
138
+ stored: Readonly<StoredIssuerKey>,
139
+ ): PeerIdentity {
140
+ const expiresAt = Date.parse(stored.expiresAt);
141
+ const needsRenewal =
142
+ Number.isNaN(expiresAt) || Date.now() > expiresAt - RENEWAL_MARGIN_MS;
143
+ if (!needsRenewal) return issuerPeerIdentity(stored);
144
+
145
+ const renewed = certifyKeyPair(stored.privateKey);
146
+ writeIssuerRecord(file, { ...stored, ...issuerKeyRecord(renewed) });
147
+ return renewed;
148
+ }
149
+
150
+ /** Generates a fresh identity and persists it at file, which does not exist yet. The create is exclusive, so when a concurrent caller creates the file first this caller keeps the winner's key and returns that instead of overwriting it, judging the winner's record with the owning module's guard. */
151
+ function createIssuerIdentity(
152
+ file: string,
153
+ guard: (value: unknown) => value is StoredIssuerKey,
154
+ ): PeerIdentity {
155
+ const identity = generateIdentity();
156
+ if (
157
+ createFileExclusive(
158
+ file,
159
+ serializeRecord(issuerKeyRecord(identity)),
160
+ IDENTITY_FILE_MODE,
161
+ )
162
+ ) {
163
+ return identity;
164
+ }
165
+ const winner = readIssuerRecord(file, guard);
166
+ if (winner === undefined) {
167
+ throw new Error(`${file} was created and removed again while loading it`);
168
+ }
169
+ return renewIfNeeded(file, winner);
170
+ }
171
+
172
+ /**
173
+ * Loads the issuer identity persisted at file, creating it on first use. A record nearing certificate expiry is renewed in place and a missing file is created exclusively. guard is the owning module's check of its full record shape, the same one its own reads use, so a record that loads here is one every later read-modify-write of that module accepts too. A file that exists but fails guard is an error, never regenerated.
174
+ */
175
+ export function loadOrCreateIssuerIdentity(
176
+ file: string,
177
+ guard: (value: unknown) => value is StoredIssuerKey,
178
+ ): PeerIdentity {
179
+ fs.mkdirSync(path.dirname(file), {
180
+ recursive: true,
181
+ mode: IDENTITY_DIR_MODE,
182
+ });
183
+
184
+ const stored = readIssuerRecord(file, guard);
185
+ if (stored === undefined) return createIssuerIdentity(file, guard);
186
+ return renewIfNeeded(file, stored);
187
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The lan_web_uis action's handler, split out of tool.ts to keep that file under the repo's max-lines cap.
3
+ */
4
+
5
+ import {
6
+ notMeshBacked,
7
+ type CommsResult,
8
+ type MeshOnlyFeatures,
9
+ } from "./tool.js";
10
+ import { idWithNames, type Namer } from "./naming.js";
11
+
12
+ /** Lists the LAN web UIs heard as web beacons (agent-comms#353): each as its ready-to-open URL beside the full peer id and its names, since the entry a user acts on is the peer id. */
13
+ export function lanWebUisAction(
14
+ store: Readonly<MeshOnlyFeatures>,
15
+ namer: Namer,
16
+ ): CommsResult {
17
+ if (!store.lanWebUis) return notMeshBacked("lan_web_uis");
18
+ const entries = store.lanWebUis.list();
19
+ if (entries.length === 0) {
20
+ return {
21
+ content:
22
+ "No LAN web UIs heard. A bridge announces its web UI on the local network only while it is bound beyond loopback (AGENT_COMMS_WEB_HOST).",
23
+ isError: false,
24
+ };
25
+ }
26
+ const lines = entries.map(
27
+ (entry) =>
28
+ `http://${entry.host}:${entry.webPort.toString()} ${idWithNames(entry.peerId, namer)}`,
29
+ );
30
+ return {
31
+ content: `LAN web UIs:\n${lines.join("\n")}`,
32
+ isError: false,
33
+ };
34
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The LAN web UIs this machine has heard web beacons for (agent-comms#353): the receiving half of the beacon bridges broadcast when their web server binds beyond loopback, kept as a bounded, expiring table so `lan_web_uis` can list neighbouring dashboards without anything accumulating.
3
+ */
4
+
5
+ import { FIRST_CONTACT_INTERVAL_MS } from "./first-contact.js";
6
+
7
+ /** Missed beacon intervals tolerated before an entry expires: the same conventional missed-heartbeat threshold the gossip presence check uses (gossip-extensions.ts's PRESENCE_STALE_MISSED_TICKS), for the same reason -- one dropped and one slow beacon is absorbed, a genuinely departed bridge leaves the list. */
8
+ const BEACON_STALE_MISSED_TICKS = 3;
9
+
10
+ const STALE_AFTER_MS = FIRST_CONTACT_INTERVAL_MS * BEACON_STALE_MISSED_TICKS;
11
+
12
+ /** One neighbouring web UI as last heard: where it listens, and when. */
13
+ export interface LanWebUiEntry {
14
+ peerId: string;
15
+ host: string;
16
+ webPort: number;
17
+ heardAtMs: number;
18
+ }
19
+
20
+ /** The table of LAN web UIs heard on this machine's first-contact presence. An entry is refreshed by every beacon its bridge sends, and a bridge that stops beaconing drops out of `list()` once its entry is stale. */
21
+ export class LanWebUis {
22
+ private readonly entries = new Map<string, LanWebUiEntry>();
23
+
24
+ constructor(private readonly now: () => number = Date.now) {}
25
+
26
+ /** Records (or refreshes) the UI a beacon announced, keyed by peer id so a bridge that restarts on a new port replaces its own entry rather than accumulating one per restart. */
27
+ record(peerId: string, host: string, webPort: number): LanWebUiEntry {
28
+ const entry: LanWebUiEntry = {
29
+ peerId,
30
+ host,
31
+ webPort,
32
+ heardAtMs: this.now(),
33
+ };
34
+ this.entries.set(peerId, entry);
35
+ return entry;
36
+ }
37
+
38
+ /** record for a beacon as first contact hands it up, host already taken from the datagram's source address. */
39
+ recordBeacon(
40
+ beacon: Readonly<{ peerId: string; host: string; webPort: number }>,
41
+ ): LanWebUiEntry {
42
+ return this.record(beacon.peerId, beacon.host, beacon.webPort);
43
+ }
44
+
45
+ /** Every unexpired entry, ordered by peer id so the listing is stable between calls. */
46
+ list(): readonly Readonly<LanWebUiEntry>[] {
47
+ const cutoff = this.now() - STALE_AFTER_MS;
48
+ for (const [peerId, entry] of this.entries) {
49
+ if (entry.heardAtMs < cutoff) this.entries.delete(peerId);
50
+ }
51
+ return [...this.entries.values()].sort((a, b) =>
52
+ a.peerId < b.peerId ? -1 : a.peerId > b.peerId ? 1 : 0,
53
+ );
54
+ }
55
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Mutual exclusion for appending to one machine's writer log of the account ledger (agent-comms#344). A data-domain log is single-writer: two appends reading the same head would both write the next sequence number and fork the log. Every bridge on a machine shares that machine's writer log, and several stores can live in one process (the cc-peer front fronts many sessions), so appends are serialised both within this process (a promise chain per lock) and across processes (a PID lock file, pid-lock-file.ts, the same claim identity-store.ts uses for a bridge slot).
3
+ */
4
+
5
+ import { releaseLockFile, tryAcquireLock } from "./pid-lock-file.js";
6
+
7
+ /** Runs `critical` with exclusive access to whatever this lock guards. */
8
+ export interface LedgerLock {
9
+ run: <T>(critical: () => Promise<T>) => Promise<T>;
10
+ }
11
+
12
+ const MS_PER_SECOND = 1000;
13
+ /** How long an append waits for another process's append to finish. An append is one small file write, so a holder still busy after this long has hung or is wedged, and failing loudly beats waiting forever. */
14
+ const LOCK_WAIT_SECONDS = 10;
15
+ const LOCK_WAIT_MS = LOCK_WAIT_SECONDS * MS_PER_SECOND;
16
+ /** Between attempts to claim a lock another live process holds: short against an append's own cost, long enough not to spin. */
17
+ const LOCK_RETRY_MS = 20;
18
+
19
+ const sleep = async (ms: number): Promise<void> =>
20
+ new Promise<void>((resolve) => {
21
+ setTimeout(resolve, ms);
22
+ });
23
+
24
+ /** The tail of each lock's queue in this process, keyed by what the lock guards. */
25
+ const tails = new Map<string, Promise<unknown>>();
26
+
27
+ async function serialised<T>(
28
+ key: string,
29
+ critical: () => Promise<T>,
30
+ ): Promise<T> {
31
+ const previous = tails.get(key) ?? Promise.resolve();
32
+ const next = previous.then(critical, critical);
33
+ const tail = next.then(
34
+ () => undefined,
35
+ () => undefined,
36
+ );
37
+ tails.set(key, tail);
38
+ try {
39
+ return await next;
40
+ } finally {
41
+ if (tails.get(key) === tail) tails.delete(key);
42
+ }
43
+ }
44
+
45
+ async function claimFile(lockFile: string): Promise<void> {
46
+ const deadline = Date.now() + LOCK_WAIT_MS;
47
+ for (;;) {
48
+ const claim = tryAcquireLock(lockFile);
49
+ if (claim.acquired) return;
50
+ if (Date.now() >= deadline) {
51
+ throw new Error(
52
+ `account ledger lock ${lockFile} is still held by pid ${String(claim.heldBy)} after ${String(LOCK_WAIT_SECONDS)}s`,
53
+ );
54
+ }
55
+ await sleep(LOCK_RETRY_MS);
56
+ }
57
+ }
58
+
59
+ /** A lock shared by every process on this machine that names the same lock file. */
60
+ export function createFileLedgerLock(lockFile: string): LedgerLock {
61
+ return {
62
+ run: async (critical) =>
63
+ serialised(lockFile, async () => {
64
+ await claimFile(lockFile);
65
+ try {
66
+ return await critical();
67
+ } finally {
68
+ releaseLockFile(lockFile);
69
+ }
70
+ }),
71
+ };
72
+ }
73
+
74
+ let nextInProcessLock = 0;
75
+
76
+ /** A lock with no file behind it, for a ledger whose storage lives only in this process's memory. */
77
+ export function createInProcessLedgerLock(): LedgerLock {
78
+ nextInProcessLock += 1;
79
+ const key = `in-process:${String(nextInProcessLock)}`;
80
+ return { run: async (critical) => serialised(key, critical) };
81
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * listener-registry -- WireMeshTransport's own operator-registered listener bookkeeping (addListener/removeListener/listListeners, plus the advertisedAddresses list they feed), split out under the repo's max-lines cap the same reason hub-session.ts, connection-approval.ts, room-router.ts, peer-lifecycle.ts, and gossip-directory.ts were each split from the same file. Free functions over a TrackedListener registry (WireMeshTransport's own coordinatorListeners Map, passed in directly) rather than a class, since WireMeshTransport already owns and mutates that Map itself and every other piece of split-out state in this file follows the same shape. Deliberately excludes the default bootstrap coordinator listener (startDataServer/becomeCoordinator's own concern) -- this registry only ever tracks listeners an operator explicitly registered via addListener.
3
+ */
4
+
5
+ import { nanoid } from "./nanoid.js";
6
+ import {
7
+ BECOME_COORDINATOR_BIND_RETRIES,
8
+ BECOME_COORDINATOR_BIND_RETRY_DELAY_MS,
9
+ retryOnAddrInUse,
10
+ } from "./bind-retry.js";
11
+ import type {
12
+ Connection,
13
+ Listener,
14
+ Transport,
15
+ } from "wire-mesh-core/ports/transport";
16
+ import type { ListenerInfo, ListenerPolicy } from "./transport.js";
17
+
18
+ /** Length of the random id minted for a tracked listener -- shared with WireMeshTransport's own becomeCoordinator, which mints an id for the default bootstrap listener the same way, outside this registry. */
19
+ export const LISTENER_ID_LENGTH = 8;
20
+
21
+ export interface TrackedListener {
22
+ listener: Listener;
23
+ policy: ListenerPolicy;
24
+ host: string;
25
+ port: number;
26
+ isDefault: boolean;
27
+ }
28
+
29
+ /** Reads the port a listener actually bound, from its own reported address -- never the port it was asked to bind, which is 0 whenever the caller wanted the OS to assign a free one. Bookkeeping that stores the requested port instead silently reports 0 for every OS-assigned listener. */
30
+ export function listenerPort(listener: Readonly<Listener>): number {
31
+ const port = listener.address.split(":").pop();
32
+ return port === undefined ? 0 : Number(port);
33
+ }
34
+
35
+ /** Registers a new listener at host:port under the given policy, tracking it in coordinatorListeners (mutated in place) and dispatching every accepted connection to onAccepted -- WireMeshTransport.addListener's own body, unchanged in behaviour. */
36
+ export async function registerListener(options: {
37
+ wireTransport: Readonly<Pick<Transport, "listen">>;
38
+ coordinatorListeners: Map<string, TrackedListener>;
39
+ host: string;
40
+ port: number;
41
+ policy: ListenerPolicy;
42
+ onAccepted: (connection: Readonly<Connection>) => void;
43
+ }): Promise<string> {
44
+ const {
45
+ wireTransport,
46
+ coordinatorListeners,
47
+ host,
48
+ port,
49
+ policy,
50
+ onAccepted,
51
+ } = options;
52
+ const id = nanoid(LISTENER_ID_LENGTH);
53
+ const listener = await wireTransport.listen(
54
+ `${host}:${String(port)}`,
55
+ onAccepted,
56
+ );
57
+ coordinatorListeners.set(id, {
58
+ listener,
59
+ policy,
60
+ host,
61
+ port: listenerPort(listener),
62
+ isDefault: false,
63
+ });
64
+ return id;
65
+ }
66
+
67
+ /** Binds the default bootstrap coordinator listener at host:port and tracks it, returning its id -- WireMeshTransport.becomeCoordinator's own bookkeeping, kept beside the registry that owns the same Map. Unlike registerListener this retries a port conflict, because both routes to the coordinator role contend for an occupied port: a graceful handover names its successor while the outgoing coordinator's own listener is still open, and a crash race has every surviving peer attempting the bind at once. The entry is marked default, which is what keeps it out of the advertised-address list and unremovable through removeListener. Closes the listener it bound and rejects when isShuttingDown() holds once the bind completes, which covers a shutdown that began before the bind as well as one during it: the transport's shutdown() closes only the listeners tracked when it began, so one bound later would hold the port for as long as the process lives (a crash-race contest still running when its store shuts down is one such caller). */
68
+ export async function registerDefaultListener(options: {
69
+ wireTransport: Readonly<Pick<Transport, "listen">>;
70
+ coordinatorListeners: Map<string, TrackedListener>;
71
+ host: string;
72
+ port: number;
73
+ onAccepted: (connection: Readonly<Connection>) => void;
74
+ isShuttingDown: () => boolean;
75
+ }): Promise<string> {
76
+ const { wireTransport, coordinatorListeners, host, port, onAccepted } =
77
+ options;
78
+ const id = nanoid(LISTENER_ID_LENGTH);
79
+ const listener = await retryOnAddrInUse(
80
+ async () => wireTransport.listen(`${host}:${String(port)}`, onAccepted),
81
+ BECOME_COORDINATOR_BIND_RETRIES,
82
+ BECOME_COORDINATOR_BIND_RETRY_DELAY_MS,
83
+ );
84
+ if (options.isShuttingDown()) {
85
+ await listener.close();
86
+ throw new Error("becomeCoordinator: the transport has shut down");
87
+ }
88
+ coordinatorListeners.set(id, {
89
+ listener,
90
+ policy: "full",
91
+ host,
92
+ port: listenerPort(listener),
93
+ isDefault: true,
94
+ });
95
+ return id;
96
+ }
97
+
98
+ /** Closes and untracks a previously registered listener -- WireMeshTransport.removeListener's own body, unchanged in behaviour. Throws if id names the default bootstrap listener (never registered through registerListener, so never removable through this path); a no-op if id names no tracked listener at all. */
99
+ export async function unregisterListener(
100
+ coordinatorListeners: Map<string, TrackedListener>,
101
+ defaultListenerId: string | undefined,
102
+ id: string,
103
+ ): Promise<void> {
104
+ if (id === defaultListenerId) {
105
+ throw new Error("Cannot remove the default listener");
106
+ }
107
+ const tracked = coordinatorListeners.get(id);
108
+ if (tracked === undefined) return;
109
+ coordinatorListeners.delete(id);
110
+ await tracked.listener.close();
111
+ }
112
+
113
+ /** Every currently tracked listener, in the shape ListenerInfo exposes externally -- WireMeshTransport.listListeners's own body, unchanged in behaviour. */
114
+ export function listTrackedListeners(
115
+ coordinatorListeners: ReadonlyMap<string, Readonly<TrackedListener>>,
116
+ ): ListenerInfo[] {
117
+ return [...coordinatorListeners.entries()].map(([id, tracked]) => ({
118
+ id,
119
+ host: tracked.host,
120
+ port: tracked.port,
121
+ policy: tracked.policy,
122
+ isDefault: tracked.isDefault,
123
+ }));
124
+ }
125
+
126
+ /** This side's own directly-reachable "host:port" candidates (wire-mesh#38), passed into every acceptMeshSession call's own self-advert -- WireMeshTransport's own advertisedAddresses getter body, unchanged in behaviour. Deliberately excludes the default bootstrap coordinator listener -- it always binds COORDINATOR_HOST (127.0.0.1, hardcoded, never configurable), which is meaningless to advertise to a remote peer -- and includes only listeners an operator explicitly registered via addListener, which by construction represent a deliberate "make me reachable from elsewhere" declaration. */
127
+ export function advertisedListenerAddresses(
128
+ coordinatorListeners: ReadonlyMap<string, Readonly<TrackedListener>>,
129
+ ): string[] {
130
+ return [...coordinatorListeners.values()]
131
+ .filter((tracked) => !tracked.isDefault)
132
+ .map((tracked) => `${tracked.host}:${String(tracked.port)}`);
133
+ }