agent-comms 8.15.9 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-delegation of a received dm:send grant to one of the recipient principal's own devices (agent-comms#187): once a remote user principal has been admitted into another user's DM-communication scope (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0 specifically to allow this), that principal mints a CHILD dm:send token -- parent = the grant it received, bearer = its own device -- so a specific device can present durable admission on the principal's behalf, without the admitting side ever needing to know that device's own device-id in advance. Mirrors device-membership.ts's admitDevice in shape (a plain mint-and-record function, no MeshStore-equivalent orchestrator), but delegates an EXISTING grant via `parent` rather than self-issuing a fresh root-level one -- the first real use of mintCapabilityToken's parent-narrowing machinery in this codebase, every grant minted before this having been an independent root-level token.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately does not touch the delegated device's own identity-store.ts slot, mirroring device-membership.ts's own "no assumption about locality" stance: getting the minted token onto that device (this process, or a different one) is left entirely to the caller.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
mintCapabilityToken,
|
|
9
|
+
type MintVerdict,
|
|
10
|
+
} from "wire-mesh-core/domain/tokens";
|
|
11
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
12
|
+
import type { IdentityPort } from "wire-mesh-core/ports/identity";
|
|
13
|
+
import type { Clock } from "wire-mesh-core/ports/clock";
|
|
14
|
+
import type {
|
|
15
|
+
CapabilityToken,
|
|
16
|
+
DeviceId,
|
|
17
|
+
} from "wire-mesh-core/generated/protocol";
|
|
18
|
+
import {
|
|
19
|
+
DM_SEND_CAPABILITY,
|
|
20
|
+
DM_SEND_SCOPE_KIND,
|
|
21
|
+
} from "./dm-token-verification.js";
|
|
22
|
+
import type { AccountLedger } from "./account-ledger.js";
|
|
23
|
+
|
|
24
|
+
/** delegationsRemaining a delegated device's own token permits below it, when the caller doesn't ask for more -- 0, matching every other non-delegable grant this codebase already mints by default (device-membership.ts's own NOT_DELEGABLE): an ordinary device is a leaf of the chain, not a further delegator. */
|
|
25
|
+
const LEAF_NOT_DELEGABLE = 0;
|
|
26
|
+
|
|
27
|
+
export interface DelegateDmSendToDeviceOptions {
|
|
28
|
+
/** The principal doing the sub-delegating -- must be the same identity the parent grant's own bearer names, or mintCapabilityToken's own parent-bearer-matches-issuer narrowing check refuses the mint outright (`parent_bearer_mismatch`). */
|
|
29
|
+
userIdentity: IdentityPort;
|
|
30
|
+
/** The account's replicated grant ledger, which records the delegation so any machine holding the account key can revoke it. */
|
|
31
|
+
accountLedger: AccountLedger;
|
|
32
|
+
clock: Clock;
|
|
33
|
+
tokenId: Uint8Array<ArrayBuffer>;
|
|
34
|
+
/** The grant this principal itself was admitted with (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0) -- every one of tokens.cddl's own narrowing obligations is checked against it at mint time. */
|
|
35
|
+
parent: CapabilityToken;
|
|
36
|
+
/** The device being delegated to -- one of this principal's own devices. */
|
|
37
|
+
deviceId: DeviceId;
|
|
38
|
+
/** The remote user principal that originally admitted this identity -- the scope this delegated token must keep naming, unchanged from parent's own scope (tokens.cddl's own narrowing requires an identical "user" scope path down the whole chain). */
|
|
39
|
+
remoteUserPrincipalDeviceId: DeviceId;
|
|
40
|
+
expires: number;
|
|
41
|
+
/** How many further hops the delegated token itself permits below it -- defaults to LEAF_NOT_DELEGABLE; only worth raising for a hierarchy deeper than "principal delegates directly to a device" genuinely needs. */
|
|
42
|
+
delegationsRemaining?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mints deviceId's own dm:send delegation from a received grant: bearer = deviceId, parent = the grant this principal itself was admitted with, scope unchanged (still the REMOTE admitting principal's own "user" scope, never this principal's own device-id); mintCapabilityToken's own narrowing refuses this outright if parent's own delegationsRemaining was 0 (nothing left to sub-delegate) or if userIdentity does not match parent's own bearer. On success, records the token-id in THIS principal's own replicated grant ledger as a dm grant keyed by deviceId's hex, so a later revocation on any of the account's machines can find it; admitAgentForDm's own root-level self-grants are recorded the same way, since both are simply grants this identity has issued, keyed by bearer, whether root-level or a delegation.
|
|
47
|
+
*/
|
|
48
|
+
export async function delegateDmSendToDevice(
|
|
49
|
+
options: Readonly<DelegateDmSendToDeviceOptions>,
|
|
50
|
+
): Promise<MintVerdict> {
|
|
51
|
+
const verdict = await mintCapabilityToken({
|
|
52
|
+
identity: options.userIdentity,
|
|
53
|
+
clock: options.clock,
|
|
54
|
+
tokenId: options.tokenId,
|
|
55
|
+
bearer: options.deviceId,
|
|
56
|
+
capability: DM_SEND_CAPABILITY,
|
|
57
|
+
scope: {
|
|
58
|
+
kind: DM_SEND_SCOPE_KIND,
|
|
59
|
+
path: deviceIdToHex(options.remoteUserPrincipalDeviceId),
|
|
60
|
+
},
|
|
61
|
+
expires: options.expires,
|
|
62
|
+
delegationsRemaining: options.delegationsRemaining ?? LEAF_NOT_DELEGABLE,
|
|
63
|
+
parent: options.parent,
|
|
64
|
+
});
|
|
65
|
+
if (!verdict.ok) return verdict;
|
|
66
|
+
|
|
67
|
+
await options.accountLedger.recordGrant(
|
|
68
|
+
"dm",
|
|
69
|
+
deviceIdToHex(options.deviceId),
|
|
70
|
+
options.tokenId,
|
|
71
|
+
);
|
|
72
|
+
return verdict;
|
|
73
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receiver-side verification of a `dm:send` capability token (agent-comms#162): the receiver-controlled admission list a user principal issues, parallel to room-token-verification.ts's own `room:member` obligations but rooted at the user principal's device-id (user-identity.ts) rather than a room's own owner -- a `dm:send` grant admits a bearer device into THIS user's communication scope across every bridge slot sharing that principal, not into one specific room path. Layered on verifyCapabilityToken the same way verifyRoomToken is: obligations 2/4/5 (bearer match, ordinary token-claims checks, delegations-remaining narrowing) live there already; this module adds the dm:send-specific obligations -- the presented token must actually carry the dm:send capability (not merely happen to share a scope shape), its scope must name this exact user principal, and its delegation chain must root at that same user principal (never at the bearer itself, or self-issued authority would let any sender simply mint its own admission).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
verifyCapabilityToken,
|
|
7
|
+
type TokenVerdictReason,
|
|
8
|
+
type VerifyCapabilityTokenOptions,
|
|
9
|
+
} from "wire-mesh-core/domain/tokens";
|
|
10
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
11
|
+
import type {
|
|
12
|
+
CapabilityToken,
|
|
13
|
+
DeviceId,
|
|
14
|
+
TokenClaims,
|
|
15
|
+
} from "wire-mesh-core/generated/protocol";
|
|
16
|
+
|
|
17
|
+
/** The one capability a user principal issues to admit a device into its own DM-communication scope (agent-comms#162) -- checked receiver-side wherever an unsolicited DM contact is admitted, the way room-token-verification.ts's ROOM_MEMBER_CAPABILITY gates every ordinary room-membership verb. */
|
|
18
|
+
export const DM_SEND_CAPABILITY = "dm:send";
|
|
19
|
+
|
|
20
|
+
/** The scope kind a dm:send grant's own scope always carries -- the resource being granted is "communication with this user principal", named by that principal's own device-id, never a specific bridge slot's peer-id, since a dm:send grant admits a bearer into the user's whole communication scope rather than one particular device. */
|
|
21
|
+
export const DM_SEND_SCOPE_KIND = "user";
|
|
22
|
+
|
|
23
|
+
export type DmSendTokenVerdictReason =
|
|
24
|
+
| TokenVerdictReason
|
|
25
|
+
| "wrong_capability"
|
|
26
|
+
| "wrong_scope_kind"
|
|
27
|
+
| "wrong_scope_path"
|
|
28
|
+
| "wrong_chain_root";
|
|
29
|
+
|
|
30
|
+
export type DmSendTokenVerdict =
|
|
31
|
+
| { ok: true; claims: TokenClaims }
|
|
32
|
+
| { ok: false; reason: DmSendTokenVerdictReason };
|
|
33
|
+
|
|
34
|
+
export interface VerifyDmSendTokenOptions extends Omit<
|
|
35
|
+
VerifyCapabilityTokenOptions,
|
|
36
|
+
"expectedBearer"
|
|
37
|
+
> {
|
|
38
|
+
/** The peer identity actually authenticated on the arriving connection -- the requester presenting this token as its own authority to contact userPrincipalDeviceId, never a relay-asserted or gossip-derived value. Mandatory here, matching verifyRoomToken's own mandatory expectedBearer: every dm:send check gates a specific counterparty, unlike verifyCapabilityToken's own optional field for a caller presenting a token to authorise itself. */
|
|
39
|
+
expectedBearer: DeviceId;
|
|
40
|
+
/** This receiving node's own user-principal device-id (user-identity.ts's loadOrCreateUserIdentity), the only issuer a dm:send grant may validly root at. */
|
|
41
|
+
userPrincipalDeviceId: DeviceId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Verifies a `dm:send` capability token against the receiver's own user-principal identity: the token must carry the dm:send capability, its scope must be kind "user" with path equal to userPrincipalDeviceId exactly, and its delegation chain must root at userPrincipalDeviceId itself -- a self-issued or third-party-issued token naming the right scope by coincidence still fails here, since rootIssuer is checked independently of scope.path.
|
|
46
|
+
*/
|
|
47
|
+
export async function verifyDmSendToken(
|
|
48
|
+
token: CapabilityToken,
|
|
49
|
+
options: Readonly<VerifyDmSendTokenOptions>,
|
|
50
|
+
): Promise<DmSendTokenVerdict> {
|
|
51
|
+
const verdict = await verifyCapabilityToken(token, {
|
|
52
|
+
identity: options.identity,
|
|
53
|
+
clock: options.clock,
|
|
54
|
+
revocation: options.revocation,
|
|
55
|
+
expectedBearer: options.expectedBearer,
|
|
56
|
+
});
|
|
57
|
+
if (!verdict.ok) {
|
|
58
|
+
return verdict;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (verdict.claims.capability !== DM_SEND_CAPABILITY) {
|
|
62
|
+
return { ok: false, reason: "wrong_capability" };
|
|
63
|
+
}
|
|
64
|
+
if (verdict.claims.scope.kind !== DM_SEND_SCOPE_KIND) {
|
|
65
|
+
return { ok: false, reason: "wrong_scope_kind" };
|
|
66
|
+
}
|
|
67
|
+
const expectedPath = deviceIdToHex(options.userPrincipalDeviceId);
|
|
68
|
+
if (verdict.claims.scope.path !== expectedPath) {
|
|
69
|
+
return { ok: false, reason: "wrong_scope_path" };
|
|
70
|
+
}
|
|
71
|
+
if (deviceIdToHex(verdict.rootIssuer) !== expectedPath) {
|
|
72
|
+
return { ok: false, reason: "wrong_chain_root" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ok: true, claims: verdict.claims };
|
|
76
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ElectionSessions: WireMeshTransport's set of live, trusted sessions to peers on this machine, the only sessions coordinator claims are read from and gossiped over (agent-comms#341), since the elected role's duties are per machine. Split out of wire-mesh-transport.ts to keep it under the repo's max-lines cap.
|
|
3
|
+
*
|
|
4
|
+
* A session counts as machine-local when it was accepted on a loopback-only listener (the data server or the default well-known port listener) or dialled at a loopback address (loopback-address.ts), whether at a peer's data server or at the well-known port. A dial at any other address may cross machines, since 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. A session reached through connectToRemote or an addListener-created listener may cross machines and is never enrolled, and neither is one admitted through an approved connect_request, whose dialling side is a connectToRemote session and so never enrols it either.
|
|
5
|
+
*
|
|
6
|
+
* Every session this transport opens is watched from the moment it opens, whether or not it is ever enrolled: a frame is delivered only when its session is enrolled at the moment the frame is read, and is dropped otherwise. So a quarantined session's claims from before its introduce are discarded rather than replayed once it introduces itself, and a session that is never enrolled does not accumulate the frames its peer sends.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AcceptedMeshSession } from "wire-mesh-core/domain/mesh-session";
|
|
10
|
+
import type { CoordinatorFrame } from "wire-mesh-core/generated/protocol";
|
|
11
|
+
import type { ConnectionHandle, TransportEvents } from "./transport.js";
|
|
12
|
+
|
|
13
|
+
/** Where a session's outbound claims stand: whether a send is unresolved, and the newest claim that arrived meanwhile. */
|
|
14
|
+
interface OutboundSlot {
|
|
15
|
+
sending: boolean;
|
|
16
|
+
waiting: Readonly<CoordinatorFrame> | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ElectionSessions {
|
|
20
|
+
/** Each enrolled session, with the handle its claims are reported under. */
|
|
21
|
+
private readonly enrolled = new Map<
|
|
22
|
+
AcceptedMeshSession,
|
|
23
|
+
Readonly<ConnectionHandle>
|
|
24
|
+
>();
|
|
25
|
+
/** Outbound state per session. A claim is state a later claim supersedes, so a session holds at most one send in flight and one waiting claim: a peer that reads slowly can never make this side's write queue grow with every claim it is answered with. */
|
|
26
|
+
private readonly outbound = new Map<AcceptedMeshSession, OutboundSlot>();
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly events: Readonly<
|
|
30
|
+
Pick<
|
|
31
|
+
TransportEvents,
|
|
32
|
+
"onCoordinatorClaim" | "onElectionSessionEnrolled" | "onError"
|
|
33
|
+
>
|
|
34
|
+
>,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
/** Starts reading every coordinator-frame the session's peer gossips, for as long as the session lives, handing each to onCoordinatorClaim only while the session is enrolled. Called exactly once per session, as soon as it is opened and before anything could enrol it. */
|
|
38
|
+
watch(
|
|
39
|
+
session: AcceptedMeshSession,
|
|
40
|
+
handle: Readonly<ConnectionHandle>,
|
|
41
|
+
): void {
|
|
42
|
+
void (async () => {
|
|
43
|
+
for await (const frame of session.coordinatorFrames) {
|
|
44
|
+
if (this.enrolled.has(session)) {
|
|
45
|
+
this.events.onCoordinatorClaim(handle, frame);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Enrols a trusted machine-local session, then fires onElectionSessionEnrolled so this side tells the peer which claim it accepts. Both ends of every machine-local session enrol it, so each tells the other. */
|
|
52
|
+
enrol(
|
|
53
|
+
session: AcceptedMeshSession,
|
|
54
|
+
handle: Readonly<ConnectionHandle>,
|
|
55
|
+
): void {
|
|
56
|
+
this.enrolled.set(session, handle);
|
|
57
|
+
this.events.onElectionSessionEnrolled(handle);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Forgets a closed session. A no-op for one never enrolled. */
|
|
61
|
+
delete(session: AcceptedMeshSession): void {
|
|
62
|
+
this.enrolled.delete(session);
|
|
63
|
+
this.outbound.delete(session);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clear(): void {
|
|
67
|
+
this.enrolled.clear();
|
|
68
|
+
this.outbound.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether this store holds a live machine-local session with the device (hex): a full member of its local mesh on this same machine. */
|
|
72
|
+
hasPeer(deviceHex: string): boolean {
|
|
73
|
+
return this.peerIds().has(deviceHex);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The device-ids of every peer this side has at least one enrolled session to: the peers that take part in the election with it. */
|
|
77
|
+
peerIds(): ReadonlySet<string> {
|
|
78
|
+
return new Set([...this.enrolled.values()].map((handle) => handle.id));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Gossips a claim over every enrolled session. A send fails only on a connection that is already closing, and each failure is reported; the peer is told the incumbent again when its next session is enrolled. */
|
|
82
|
+
async broadcast(frame: Readonly<CoordinatorFrame>): Promise<void> {
|
|
83
|
+
await Promise.all(
|
|
84
|
+
[...this.enrolled].map(async ([session, handle]) =>
|
|
85
|
+
this.sendOver(session, handle, frame),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Sends a claim over every enrolled session to one peer, as an announcement or an answer its peer is waiting to converge on; a peer with no enrolled session is not machine-local and never carries a claim. */
|
|
91
|
+
async sendTo(
|
|
92
|
+
peerId: string,
|
|
93
|
+
frame: Readonly<CoordinatorFrame>,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
await Promise.all(
|
|
96
|
+
[...this.enrolled]
|
|
97
|
+
.filter(([, handle]) => handle.id === peerId)
|
|
98
|
+
.map(async ([session, handle]) =>
|
|
99
|
+
this.sendOver(session, handle, frame),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Sends a claim over one session. While an earlier send on it is still unresolved the claim replaces any one already waiting and this call returns at once; the send in flight then writes the newest waiting claim when it settles, so every send that does happen is awaited and the claim a peer finally reads is the newest one. */
|
|
105
|
+
private async sendOver(
|
|
106
|
+
session: AcceptedMeshSession,
|
|
107
|
+
handle: Readonly<ConnectionHandle>,
|
|
108
|
+
frame: Readonly<CoordinatorFrame>,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const slot = this.outbound.get(session) ?? {
|
|
111
|
+
sending: false,
|
|
112
|
+
waiting: undefined,
|
|
113
|
+
};
|
|
114
|
+
this.outbound.set(session, slot);
|
|
115
|
+
if (slot.sending) {
|
|
116
|
+
slot.waiting = frame;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
slot.sending = true;
|
|
120
|
+
let next: Readonly<CoordinatorFrame> | undefined = frame;
|
|
121
|
+
while (next !== undefined) {
|
|
122
|
+
await session.sendCoordinatorClaim(next).catch((error: unknown) => {
|
|
123
|
+
this.events.onError?.(
|
|
124
|
+
error instanceof Error
|
|
125
|
+
? error
|
|
126
|
+
: new Error(`sendCoordinatorClaim(${handle.id}): ${String(error)}`),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
// A session closed meanwhile has no one left to read what waits.
|
|
130
|
+
next = this.outbound.get(session) === slot ? slot.waiting : undefined;
|
|
131
|
+
slot.waiting = undefined;
|
|
132
|
+
}
|
|
133
|
+
slot.sending = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default sink for a mesh store's error channel. MeshStore.onError carries every failure the mesh handles internally (a hub dial that was refused, a gossip send that failed, a default-front session that could not attach), and nothing shows them unless a bridge assigns a handler, so a bridge without a UI of its own routes them to stderr.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { shortId } from "./display-name.js";
|
|
6
|
+
import { errnoCode, PeerLinkError } from "./peer-link-error.js";
|
|
7
|
+
|
|
8
|
+
/** The causes of a refused or dropped connection to a peer, which all mean the process behind that address has gone. */
|
|
9
|
+
const PEER_GONE_CODES: ReadonlySet<string> = new Set([
|
|
10
|
+
"ECONNREFUSED",
|
|
11
|
+
"ECONNRESET",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** The causes of a first-contact broadcast that found no network to leave through. */
|
|
15
|
+
const NO_NETWORK_CODES: ReadonlySet<string> = new Set([
|
|
16
|
+
"ENETUNREACH",
|
|
17
|
+
"EHOSTUNREACH",
|
|
18
|
+
"ENETDOWN",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** A mesh error as a person should read it: `message` says what happened, what it most likely means and whether anything needs doing, and `key` identifies the condition so a repeat of it can be recognised. */
|
|
22
|
+
export interface DescribedMeshError {
|
|
23
|
+
key: string;
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function describePeerLinkError(
|
|
28
|
+
error: Readonly<PeerLinkError>,
|
|
29
|
+
): DescribedMeshError {
|
|
30
|
+
const peer = shortId(error.peerId);
|
|
31
|
+
const code = errnoCode(error);
|
|
32
|
+
if (error.action === "send") {
|
|
33
|
+
return {
|
|
34
|
+
key: `peer-send:${error.peerId}`,
|
|
35
|
+
message: `The connection to peer ${peer} dropped while a message was being sent (${error.cause instanceof Error ? error.cause.message : String(error.cause)}). The peer most likely exited or restarted.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (code !== undefined && PEER_GONE_CODES.has(code)) {
|
|
39
|
+
return {
|
|
40
|
+
key: `peer-gone:${error.peerId}`,
|
|
41
|
+
message: `Could not connect to peer ${peer} on port ${String(error.port)} (${code}). Its agent most likely exited or restarted, leaving a stale address behind, and no action is needed. If a peer that should be running stays unreachable, check that nothing is blocking connections to localhost.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
key: `peer-dial:${error.peerId}`,
|
|
46
|
+
message: `Could not connect to peer ${peer} on port ${String(error.port)}: ${error.cause instanceof Error ? error.cause.message : String(error.cause)}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Recognises the mesh failures that have a known meaning and a usual remedy, and returns them described for a person. Anything else is `undefined` and is reported as it came. */
|
|
51
|
+
export function describeMeshError(
|
|
52
|
+
error: Readonly<Error>,
|
|
53
|
+
): DescribedMeshError | undefined {
|
|
54
|
+
if (error instanceof PeerLinkError) return describePeerLinkError(error);
|
|
55
|
+
const code = errnoCode(error);
|
|
56
|
+
if (code !== undefined && NO_NETWORK_CODES.has(code)) {
|
|
57
|
+
return {
|
|
58
|
+
key: `no-network:${code}`,
|
|
59
|
+
message: `Could not reach the local network to look for agents on other machines (${code}). Check that the network is connected; agents on this machine still find each other, and discovery of other machines resumes once a network is available.`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The single line a mesh error is reported as. */
|
|
66
|
+
export function formatMeshError(error: Readonly<Error>): string {
|
|
67
|
+
return `agent-comms: ${describeMeshError(error)?.message ?? error.message}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Builds the handler a bridge assigns to `store.onError`, writing each error to `write` as one line. `write` defaults to the process's stderr, which stdio bridges keep clear of protocol traffic. A recognised condition is written once per reporter: a stale peer or a missing network repeats on every dial and beacon, and a second line says nothing the first did not. Errors with no known meaning are always written. Returns the handler rather than assigning it, so the caller does the assignment itself, like wireDefaultCcPeerFront does for onCoordinatorRoleChanged. */
|
|
71
|
+
export function createMeshErrorReporter(
|
|
72
|
+
write: (line: string) => void = (line) => {
|
|
73
|
+
process.stderr.write(line);
|
|
74
|
+
},
|
|
75
|
+
): (error: Error) => void {
|
|
76
|
+
const reported = new Set<string>();
|
|
77
|
+
return (error) => {
|
|
78
|
+
const described = describeMeshError(error);
|
|
79
|
+
if (described !== undefined) {
|
|
80
|
+
if (reported.has(described.key)) return;
|
|
81
|
+
reported.add(described.key);
|
|
82
|
+
}
|
|
83
|
+
write(formatMeshError(error));
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FirstContact: default-on machine-local mesh formation without a coordinator (agent-comms#341). A small UDP presence on the shared beacon port (19877, the same port and payload shape discovery-mdns.ts's own backend uses, so both interoperate on the wire) that lets two peers on the same network find and dial each other directly.
|
|
3
|
+
*
|
|
4
|
+
* The protocol is two packets. A beacon, sent immediately at start and on the same interval discovery-mdns.ts beacons on: `{"type":"agent-comms-beacon","name":...,"port":...,"peerId":...}`, carrying this store's own DATA port, the port a discovering peer actually dials (the coordinator port was never the thing to advertise here: formation needs peers, not a rendezvous). A probe (`{"type":"agent-comms-probe"}`), sent once at start so a joining peer does not wait out a full beacon interval: any peer hearing it answers at once with a fresh beacon.
|
|
5
|
+
*
|
|
6
|
+
* Every packet goes to two destinations, never to a unicast address. Peers on the same host all share one UDP port (reuseAddr), and a unicast datagram to a shared port reaches only ONE of the sockets bound to it, which one being platform-dependent: a probe answer sent back to its prober would land on the answerer itself. Loopback multicast (FIRST_CONTACT_GROUP joined on 127.0.0.1) reaches every socket bound to the port and needs no live network, so it carries same-host formation; limited broadcast reaches other machines on the LAN. That is also why a probe is answered with a broadcast beacon rather than a reply to the prober: the reply has to be heard by the prober's socket specifically, and only a many-receiver destination guarantees it.
|
|
7
|
+
*
|
|
8
|
+
* Discovered peers are handed to the caller's onPeerDiscovered as (peerId, host, data port), and the caller feeds them through the existing peer_list path (PeerLifecycle.handlePeerList), which already dials every peer it learns about; each established connection then shares its own peer list, so the data-connection graph forms transitively by flooding and no rendezvous peer-list handout is needed. A peer's own beacon reaching itself (loopback delivery, or a second bridge in the same slot) is dropped by peerId, not by address, because a multi-homed host can legitimately hear itself on more than one interface address.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately independent of DiscoveryManager: that manager is the opt-in, tool-facing advertise/discover surface (mesh_advertise/mesh_discover) with its own visibility semantics, and default-on formation must not change what those actions mean. This module owns its socket lifecycle entirely (start/stop), keeps no state beyond it, and never blocks a caller: probe answers and discovery callbacks arrive asynchronously on the socket's own events.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as dgram from "node:dgram";
|
|
14
|
+
import type { PeerInfo } from "./wire-protocol.js";
|
|
15
|
+
import { isWebBeaconPayload } from "./web-beacon.js";
|
|
16
|
+
|
|
17
|
+
/** The shared beacon port, the same one discovery-mdns.ts's backend uses: one presence per machine-local network, interoperable payloads. */
|
|
18
|
+
export const FIRST_CONTACT_PORT = 19877;
|
|
19
|
+
|
|
20
|
+
/** The administratively scoped multicast group (239.255.0.0/16, organisation-local scope) joined on the loopback interface, so every same-host socket bound to the beacon port hears every packet whatever the state of the network. */
|
|
21
|
+
export const FIRST_CONTACT_GROUP = "239.255.19.77";
|
|
22
|
+
|
|
23
|
+
/** The loopback interface the group is joined and sent on, and the address same-host multicast packets arrive from. */
|
|
24
|
+
const LOOPBACK_INTERFACE = "127.0.0.1";
|
|
25
|
+
|
|
26
|
+
/** The limited-broadcast address that carries beacons and probes to other machines on the LAN. */
|
|
27
|
+
export const BROADCAST_ADDRESS = "255.255.255.255";
|
|
28
|
+
|
|
29
|
+
/** How often the beacon repeats once started; matching discovery-mdns.ts's own interval so the two presences behave alike on the wire. */
|
|
30
|
+
export const FIRST_CONTACT_INTERVAL_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/** The one shape every beacon carries. Extension-safe for other consumers: parsers must ignore fields they do not know (discovery-mdns.ts's parseBeacon already does). */
|
|
33
|
+
interface BeaconPayload {
|
|
34
|
+
type: "agent-comms-beacon";
|
|
35
|
+
/** This store's own peer id, so a peer can drop its own beacon reaching it back. */
|
|
36
|
+
peerId: string;
|
|
37
|
+
/** This store's data-server port: the port a discovering peer dials. */
|
|
38
|
+
port: number;
|
|
39
|
+
name: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A peer this side discovered and can dial directly. */
|
|
43
|
+
export interface DiscoveredPeer {
|
|
44
|
+
peerId: string;
|
|
45
|
+
host: string;
|
|
46
|
+
port: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FirstContactOptions {
|
|
50
|
+
/** This store's own peer id, for the self-beacon guard. */
|
|
51
|
+
peerId: string;
|
|
52
|
+
/** This store's own data-server port, the port beacons advertise. */
|
|
53
|
+
dataPort: number;
|
|
54
|
+
/** A human-readable name for the beacon payload, matching discovery-mdns.ts's own name field. */
|
|
55
|
+
name: string;
|
|
56
|
+
/** Called once per discovered peer (deduplicated by peerId within this instance's lifetime). */
|
|
57
|
+
onPeerDiscovered: (peer: Readonly<DiscoveredPeer>) => void;
|
|
58
|
+
/** Called for every web UI beacon heard (agent-comms#353), with the host taken from the datagram's source address. Not deduplicated: a beacon repeats on the presence's own interval, and the receiver refreshes its entry each time. Absent means this presence ignores web beacons, today's behaviour before the receiving side existed. */
|
|
59
|
+
onWebBeacon?: (
|
|
60
|
+
beacon: Readonly<{ peerId: string; host: string; webPort: number }>,
|
|
61
|
+
) => void;
|
|
62
|
+
/** The UDP port the presence binds, beacons, and probes on. Defaults to FIRST_CONTACT_PORT; overridable so tests take an OS-assigned free port rather than contending with a real machine presence. */
|
|
63
|
+
port?: number;
|
|
64
|
+
/** Reported, never fatal: a network where broadcast is unavailable (some cloud environments) simply yields no discovery. */
|
|
65
|
+
onError?: (error: Error) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class FirstContact {
|
|
69
|
+
private socket: dgram.Socket | undefined;
|
|
70
|
+
private beaconTimer: ReturnType<typeof setInterval> | undefined;
|
|
71
|
+
private readonly seenPeerIds = new Set<string>();
|
|
72
|
+
|
|
73
|
+
constructor(private readonly opts: Readonly<FirstContactOptions>) {}
|
|
74
|
+
|
|
75
|
+
/** Starts the presence: bind, beacon once and on interval, and broadcast one probe round so nearby peers answer at once instead of on their next beacon. Never throws: a bind failure (another process holds the port without reuse, or the network forbids it) is reported through onError and simply leaves this peer discoverable by others' probes unanswered. */
|
|
76
|
+
start(): void {
|
|
77
|
+
if (this.socket) return;
|
|
78
|
+
const socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
|
|
79
|
+
socket.on("error", (err: Error) => {
|
|
80
|
+
this.opts.onError?.(err);
|
|
81
|
+
});
|
|
82
|
+
socket.on("message", (msg: Buffer, rinfo: dgram.RemoteInfo) => {
|
|
83
|
+
this.handlePacket(msg, rinfo);
|
|
84
|
+
});
|
|
85
|
+
socket.bind(this.opts.port ?? FIRST_CONTACT_PORT, () => {
|
|
86
|
+
socket.setBroadcast(true);
|
|
87
|
+
try {
|
|
88
|
+
socket.addMembership(FIRST_CONTACT_GROUP, LOOPBACK_INTERFACE);
|
|
89
|
+
socket.setMulticastInterface(LOOPBACK_INTERFACE);
|
|
90
|
+
socket.setMulticastLoopback(true);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Reported, not fatal: a host whose loopback interface cannot carry multicast (a Linux container without it, say) still gets LAN broadcast, so the presence degrades to cross-machine discovery rather than failing outright.
|
|
93
|
+
this.opts.onError?.(
|
|
94
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
this.sendBeacon();
|
|
98
|
+
this.sendProbe();
|
|
99
|
+
this.beaconTimer = setInterval(() => {
|
|
100
|
+
this.sendBeacon();
|
|
101
|
+
}, FIRST_CONTACT_INTERVAL_MS);
|
|
102
|
+
});
|
|
103
|
+
this.socket = socket;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stops the presence and releases the port. Idempotent. */
|
|
107
|
+
stop(): void {
|
|
108
|
+
if (this.beaconTimer !== undefined) {
|
|
109
|
+
clearInterval(this.beaconTimer);
|
|
110
|
+
this.beaconTimer = undefined;
|
|
111
|
+
}
|
|
112
|
+
const socket = this.socket;
|
|
113
|
+
this.socket = undefined;
|
|
114
|
+
if (socket) {
|
|
115
|
+
socket.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private handlePacket(msg: Buffer, rinfo: dgram.RemoteInfo): void {
|
|
120
|
+
const parsed: unknown = this.parseJson(msg);
|
|
121
|
+
if (isWebBeaconPayload(parsed)) {
|
|
122
|
+
if (parsed.peerId === this.opts.peerId) return;
|
|
123
|
+
this.opts.onWebBeacon?.({
|
|
124
|
+
peerId: parsed.peerId,
|
|
125
|
+
host: rinfo.address,
|
|
126
|
+
webPort: parsed.webPort,
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!isBeaconPayload(parsed)) {
|
|
131
|
+
// A probe from another peer: answer at once with a fresh beacon on the same many-receiver destinations (see the header comment on why never a unicast reply), so the prober does not wait out our interval.
|
|
132
|
+
if (isProbePayload(parsed)) {
|
|
133
|
+
this.sendBeacon();
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (parsed.peerId === this.opts.peerId) return;
|
|
138
|
+
if (this.seenPeerIds.has(parsed.peerId)) return;
|
|
139
|
+
this.seenPeerIds.add(parsed.peerId);
|
|
140
|
+
this.opts.onPeerDiscovered({
|
|
141
|
+
peerId: parsed.peerId,
|
|
142
|
+
host: rinfo.address,
|
|
143
|
+
port: parsed.port,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private sendBeacon(): void {
|
|
148
|
+
const port = this.opts.port ?? FIRST_CONTACT_PORT;
|
|
149
|
+
this.sendBeaconTo(FIRST_CONTACT_GROUP, port);
|
|
150
|
+
this.sendBeaconTo(BROADCAST_ADDRESS, port);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private sendBeaconTo(address: string, port: number): void {
|
|
154
|
+
const socket = this.socket;
|
|
155
|
+
if (socket === undefined) return;
|
|
156
|
+
const payload: BeaconPayload = {
|
|
157
|
+
type: "agent-comms-beacon",
|
|
158
|
+
peerId: this.opts.peerId,
|
|
159
|
+
port: this.opts.dataPort,
|
|
160
|
+
name: this.opts.name,
|
|
161
|
+
};
|
|
162
|
+
socket.send(JSON.stringify(payload), port, address, (err) => {
|
|
163
|
+
if (err) {
|
|
164
|
+
this.opts.onError?.(err);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private sendProbe(): void {
|
|
170
|
+
const socket = this.socket;
|
|
171
|
+
if (socket === undefined) return;
|
|
172
|
+
const probe = JSON.stringify({ type: "agent-comms-probe" });
|
|
173
|
+
const port = this.opts.port ?? FIRST_CONTACT_PORT;
|
|
174
|
+
for (const address of [FIRST_CONTACT_GROUP, BROADCAST_ADDRESS]) {
|
|
175
|
+
socket.send(probe, port, address, (err) => {
|
|
176
|
+
if (err) {
|
|
177
|
+
this.opts.onError?.(err);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private parseJson(msg: Buffer): unknown {
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(msg.toString());
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isBeaconPayload(value: unknown): value is BeaconPayload {
|
|
193
|
+
if (typeof value !== "object" || value === null) return false;
|
|
194
|
+
if (!("type" in value) || value.type !== "agent-comms-beacon") return false;
|
|
195
|
+
if (!("peerId" in value) || typeof value.peerId !== "string") return false;
|
|
196
|
+
if (!("port" in value) || typeof value.port !== "number") return false;
|
|
197
|
+
if (!("name" in value) || typeof value.name !== "string") return false;
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isProbePayload(value: unknown): boolean {
|
|
202
|
+
if (typeof value !== "object" || value === null) return false;
|
|
203
|
+
return "type" in value && value.type === "agent-comms-probe";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** How much of the peer id the beacon's human-readable name carries: enough to tell two beacons apart in a packet capture, not an identity claim (the full peerId field beside it is that). */
|
|
207
|
+
const BEACON_NAME_PEER_ID_CHARS = 8;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The store-side half of the wiring (agent-comms#341), kept here rather than in mesh-store.ts so that file stays under the repo's max-lines cap, the same reason its other collaborators were split out: constructs, starts, and returns the presence, mapping each discovered peer into a PeerInfo (host from the beacon's source address, port from its payload) for the caller to feed into the ordinary peer-list flood.
|
|
211
|
+
*/
|
|
212
|
+
export function startFirstContact(
|
|
213
|
+
input: Readonly<{
|
|
214
|
+
peerId: string;
|
|
215
|
+
dataPort: number;
|
|
216
|
+
port: number;
|
|
217
|
+
onPeer: (peer: Readonly<PeerInfo>) => void;
|
|
218
|
+
onWebBeacon?: FirstContactOptions["onWebBeacon"];
|
|
219
|
+
onError: (error: Error) => void;
|
|
220
|
+
}>,
|
|
221
|
+
): FirstContact {
|
|
222
|
+
const presence = new FirstContact({
|
|
223
|
+
peerId: input.peerId,
|
|
224
|
+
dataPort: input.dataPort,
|
|
225
|
+
name: `agent-comms-${input.peerId.slice(0, BEACON_NAME_PEER_ID_CHARS)}`,
|
|
226
|
+
port: input.port,
|
|
227
|
+
...(input.onWebBeacon !== undefined
|
|
228
|
+
? { onWebBeacon: input.onWebBeacon }
|
|
229
|
+
: {}),
|
|
230
|
+
onPeerDiscovered: (peer) => {
|
|
231
|
+
input.onPeer({
|
|
232
|
+
id: peer.peerId,
|
|
233
|
+
port: peer.port,
|
|
234
|
+
startedAt: new Date().toISOString(),
|
|
235
|
+
host: peer.host,
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
onError: input.onError,
|
|
239
|
+
});
|
|
240
|
+
presence.start();
|
|
241
|
+
return presence;
|
|
242
|
+
}
|