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,22 @@
1
+ /**
2
+ * Whether an address this side dials can only reach this machine, judged from the address literal alone. WireMeshTransport uses it to decide whether a dialled session takes part in the coordinator election (agent-comms#341): the dialler knows nothing about the far side's listener beyond the address it dialled, and a peer list or a first-contact beacon heard over LAN broadcast can name any host.
3
+ */
4
+
5
+ import * as net from "node:net";
6
+
7
+ /** RFC 1122 section 3.2.1.3 reserves the whole of 127.0.0.0/8 for loopback, not just 127.0.0.1. */
8
+ const IPV4_LOOPBACK_NETWORK = "127.0.0.0";
9
+ const IPV4_LOOPBACK_PREFIX_LENGTH = 8;
10
+ /** RFC 4291 section 2.5.3: IPv6 has a single loopback address. */
11
+ const IPV6_LOOPBACK_ADDRESS = "::1";
12
+
13
+ const LOOPBACK = new net.BlockList();
14
+ LOOPBACK.addSubnet(IPV4_LOOPBACK_NETWORK, IPV4_LOOPBACK_PREFIX_LENGTH, "ipv4");
15
+ LOOPBACK.addAddress(IPV6_LOOPBACK_ADDRESS, "ipv6");
16
+
17
+ /** True for an IPv4 address in 127.0.0.0/8, the IPv6 loopback address, or an IPv4-mapped IPv6 form of the former. False for every other address and for any hostname, since a name may resolve anywhere and is never evidence of where the far side is. */
18
+ export function isLoopbackAddress(host: string): boolean {
19
+ if (net.isIPv4(host)) return LOOPBACK.check(host, "ipv4");
20
+ if (net.isIPv6(host)) return LOOPBACK.check(host, "ipv6");
21
+ return false;
22
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Persistent machine identity (agent-comms#343): one keypair per OS account per host, the third identity scope beside a bridge slot's device identity (identity-store.ts) and the account's user principal (user-identity.ts). It is structurally another grouping issuer, like the principal: it vouches for the devices living on this host with the same group:member proofs (membership-proof.ts), so a peer can group devices by machine and trust or stop trusting a whole host in one act.
3
+ *
4
+ * Stored at ~/.agent-comms/machine-identity.json (mode 0600), so it belongs to the OS account whose home directory holds it: every bridge that account runs on the host reads the same file and is vouched for by the same machine key, while another account on the same host mints a machine key of its own and appears as a separate machine. Until the principal becomes portable across hosts (agent-comms#344) this is the same scope as the user principal; a key naming the host across every account needs a signer outside any one account and is agent-comms#367. The key is generated locally on first use and never derived from hardware (issuer-identity-file.ts says why); re-homing a rebuilt host is a deliberate copy of this file.
5
+ */
6
+
7
+ import * as path from "node:path";
8
+ import { resolveDataDir } from "./data-directory.js";
9
+ import type { PeerIdentity } from "./identity.js";
10
+ import {
11
+ isStoredIssuerKey,
12
+ loadOrCreateIssuerIdentity,
13
+ readIssuerRecord,
14
+ writeIssuerRecord,
15
+ type StoredIssuerKey,
16
+ } from "./issuer-identity-file.js";
17
+
18
+ export interface MachineIdentityOptions {
19
+ /** Directory override for tests; defaults to ~/.agent-comms, beside the user identity and every bridge slot. */
20
+ dir?: string;
21
+ }
22
+
23
+ /** The machine identity file for options. */
24
+ export function machineIdentityFile(
25
+ options?: Readonly<MachineIdentityOptions>,
26
+ ): string {
27
+ const dir = resolveDataDir({ dir: options?.dir });
28
+ return path.join(dir, "machine-identity.json");
29
+ }
30
+
31
+ /** Loads this account's machine identity for this host, creating it on first use (exclusive create, renewal in place, an unusable file refused rather than regenerated). */
32
+ export function loadOrCreateMachineIdentity(
33
+ options?: Readonly<MachineIdentityOptions>,
34
+ ): PeerIdentity {
35
+ return loadOrCreateIssuerIdentity(
36
+ machineIdentityFile(options),
37
+ isStoredMachineIdentity,
38
+ );
39
+ }
40
+
41
+ interface StoredMachineIdentity extends StoredIssuerKey {
42
+ /** The name this machine asserts for itself (agent-comms#345), signed into a name claim by the machine key whenever a bridge on the host re-advertises. Absent until someone names the machine. */
43
+ displayName?: string;
44
+ }
45
+
46
+ function isStoredMachineIdentity(
47
+ value: unknown,
48
+ ): value is StoredMachineIdentity {
49
+ if (!isStoredIssuerKey(value)) return false;
50
+ return !("displayName" in value) || typeof value.displayName === "string";
51
+ }
52
+
53
+ /** The name this machine asserts for itself, or undefined when it has none or the machine identity has not been created yet. Read from disk on every call, so a name set by any bridge on the host is seen by all of them. */
54
+ export function loadMachineDisplayName(
55
+ options?: Readonly<MachineIdentityOptions>,
56
+ ): string | undefined {
57
+ return readIssuerRecord(machineIdentityFile(options), isStoredMachineIdentity)
58
+ ?.displayName;
59
+ }
60
+
61
+ /** Sets (or, given undefined, clears) the name this machine asserts for itself, keeping the key material and every other field. Throws if the machine identity has not been created yet. */
62
+ export function saveMachineDisplayName(
63
+ options: Readonly<MachineIdentityOptions> | undefined,
64
+ displayName: string | undefined,
65
+ ): void {
66
+ const file = machineIdentityFile(options);
67
+ const stored = readIssuerRecord(file, isStoredMachineIdentity);
68
+ if (stored === undefined) {
69
+ throw new Error(
70
+ `no machine identity persisted yet; call loadOrCreateMachineIdentity first (${file})`,
71
+ );
72
+ }
73
+ const next: StoredMachineIdentity = { ...stored };
74
+ if (displayName === undefined) delete next.displayName;
75
+ else next.displayName = displayName;
76
+ writeIssuerRecord(file, next);
77
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * list_agents' machine grouping (agent-comms#343): the listed agents split into one group per machine, this host first, then every other machine by id, then the agents no machine proof places. Split out of tool.ts to keep that file under the repo's max-lines cap.
3
+ */
4
+
5
+ import type { AgentIdentity } from "./types.js";
6
+ import type { Namer } from "./naming.js";
7
+
8
+ /** One run of listed agents and the heading above it; no heading when nothing is grouped at all. */
9
+ export interface AgentGroup {
10
+ heading: string | undefined;
11
+ agents: AgentIdentity[];
12
+ }
13
+
14
+ /** agents grouped by machine, each group keeping the agents' listed order and each heading naming its machine by the display convention (namer). machines maps device id to machine id (MeshStore.listAgentMachines); undefined on a store with no machine identity, in which case, as when no agent is placed on a machine, the list is one ungrouped run. */
15
+ export function groupAgentsByMachine(
16
+ agents: readonly AgentIdentity[],
17
+ machines: ReadonlyMap<string, string> | undefined,
18
+ ownMachine: string | undefined,
19
+ namer: Namer,
20
+ ): AgentGroup[] {
21
+ if (
22
+ machines === undefined ||
23
+ !agents.some((agent) => machines.has(agent.id))
24
+ ) {
25
+ return [{ heading: undefined, agents: [...agents] }];
26
+ }
27
+ const byMachine = new Map<string, AgentIdentity[]>();
28
+ const unplaced: AgentIdentity[] = [];
29
+ for (const agent of agents) {
30
+ const machine = machines.get(agent.id);
31
+ if (machine === undefined) {
32
+ unplaced.push(agent);
33
+ continue;
34
+ }
35
+ const group = byMachine.get(machine);
36
+ if (group === undefined) byMachine.set(machine, [agent]);
37
+ else group.push(agent);
38
+ }
39
+ const groups: AgentGroup[] = [...byMachine]
40
+ .sort(([a], [b]) => {
41
+ if (a === ownMachine) return -1;
42
+ if (b === ownMachine) return 1;
43
+ return a.localeCompare(b);
44
+ })
45
+ .map(([machine, members]) => ({
46
+ heading: `Machine ${namer(machine)}${machine === ownMachine ? " (this machine)" : ""}:`,
47
+ agents: members,
48
+ }));
49
+ if (unplaced.length > 0) {
50
+ groups.push({ heading: "Machine not proven:", agents: unplaced });
51
+ }
52
+ return groups;
53
+ }
@@ -0,0 +1,69 @@
1
+ // Pure retry logic for scripts/publish-mcp-registry.ts, kept here rather than in the script itself so it's testable under src/test/ -- tsconfig.json's rootDir is src/, so a test there can't import from scripts/ (outside that root), while a script importing from src/ is the normal direction. The script itself keeps the actual Date.now()/spawnSync-driven retry loop, which is inherently imperative and time-based; this module holds only the two decisions that loop makes that are worth asserting deterministically.
2
+
3
+ // The MCP registry validates a publish against the npm registry, where the version semantic-release published seconds earlier may not be visible yet. The registry's own error text says to wait and retry, so retry that failure specifically instead of failing the release job on propagation lag.
4
+ //
5
+ // Confirmed insufficient in production (ExaDev/agent-comms, 2026-09-13, v2.18.0): the original fixed [15, 30, 60, 120] schedule (225s total) exhausted and failed the release job while npm's own CDN still hadn't propagated the just-published version -- a real, observed lag longer than that budget, not a hypothetical one. The release job carries no timeout-minutes of its own, so there's no tight external deadline forcing a small retry budget.
6
+ export const INITIAL_RETRY_DELAY_MS = 15_000;
7
+ export const MAX_RETRY_DELAY_MS = 120_000;
8
+ export const RETRY_BACKOFF_MULTIPLIER = 2;
9
+ const MAX_TOTAL_RETRY_MINUTES = 15;
10
+ const SECONDS_PER_MINUTE = 60;
11
+ const MS_PER_SECOND = 1000;
12
+ export const MAX_TOTAL_RETRY_MS =
13
+ MAX_TOTAL_RETRY_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
14
+
15
+ /** The next delay in the doubling-with-cap backoff schedule, given the delay just used. */
16
+ export function nextRetryDelayMs(previousDelayMs: number): number {
17
+ return Math.min(
18
+ previousDelayMs * RETRY_BACKOFF_MULTIPLIER,
19
+ MAX_RETRY_DELAY_MS,
20
+ );
21
+ }
22
+
23
+ /** The MCP registry's own npm-propagation-lag error text -- the version semantic-release just published may not be visible to the registry's own npm lookup for a few seconds. */
24
+ function isNpmPropagationLag(output: string): boolean {
25
+ return (
26
+ output.includes("was not found") &&
27
+ output.includes("A newly published release can take a moment")
28
+ );
29
+ }
30
+
31
+ // Confirmed as a second, distinct retryable failure mode in the same run this retry logic was first fixed for (ExaDev/agent-comms, 2026-09-13, v2.18.1): the registry returned a genuine HTTP 504 (a transient gateway timeout on the registry's own infrastructure, not anything to do with npm propagation), and the retry logic at the time only recognised the propagation-lag text above, so it failed the release job immediately instead of retrying a condition that resolved itself on a plain re-run seconds later. Any 5xx is the registry's own server failing to complete the request, not this side's request being wrong -- exactly the class of error a retry can plausibly fix, unlike a 4xx (this side asked for something invalid) other than the specific propagation-lag 404 already handled above.
32
+ const SERVER_ERROR_STATUS_PATTERN = /server returned status 5\d{2}\b/;
33
+
34
+ function isRetryableServerError(output: string): boolean {
35
+ return SERVER_ERROR_STATUS_PATTERN.test(output);
36
+ }
37
+
38
+ // Confirmed as a third retryable failure mode (ExaDev/agent-comms, 2026-09-17, issue #180, run 35249771867, v3.9.0): the registry request never got as far as an HTTP response at all, a raw connection-level failure from mcp-publisher's own Go HTTP client (a DNS/dial/timeout/reset at the TCP layer), which matches neither the propagation-lag text nor the 5xx status pattern above since there is no status line to match against. This is transient infrastructure the same way a 5xx is: the request itself may well have been valid, retrying it can plausibly succeed.
39
+ const CONNECTION_ERROR_PATTERN =
40
+ /\b(?:dial tcp|i\/o timeout|connection refused|connection reset|no such host)\b/;
41
+
42
+ function isConnectionError(output: string): boolean {
43
+ return CONNECTION_ERROR_PATTERN.test(output);
44
+ }
45
+
46
+ // Confirmed as a fourth retryable failure mode (ExaDev/agent-comms, 2026-09-17, issue #180, run 35239013253, v3.9.0): mcp-publisher logs in with `login github-oidc` once before the retry loop starts, and a retry attempt late enough in the MAX_TOTAL_RETRY_MS budget can present a JWT that expired in the meantime, producing a 401 that no amount of retrying the same token will ever fix. It's retryable only because the caller re-runs the login step before every retry attempt (see scripts/publish-mcp-registry.ts), which mints a fresh token, so this classifier deliberately matches only the specific "expired" 401 body, not a 401 in general, since a genuine authorisation failure (wrong scope, revoked credential) would 401 again regardless of how fresh the token is.
47
+ const EXPIRED_JWT_STATUS_PATTERN = /server returned status 401\b/;
48
+
49
+ function isExpiredJwt(output: string): boolean {
50
+ return EXPIRED_JWT_STATUS_PATTERN.test(output) && output.includes("expired");
51
+ }
52
+
53
+ // Confirmed as a fifth retryable failure mode (ExaDev/agent-comms, 2026-10-21, release run 39670379457, v8.15.4): the registry's own Postgres had no free connection slots (SQLSTATE 53300), and the registry reported that as an HTTP 400 "Failed to publish server" and not a 5xx, so the status pattern above never matched and the release job failed on its first attempt, before the GitHub release was created. PostgreSQL's SQLSTATE class 53 is "insufficient resources" (no connection slots, disk full, out of memory, configuration limit): a shortage on the registry's side that clears by itself and has nothing to do with the request, so it is retryable whatever HTTP status carries it. Any other SQLSTATE class (a constraint violation, say) describes the request or its data and would fail again.
54
+ const DATABASE_RESOURCE_SHORTAGE_PATTERN = /\bSQLSTATE 53\d{3}\b/;
55
+
56
+ function isDatabaseResourceShortage(output: string): boolean {
57
+ return DATABASE_RESOURCE_SHORTAGE_PATTERN.test(output);
58
+ }
59
+
60
+ /** Recognises a publish failure worth retrying (npm propagation lag, a transient 5xx from the registry's own infrastructure, a connection-level failure that never reached the registry, a login JWT that expired mid-retry-window, or the registry's database running short of resources) as opposed to any other failure -- a genuinely invalid publish request or authorisation failure, for instance, which retrying can never fix. */
61
+ export function isRetryablePublishFailure(output: string): boolean {
62
+ return (
63
+ isNpmPropagationLag(output) ||
64
+ isRetryableServerError(output) ||
65
+ isConnectionError(output) ||
66
+ isExpiredJwt(output) ||
67
+ isDatabaseResourceShortage(output)
68
+ );
69
+ }
@@ -0,0 +1,18 @@
1
+ // Decides whether a version is already listed on the MCP registry, kept here rather than in scripts/publish-mcp-registry.ts for the same reason as mcp-registry-retry.ts: a test under src/test/ cannot import from scripts/. The registry publish runs as its own CI job after the release and is re-run by hand after an outage, so it must skip a version the registry already lists, because publishing one again fails the job.
2
+ const REGISTRY_BASE_URL = "https://registry.modelcontextprotocol.io";
3
+ const HTTP_OK = 200;
4
+ const HTTP_NOT_FOUND = 404;
5
+
6
+ /** The registry's endpoint for one version of one server. The name carries a `/` (`io.github.<owner>/<repo>`) that must stay inside a single path segment. */
7
+ export function registryVersionUrl(name: string, version: string): string {
8
+ return `${REGISTRY_BASE_URL}/v0/servers/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`;
9
+ }
10
+
11
+ /** Reads the status of a {@link registryVersionUrl} response: 200 means listed, 404 means not listed. Any other status says nothing about whether the version is listed, so it throws and fails the job to be re-run, never guessing either way. */
12
+ export function isPublishedOnRegistry(status: number): boolean {
13
+ if (status === HTTP_OK) return true;
14
+ if (status === HTTP_NOT_FOUND) return false;
15
+ throw new Error(
16
+ `MCP registry answered ${String(status)} when asked whether the version is listed`,
17
+ );
18
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The proof a device gossips to show that a grouping issuer vouches for it (agent-comms#266 for the user principal, agent-comms#343 for the machine): a short-lived group:member token, minted by that issuer for that one device, carried as one line of text. A receiver that trusts the issuer verifies it against the gossiping device and then trusts the device without it being listed individually, which is what trusting a principal or a machine is for. A receiver that only wants to know which machine a device runs on reads the issuer out of the proof (identifyMembershipProofIssuer).
3
+ *
4
+ * A proof shows that the issuer vouches for a device id. It does not show that whoever gossips it is that device: both arrive in the same unauthenticated advert and anyone who has read one can repeat it, so it earns only what GatewayTrust.isReachable grants (see directory-admission.ts). It is also public: it names the issuer, so anyone connected to the hub can see which devices belong to which principal and which run on the same machine. That is why a proof is only ever carried in the agent/self advert, which a hidden or ghost agent never sends.
5
+ *
6
+ * Every bridge of one account shares that account's user identity, and every bridge one account runs on a host shares that account's machine identity for the host (machine-identity.ts), so any of them can mint a proof for any device id, which is the same trust the issuer is given anyway. Proofs are short-lived rather than revocable, so removeDevice (which revokes an admitDevice grant) does not affect them and a removed device holding the issuer's key can keep minting its own: they are minted without recording an issued-grant, so a bridge refreshing its own does not rewrite the shared identity file.
7
+ */
8
+
9
+ import type { Clock } from "wire-mesh-core/ports/clock";
10
+ import type { IdentityPort } from "wire-mesh-core/ports/identity";
11
+ import type { RevocationView } from "wire-mesh-core/domain/revocation-view";
12
+ import type {
13
+ CapabilityToken,
14
+ DeviceId,
15
+ } from "wire-mesh-core/generated/protocol";
16
+ import { mintGroupMembership } from "./device-membership.js";
17
+ import {
18
+ identifyGroupIssuer,
19
+ verifyDeviceMembership,
20
+ } from "./device-membership-verification.js";
21
+ import { randomId } from "./random-id.js";
22
+ import { CommsError } from "./store.js";
23
+ import { decodeTokenText, encodeTokenText } from "./token-text.js";
24
+
25
+ const MS_PER_MINUTE = 60_000;
26
+ const MEMBERSHIP_PROOF_LIFETIME_MINUTES = 15;
27
+
28
+ /** The longest proof text a receiver will look at. A real proof is a single root-level token, well under half of this; a longer one can only be something else, and the length also bounds how deep a delegation chain could be smuggled in. */
29
+ export const MAX_MEMBERSHIP_PROOF_LENGTH = 4096;
30
+
31
+ /** How long a minted proof stays valid. Long enough to ride out a missed refresh or a gossip gap, short enough that a device an account stops running stops being trusted soon after. */
32
+ export const MEMBERSHIP_PROOF_LIFETIME_MS =
33
+ MEMBERSHIP_PROOF_LIFETIME_MINUTES * MS_PER_MINUTE;
34
+
35
+ export interface MintMembershipProofOptions {
36
+ /** The grouping issuer vouching for the device: the user principal or the machine. */
37
+ issuer: IdentityPort;
38
+ clock: Clock;
39
+ /** The device the proof is for, and the only device it will verify for. */
40
+ deviceId: DeviceId;
41
+ }
42
+
43
+ /** A fresh proof that options.issuer vouches for options.deviceId, as one line of text. Throws MINT_FAILED if the token cannot be minted. */
44
+ export async function mintMembershipProof(
45
+ options: Readonly<MintMembershipProofOptions>,
46
+ ): Promise<string> {
47
+ const verdict = await mintGroupMembership({
48
+ issuer: options.issuer,
49
+ clock: options.clock,
50
+ tokenId: randomId(),
51
+ deviceId: options.deviceId,
52
+ expires: options.clock.now() + MEMBERSHIP_PROOF_LIFETIME_MS,
53
+ });
54
+ if (!verdict.ok) {
55
+ throw new CommsError(
56
+ `Failed to mint a membership proof: ${verdict.reason}`,
57
+ "MINT_FAILED",
58
+ );
59
+ }
60
+ return encodeTokenText(verdict.token);
61
+ }
62
+
63
+ export interface VerifyMembershipProofOptions {
64
+ proof: string;
65
+ /** The device the proof is claimed for: the gossiping device the directory entry names. */
66
+ deviceId: DeviceId;
67
+ /** The issuer (principal or machine) the proof must have been minted by. */
68
+ issuerId: DeviceId;
69
+ /** The verifying node's own identity, clock and revocation view. */
70
+ identity: IdentityPort;
71
+ clock: Clock;
72
+ revocation: RevocationView;
73
+ }
74
+
75
+ export type MembershipProofVerdict =
76
+ { ok: true; expires: number } | { ok: false; reason: string };
77
+
78
+ /** The proof text decoded to a token, or the refusal for text that cannot be one. */
79
+ function decodeProof(
80
+ proof: string,
81
+ ): { ok: true; token: CapabilityToken } | { ok: false; reason: string } {
82
+ if (proof.length > MAX_MEMBERSHIP_PROOF_LENGTH) {
83
+ return { ok: false, reason: "too_long" };
84
+ }
85
+ try {
86
+ return { ok: true, token: decodeTokenText(proof) };
87
+ } catch {
88
+ return { ok: false, reason: "not_a_token" };
89
+ }
90
+ }
91
+
92
+ /** Whether options.proof shows that options.issuerId vouches for options.deviceId right now. Never throws for a bad proof: text that is not a token, a token for another device or issuer, an expired or revoked one, all come back as a refusal. */
93
+ export async function verifyMembershipProof(
94
+ options: Readonly<VerifyMembershipProofOptions>,
95
+ ): Promise<MembershipProofVerdict> {
96
+ const decoded = decodeProof(options.proof);
97
+ if (!decoded.ok) return decoded;
98
+ const verdict = await verifyDeviceMembership(decoded.token, {
99
+ identity: options.identity,
100
+ clock: options.clock,
101
+ revocation: options.revocation,
102
+ expectedBearer: options.deviceId,
103
+ groupIssuerId: options.issuerId,
104
+ });
105
+ if (!verdict.ok) return { ok: false, reason: verdict.reason };
106
+ // Every proof is minted at the root and cannot be delegated; a chain here would be someone else's construction.
107
+ if (verdict.depth !== 0) return { ok: false, reason: "not_root_level" };
108
+ return { ok: true, expires: verdict.claims.expires };
109
+ }
110
+
111
+ export type IdentifyMembershipProofOptions = Omit<
112
+ VerifyMembershipProofOptions,
113
+ "issuerId"
114
+ >;
115
+
116
+ export type IdentifiedMembershipProof =
117
+ | { ok: true; issuerHex: string; expires: number }
118
+ | { ok: false; reason: string };
119
+
120
+ /** Which issuer options.proof shows vouching for options.deviceId right now, learned from the proof itself rather than checked against one already known: the same refusals as verifyMembershipProof, plus a proof whose named group is not the key that signed it. */
121
+ export async function identifyMembershipProofIssuer(
122
+ options: Readonly<IdentifyMembershipProofOptions>,
123
+ ): Promise<IdentifiedMembershipProof> {
124
+ const decoded = decodeProof(options.proof);
125
+ if (!decoded.ok) return decoded;
126
+ const verdict = await identifyGroupIssuer(decoded.token, {
127
+ identity: options.identity,
128
+ clock: options.clock,
129
+ revocation: options.revocation,
130
+ expectedBearer: options.deviceId,
131
+ });
132
+ if (!verdict.ok) return { ok: false, reason: verdict.reason };
133
+ if (verdict.depth !== 0) return { ok: false, reason: "not_root_level" };
134
+ return {
135
+ ok: true,
136
+ issuerHex: verdict.issuerHex,
137
+ expires: verdict.claims.expires,
138
+ };
139
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * A device's own membership proof under one grouping issuer, kept fresh, and the checks for other devices' proofs (agent-comms#266, agent-comms#343). MeshStore holds one instance per issuer, its user principal and its machine, and hands each its identity, peer id and error sink. The checks do not depend on which issuer an instance mints under, so either instance can verify or identify any proof.
3
+ */
4
+
5
+ import type { IdentityPort } from "wire-mesh-core/ports/identity";
6
+ import {
7
+ deviceIdFromHex,
8
+ deviceIdToHex,
9
+ } from "wire-mesh-core/domain/device-id";
10
+ import type { MeshStoreIdentity } from "./mesh-store-shared.js";
11
+ import {
12
+ MEMBERSHIP_PROOF_LIFETIME_MS,
13
+ identifyMembershipProofIssuer,
14
+ mintMembershipProof,
15
+ verifyMembershipProof,
16
+ type IdentifiedMembershipProof,
17
+ type MembershipProofVerdict,
18
+ } from "./membership-proof.js";
19
+
20
+ /** How many times per proof lifetime the proof is re-minted, so a missed tick or two never lets the advertised proof lapse. */
21
+ const REFRESHES_PER_LIFETIME = 3;
22
+ /** How often an advertised proof, or a name claim riding beside it, is re-minted. */
23
+ export const PROOF_REFRESH_INTERVAL_MS =
24
+ MEMBERSHIP_PROOF_LIFETIME_MS / REFRESHES_PER_LIFETIME;
25
+
26
+ export interface MembershipProofsDeps {
27
+ /** Throws if the store has no identity yet. */
28
+ getIdentity: () => MeshStoreIdentity;
29
+ /** The issuer this instance mints under, picked out of the store's identity: its user principal or its machine. */
30
+ issuerOf: (identity: Readonly<MeshStoreIdentity>) => IdentityPort;
31
+ /** This device's own id (hex). */
32
+ getPeerId: () => string;
33
+ onError: (error: Error) => void;
34
+ }
35
+
36
+ /** The last account-membership verdict for one device: which proof it was about, against which principal, and until when a positive one holds. */
37
+ interface AccountMemberVerdict {
38
+ proof: string;
39
+ principal: string;
40
+ /** Epoch ms after which a positive verdict needs checking again; a negative one never changes for the same proof and principal. */
41
+ until: number | undefined;
42
+ }
43
+
44
+ export class MembershipProofs {
45
+ private proof: string | undefined;
46
+ private minting = false;
47
+ private timer: ReturnType<typeof setInterval> | undefined;
48
+ /** Keyed by device-id hex. One entry per device this side has checked, replaced whenever that device's proof or this side's principal changes. */
49
+ private readonly accountVerdicts = new Map<string, AccountMemberVerdict>();
50
+
51
+ constructor(private readonly deps: Readonly<MembershipProofsDeps>) {}
52
+
53
+ /** This device's current proof, undefined until the first has been minted. */
54
+ current(): string | undefined {
55
+ return this.proof;
56
+ }
57
+
58
+ /** Mints a proof now and again on an interval. A failed mint is reported and retried on the next tick; the previous proof stays until it lapses. */
59
+ start(): void {
60
+ this.stop();
61
+ this.refresh();
62
+ this.timer = setInterval(() => {
63
+ this.refresh();
64
+ }, PROOF_REFRESH_INTERVAL_MS);
65
+ this.timer.unref();
66
+ }
67
+
68
+ stop(): void {
69
+ clearInterval(this.timer);
70
+ this.timer = undefined;
71
+ }
72
+
73
+ /** Whether a gossiped proof shows that issuerHex vouches for deviceHex, as this node sees it: its own identity, clock and revocation view. */
74
+ async verify(
75
+ claim: Readonly<{ proof: string; deviceHex: string; issuerHex: string }>,
76
+ ): Promise<MembershipProofVerdict> {
77
+ const { identity, clock, revocation } = this.deps.getIdentity();
78
+ return verifyMembershipProof({
79
+ proof: claim.proof,
80
+ deviceId: deviceIdFromHex(claim.deviceHex),
81
+ issuerId: deviceIdFromHex(claim.issuerHex),
82
+ identity,
83
+ clock,
84
+ revocation,
85
+ });
86
+ }
87
+
88
+ /** Whether a gossiped proof shows that deviceHex belongs to the account this store holds: the proof verifies against this store's own user principal. A verdict is remembered per device, so a device re-gossiping the same proof is not re-verified on every advert; a positive one is trusted only until its proof expires, a negative one for as long as the proof and this side's principal are unchanged. */
89
+ async isAccountMember(
90
+ claim: Readonly<{ proof: string; deviceHex: string }>,
91
+ ): Promise<boolean> {
92
+ const { userIdentity, clock } = this.deps.getIdentity();
93
+ const principal = deviceIdToHex(userIdentity.deviceId);
94
+ const known = this.accountVerdicts.get(claim.deviceHex);
95
+ if (known?.proof === claim.proof && known.principal === principal) {
96
+ if (known.until === undefined) return false;
97
+ if (known.until > clock.now()) return true;
98
+ }
99
+ const verdict = await this.verify({
100
+ proof: claim.proof,
101
+ deviceHex: claim.deviceHex,
102
+ issuerHex: principal,
103
+ });
104
+ this.accountVerdicts.set(claim.deviceHex, {
105
+ proof: claim.proof,
106
+ principal,
107
+ until: verdict.ok ? verdict.expires : undefined,
108
+ });
109
+ return verdict.ok;
110
+ }
111
+
112
+ /** Which issuer a gossiped proof shows vouching for deviceHex, as this node sees it. */
113
+ async identify(
114
+ claim: Readonly<{ proof: string; deviceHex: string }>,
115
+ ): Promise<IdentifiedMembershipProof> {
116
+ const { identity, clock, revocation } = this.deps.getIdentity();
117
+ return identifyMembershipProofIssuer({
118
+ proof: claim.proof,
119
+ deviceId: deviceIdFromHex(claim.deviceHex),
120
+ identity,
121
+ clock,
122
+ revocation,
123
+ });
124
+ }
125
+
126
+ private refresh(): void {
127
+ if (this.minting) return;
128
+ this.minting = true;
129
+ const storeIdentity = this.deps.getIdentity();
130
+ mintMembershipProof({
131
+ issuer: this.deps.issuerOf(storeIdentity),
132
+ clock: storeIdentity.clock,
133
+ deviceId: deviceIdFromHex(this.deps.getPeerId()),
134
+ })
135
+ .then(
136
+ (proof) => {
137
+ this.proof = proof;
138
+ },
139
+ (error: unknown) => {
140
+ this.deps.onError(
141
+ error instanceof Error ? error : new Error(String(error)),
142
+ );
143
+ },
144
+ )
145
+ .finally(() => {
146
+ this.minting = false;
147
+ });
148
+ }
149
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * CommsTool's mesh_graph/mesh_trace action handlers (agent-comms#199) -- split out of tool.ts purely to keep that file under the repo's max-lines cap, the same reason gateway-trust-actions.ts/connection-code-tool.ts were split from it. Free functions over MeshOnlyFeatures's own meshGraph/meshTrace methods rather than class methods, since CommsTool owns no state of its own for this concern.
3
+ */
4
+
5
+ import type { CommsAction } from "./types.js";
6
+ import type { CommsResult, MeshOnlyFeatures } from "./tool.js";
7
+ import { tryMeshAction } from "./tool.js";
8
+ import { idWithNames, type Namer } from "./naming.js";
9
+
10
+ /** Column width the "direct"/"relay" edge-kind label is padded to in meshGraphAction's own listing, matching tool.ts's own aligned-column convention for every other tabular action result. */
11
+ const MESH_GRAPH_EDGE_KIND_COLUMN_WIDTH = 6;
12
+
13
+ /** Uniform "this bridge isn't backed by a mesh transport" result, mirroring tool.ts's own notMeshBacked for the other MeshOnlyFeatures methods (not exported from there, so re-declared locally the same way gateway-trust-actions.ts's own gatewayTrustUnavailable already does). */
14
+ function meshFeatureUnavailable(action: string): CommsResult {
15
+ return {
16
+ isError: true,
17
+ content: `${action} requires a mesh-backed store (this session is running on FileStore)`,
18
+ };
19
+ }
20
+
21
+ /** Reports every known device (nodes, each by its full id, which mesh_trace takes, and its display name from namer) and every self-reported connection edge between them (edges, by display name), assembled from the cached gossip directory: a cheap, possibly-stale snapshot; meshTraceAction below is the live, cache-bust counterpart for one specific device. */
22
+ export function meshGraphAction(
23
+ store: Readonly<Pick<MeshOnlyFeatures, "meshGraph">>,
24
+ namer: Namer,
25
+ ): CommsResult {
26
+ if (!store.meshGraph) return meshFeatureUnavailable("mesh_graph");
27
+ const graph = store.meshGraph();
28
+ if (graph.nodes.length === 0)
29
+ return { content: "No known devices in the mesh graph.", isError: false };
30
+
31
+ const edgeLines = graph.edges.map((edge) => {
32
+ const via =
33
+ edge.kind === "relay" && edge.via !== undefined
34
+ ? ` via ${namer(edge.via)}`
35
+ : "";
36
+ return ` ${edge.kind.padEnd(MESH_GRAPH_EDGE_KIND_COLUMN_WIDTH)} ${namer(edge.from)} -> ${namer(edge.to)}${via}`;
37
+ });
38
+ const edgesBlock =
39
+ edgeLines.length > 0 ? edgeLines.join("\n") : " (no edges reported)";
40
+ return {
41
+ content: `Mesh graph:\nNodes (${String(graph.nodes.length)}):\n${graph.nodes.map((node) => ` ${idWithNames(node, namer)}`).join("\n")}\nEdges:\n${edgesBlock}`,
42
+ isError: false,
43
+ };
44
+ }
45
+
46
+ /** Sends path.trace to action.target and reports the real end-to-end RTT plus each side's own relayed/hub-address knowledge -- the live counterpart to meshGraphAction's gossiped snapshot. */
47
+ export async function meshTraceAction(
48
+ store: Readonly<Pick<MeshOnlyFeatures, "meshTrace">>,
49
+ action: CommsAction & { action: "mesh_trace" },
50
+ ): Promise<CommsResult> {
51
+ if (!store.meshTrace) return meshFeatureUnavailable("mesh_trace");
52
+ return tryMeshAction(`trace ${action.target}`, async () => {
53
+ // Called via `?.()` rather than a `const meshTrace = store.meshTrace` extraction -- the latter would silently drop `this` (WireMeshTransport.meshTrace reads its own private fields), a real bug this exact pattern caught during development. TypeScript can't carry the `if (!store.meshTrace)` guard above across this closure boundary, so `result` is still typed possibly-undefined even though it never actually is at runtime.
54
+ const result = await store.meshTrace?.(action.target, action.timeoutMs);
55
+ if (!result) return meshFeatureUnavailable("mesh_trace").content;
56
+ if (result.outcome.result === "error") {
57
+ throw new Error(
58
+ `${result.outcome.code}${result.outcome.message !== undefined ? `: ${result.outcome.message}` : ""}`,
59
+ );
60
+ }
61
+ const localHub =
62
+ result.local.hubAddress !== undefined
63
+ ? ` (hub ${result.local.hubAddress})`
64
+ : "";
65
+ const lines = [
66
+ `Trace to ${action.target}:`,
67
+ ` RTT: ${String(result.rttMs)}ms`,
68
+ ` Local: ${result.local.relayed ? "relayed" : "direct"}${localHub}`,
69
+ ];
70
+ if (result.remote) {
71
+ const remoteHub =
72
+ result.remote.hubAddress !== undefined
73
+ ? ` (hub ${result.remote.hubAddress})`
74
+ : "";
75
+ lines.push(
76
+ ` Remote: ${result.remote.relayed ? "relayed" : "direct"}${remoteHub}`,
77
+ );
78
+ }
79
+ return lines.join("\n");
80
+ });
81
+ }