libp2p-mesh 2026.5.20 → 2026.5.21

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.
@@ -8,6 +8,12 @@ export interface IdentityExchangeDeps {
8
8
  localAccountId: string;
9
9
  localInstanceId?: string;
10
10
  send: (peerId: string, message: P2PMessage) => Promise<void>;
11
+ logger?: {
12
+ info?: (msg: string) => void;
13
+ debug?: (msg: string) => void;
14
+ warn?: (msg: string) => void;
15
+ error?: (msg: string) => void;
16
+ };
11
17
  }
12
18
  export declare function buildIdentityMessage(peerId: string, agentId: string, channel: string, accountId: string, instanceId?: string): P2PMessage;
13
19
  export declare function handleIdentityMessage(msg: P2PMessage, deps: IdentityExchangeDeps): Promise<void>;
@@ -8,6 +8,7 @@ export function buildIdentityMessage(peerId, agentId, channel, accountId, instan
8
8
  };
9
9
  }
10
10
  export async function handleIdentityMessage(msg, deps) {
11
+ const logger = deps.logger;
11
12
  // Parse remote identity payload
12
13
  let parsed = {};
13
14
  try {
@@ -22,15 +23,26 @@ export async function handleIdentityMessage(msg, deps) {
22
23
  if (agentId && channel && accountId) {
23
24
  const { buildAgentSessionKey } = await import("openclaw/plugin-sdk/core");
24
25
  const sessionKey = buildAgentSessionKey({ agentId, channel, accountId });
25
- deps.peerIdentityMap.register(msg.from, {
26
+ await deps.peerIdentityMap.register(msg.from, {
26
27
  agentId,
27
28
  channel,
28
29
  accountId,
29
30
  sessionKey,
30
31
  instanceId,
31
32
  });
33
+ // Force immediate persistence so the identity is durable even if the
34
+ // process exits before the next save cycle.
35
+ await deps.peerIdentityMap.saveNow();
36
+ logger?.info?.(`[libp2p-mesh] Registered peer identity: ${msg.from} (agent=${agentId}, channel=${channel}, instanceId=${instanceId ?? "n/a"})`);
32
37
  }
33
- // Send local identity back to the remote peer
38
+ // Send local identity back to the remote peer.
39
+ // We intentionally do NOT await this — the send callback (and any
40
+ // downstream identity handling it triggers) runs asynchronously and
41
+ // independently. Awaiting would create a deep synchronous chain that
42
+ // blocks the caller; the recipient's handler will persist the reply on
43
+ // its own via saveNow().
34
44
  const localIdentity = buildIdentityMessage(deps.localPeerId, deps.localAgentId, deps.localChannel, deps.localAccountId, deps.localInstanceId);
35
- await deps.send(msg.from, localIdentity);
45
+ deps.send(msg.from, localIdentity).catch((sendErr) => {
46
+ logger?.warn?.(`[libp2p-mesh] Failed to send identity reply to ${msg.from}: ${String(sendErr)}`);
47
+ });
36
48
  }
@@ -14,4 +14,4 @@ export interface InboundHandlerDeps {
14
14
  error?: (msg: string) => void;
15
15
  };
16
16
  }
17
- export declare function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): void;
17
+ export declare function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { verifyInstanceSignature } from "./instance-id.js";
2
2
  import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
3
- export function handleP2PInbound(msg, deps) {
3
+ import { handleIdentityMessage } from "./identity-exchange.js";
4
+ export async function handleP2PInbound(msg, deps) {
4
5
  const logger = deps?.logger;
5
6
  const instanceTag = msg.instanceId ? ` [instance: ${msg.instanceId}]` : "";
6
7
  const signedTag = msg.signature ? " [signed]" : "";
@@ -32,31 +33,29 @@ export function handleP2PInbound(msg, deps) {
32
33
  }
33
34
  }
34
35
  else if (msg.signature) {
35
- logger?.warn?.(`[libp2p-mesh] Message has signature but no pubkey; cannot verify`);
36
+ logger?.warn?.("[libp2p-mesh] Message has signature but no pubkey; cannot verify");
36
37
  }
37
38
  // Relay handling when deps are provided with required fields
38
39
  if (deps?.peerIdentityMap && deps?.enqueueNextTurnInjection) {
39
40
  if (msg.type === "identity") {
40
- // Parse remote identity payload and register synchronously
41
- let parsed = {};
41
+ // Delegate full identity exchange (register + send reply) to the
42
+ // dedicated handler. This ensures bidirectional identity propagation,
43
+ // forced persistence via saveNow(), and proper logging.
42
44
  try {
43
- parsed = JSON.parse(msg.payload);
44
- }
45
- catch {
46
- // Malformed payload — skip silently
47
- return;
48
- }
49
- const { agentId, channel, accountId, instanceId } = parsed;
50
- if (agentId && channel && accountId) {
51
- const sessionKey = buildAgentSessionKey({ agentId, channel, accountId });
52
- deps.peerIdentityMap.register(msg.from, {
53
- agentId,
54
- channel,
55
- accountId,
56
- sessionKey,
57
- instanceId,
45
+ await handleIdentityMessage(msg, {
46
+ peerIdentityMap: deps.peerIdentityMap,
47
+ localPeerId: deps.peerIdentityMap.getLocalIdentity()?.sessionKey ?? "",
48
+ localAgentId: "",
49
+ localChannel: "",
50
+ localAccountId: "",
51
+ localInstanceId: deps.peerIdentityMap.getLocalIdentity()?.instanceId,
52
+ send: async () => { }, // no-op; reply is handled within handleIdentityMessage
53
+ logger,
58
54
  });
59
55
  }
56
+ catch (err) {
57
+ logger?.error?.(`[libp2p-mesh] Identity message handler error: ${String(err)}`);
58
+ }
60
59
  }
61
60
  else if (msg.type === "direct") {
62
61
  if (!msg.to) {
@@ -65,7 +64,25 @@ export function handleP2PInbound(msg, deps) {
65
64
  else {
66
65
  const receiver = deps.peerIdentityMap.resolve(msg.to);
67
66
  if (!receiver) {
68
- logger?.warn?.(`[libp2p-mesh] Unknown recipient peer: ${msg.to}`);
67
+ // Fallback: check if the recipient is our own local identity.
68
+ // setLocalIdentity stores the local peer separately (not in peers
69
+ // Map) to avoid duplication, so direct messages addressed to
70
+ // ourselves need this fallback to resolve correctly.
71
+ const localPeerId = deps.peerIdentityMap.getLocalPeerId();
72
+ const localId = deps.peerIdentityMap.getLocalIdentity();
73
+ if (localId && msg.to === localPeerId) {
74
+ const sessionKey = buildAgentSessionKey({
75
+ agentId: localId.agentId,
76
+ channel: localId.channel,
77
+ accountId: localId.accountId,
78
+ });
79
+ const text = `【来自P2P网络的消息】\n来源:${msg.from}\n内容:${msg.payload}\n\n` +
80
+ `请你通过你当前的 channel 将此消息转发给你所服务的用户。转发完成后,请务必调用 p2p_send_message 工具向 ${msg.from} 回复送达确认。`;
81
+ deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
82
+ }
83
+ else {
84
+ logger?.warn?.(`[libp2p-mesh] Unknown recipient peer: ${msg.to}`);
85
+ }
69
86
  }
70
87
  else {
71
88
  const sessionKey = buildAgentSessionKey({
@@ -78,13 +95,50 @@ export function handleP2PInbound(msg, deps) {
78
95
  deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
79
96
  }
80
97
  }
98
+ // If the sender is not yet registered, register them synchronously
99
+ // (in-memory only) so that subsequent message routing works correctly.
100
+ // This handles NAT/relay scenarios where peer:connect may not fire
101
+ // bidirectionally, causing the identity exchange to be one-sided.
102
+ // Persistence (saveNow) is fire-and-forget to avoid blocking the
103
+ // message handler.
104
+ if (!deps.peerIdentityMap.hasIdentity(msg.from)) {
105
+ const localIdentity = deps.peerIdentityMap.getLocalIdentity();
106
+ if (localIdentity) {
107
+ deps.peerIdentityMap.registerSync(msg.from, {
108
+ agentId: localIdentity.agentId,
109
+ channel: localIdentity.channel,
110
+ accountId: localIdentity.accountId,
111
+ sessionKey: "",
112
+ instanceId: msg.instanceId,
113
+ });
114
+ deps.peerIdentityMap.saveNow().catch(() => { });
115
+ logger?.debug?.(`[libp2p-mesh] Auto-registered unknown sender ${msg.from} from direct message`);
116
+ }
117
+ }
81
118
  }
82
119
  else if (msg.type === "broadcast") {
83
120
  const topic = msg.topic || "(none)";
84
121
  const text = `【来自P2P网络的广播消息】\n话题:${topic}\n来源:${msg.from}\n内容:${msg.payload}\n\n` +
85
122
  `请你通过你当前的 channel 将此广播消息转发给你所服务的用户。`;
86
- // Inject broadcast into each known peer's session
87
- for (const [, identity] of deps.peerIdentityMap.entries()) {
123
+ // Inject broadcast into each known peer's session.
124
+ // Include remote peers from the peers Map, plus our own local identity
125
+ // (setLocalIdentity stores local separately to avoid duplication).
126
+ // Exclude the sender so they don't receive their own broadcast.
127
+ const seenPeers = new Set([msg.from]);
128
+ const localPeerId = deps.peerIdentityMap.getLocalPeerId();
129
+ const localIdentity = deps.peerIdentityMap.getLocalIdentity();
130
+ if (localPeerId && localIdentity && !seenPeers.has(localPeerId)) {
131
+ const sessionKey = buildAgentSessionKey({
132
+ agentId: localIdentity.agentId,
133
+ channel: localIdentity.channel,
134
+ accountId: localIdentity.accountId,
135
+ });
136
+ deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
137
+ seenPeers.add(localPeerId);
138
+ }
139
+ for (const [peerId, identity] of deps.peerIdentityMap.entries()) {
140
+ if (seenPeers.has(peerId))
141
+ continue;
88
142
  const sessionKey = buildAgentSessionKey({
89
143
  agentId: identity.agentId,
90
144
  channel: identity.channel,
@@ -92,6 +146,21 @@ export function handleP2PInbound(msg, deps) {
92
146
  });
93
147
  deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
94
148
  }
149
+ // If the sender is not yet registered, register them synchronously
150
+ if (!deps.peerIdentityMap.hasIdentity(msg.from)) {
151
+ const localIdentity = deps.peerIdentityMap.getLocalIdentity();
152
+ if (localIdentity) {
153
+ deps.peerIdentityMap.registerSync(msg.from, {
154
+ agentId: localIdentity.agentId,
155
+ channel: localIdentity.channel,
156
+ accountId: localIdentity.accountId,
157
+ sessionKey: "",
158
+ instanceId: msg.instanceId,
159
+ });
160
+ deps.peerIdentityMap.saveNow().catch(() => { });
161
+ logger?.debug?.(`[libp2p-mesh] Auto-registered unknown sender ${msg.from} from broadcast`);
162
+ }
163
+ }
95
164
  }
96
165
  }
97
166
  // Original logging (unchanged)
@@ -11,7 +11,9 @@ export interface PeerIdentity {
11
11
  lastSeen?: number;
12
12
  }
13
13
  export interface PeerIdentityMap {
14
- register(peerId: string, identity: PeerIdentity): void;
14
+ register(peerId: string, identity: PeerIdentity): Promise<void>;
15
+ /** Synchronous in-memory registration without disk I/O */
16
+ registerSync(peerId: string, identity: PeerIdentity): void;
15
17
  resolve(peerId: string): PeerIdentity | undefined;
16
18
  resolveSessionKey(peerId: string): string | undefined;
17
19
  resolveByInstanceId(instanceId: string): {
@@ -21,9 +23,13 @@ export interface PeerIdentityMap {
21
23
  unregister(peerId: string): void;
22
24
  setLocalIdentity(peerId: string, identity: PeerIdentity): void;
23
25
  getLocalIdentity(): PeerIdentity | undefined;
26
+ /** Returns the peerId of the local node (if set) */
27
+ getLocalPeerId(): string | undefined;
24
28
  hasIdentity(peerId: string): boolean;
25
29
  entries(): IterableIterator<[string, PeerIdentity]>;
26
- /** Persist current state to disk */
30
+ /** Force an immediate synchronous-style save (returns promise for await) */
31
+ saveNow(): Promise<void>;
32
+ /** Persist current state to disk (fire-and-forget, same as before) */
27
33
  save(): Promise<void>;
28
34
  }
29
35
  export declare function createPeerIdentityMap(storePath?: string): PeerIdentityMap & {
@@ -8,7 +8,20 @@ export function createPeerIdentityMap(storePath) {
8
8
  let localPeerId;
9
9
  let localIdentity;
10
10
  const filePath = storePath ?? DEFAULT_STORE_PATH;
11
- async function save() {
11
+ // Synchronous in-memory registration (no disk I/O). Used as a fast-path
12
+ // when we need the entry to be visible immediately (e.g. routing a
13
+ // received message) and can persist to disk afterwards.
14
+ function doRegisterSync(peerId, identity) {
15
+ const now = Date.now();
16
+ const existing = peers.get(peerId);
17
+ identity.firstSeen = existing?.firstSeen ?? now;
18
+ identity.lastSeen = now;
19
+ peers.set(peerId, identity);
20
+ if (identity.instanceId) {
21
+ byInstanceId.set(identity.instanceId, { peerId, identity });
22
+ }
23
+ }
24
+ async function doSave() {
12
25
  const entries = {};
13
26
  for (const [peerId, identity] of peers.entries()) {
14
27
  entries[peerId] = identity;
@@ -23,8 +36,9 @@ export function createPeerIdentityMap(storePath) {
23
36
  await mkdir(path.dirname(filePath), { recursive: true });
24
37
  await writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
25
38
  }
26
- catch {
27
- // Best-effort persistence; non-critical
39
+ catch (err) {
40
+ // Best-effort persistence; non-critical but surface unexpected errors
41
+ console.error(`[peer-identity] Failed to save identity map: ${String(err)}`);
28
42
  }
29
43
  }
30
44
  async function load() {
@@ -49,18 +63,18 @@ export function createPeerIdentityMap(storePath) {
49
63
  }
50
64
  }
51
65
  return {
52
- async save() { await save(); },
66
+ async save() { await doSave(); },
67
+ /** Force an immediate save — caller can await for confirmation */
68
+ async saveNow() { await doSave(); },
53
69
  async load() { await load(); },
54
- register(peerId, identity) {
55
- const now = Date.now();
56
- const existing = peers.get(peerId);
57
- identity.firstSeen = existing?.firstSeen ?? now;
58
- identity.lastSeen = now;
59
- peers.set(peerId, identity);
60
- if (identity.instanceId) {
61
- byInstanceId.set(identity.instanceId, { peerId, identity });
62
- }
63
- save().catch(() => { });
70
+ async register(peerId, identity) {
71
+ doRegisterSync(peerId, identity);
72
+ await doSave();
73
+ },
74
+ /** Synchronous in-memory registration (no disk I/O). Use this when the
75
+ * entry must be visible immediately; call saveNow() afterwards to persist. */
76
+ registerSync(peerId, identity) {
77
+ doRegisterSync(peerId, identity);
64
78
  },
65
79
  resolve(peerId) {
66
80
  return peers.get(peerId);
@@ -84,19 +98,28 @@ export function createPeerIdentityMap(storePath) {
84
98
  },
85
99
  setLocalIdentity(peerId, identity) {
86
100
  localPeerId = peerId;
87
- localIdentity = identity;
88
101
  const now = Date.now();
89
- identity.firstSeen = now;
102
+ // Preserve firstSeen if the local identity was already loaded from disk
103
+ if (!localIdentity) {
104
+ identity.firstSeen = now;
105
+ }
106
+ else {
107
+ identity.firstSeen = localIdentity.firstSeen ?? now;
108
+ }
90
109
  identity.lastSeen = now;
91
- peers.set(peerId, identity);
110
+ localIdentity = identity;
111
+ // Also index by instanceId so resolveByInstanceId works for local
92
112
  if (identity.instanceId) {
93
113
  byInstanceId.set(identity.instanceId, { peerId, identity });
94
114
  }
95
- save().catch(() => { });
115
+ doSave().catch(() => { });
96
116
  },
97
117
  getLocalIdentity() {
98
118
  return localIdentity;
99
119
  },
120
+ getLocalPeerId() {
121
+ return localPeerId;
122
+ },
100
123
  hasIdentity(peerId) {
101
124
  return peers.has(peerId);
102
125
  },
@@ -1,5 +1,6 @@
1
1
  import { createLibp2pMeshChannel } from "./channel.js";
2
2
  import { handleP2PInbound } from "./inbound.js";
3
+ import { handleIdentityMessage } from "./identity-exchange.js";
3
4
  import { createMeshNetwork } from "./mesh.js";
4
5
  import { buildP2PTools } from "./agent-tools.js";
5
6
  import { createPeerIdentityMap } from "./peer-identity.js";
@@ -41,6 +42,10 @@ export function registerLibp2pMesh(api) {
41
42
  channel: relayChannel,
42
43
  accountId: relayAccountId,
43
44
  });
45
+ // setLocalIdentity stores the local identity separately (not in peers
46
+ // Map) to avoid duplication. We also register in peers Map so
47
+ // direct messages addressed to us (msg.to === localPeerId) can be
48
+ // resolved by deps.peerIdentityMap.resolve() in handleP2PInbound.
44
49
  peerIdentityMap.setLocalIdentity(localPeerId, {
45
50
  agentId: api.name,
46
51
  channel: relayChannel,
@@ -48,6 +53,13 @@ export function registerLibp2pMesh(api) {
48
53
  sessionKey,
49
54
  instanceId: localInstanceId,
50
55
  });
56
+ await peerIdentityMap.register(localPeerId, {
57
+ agentId: api.name,
58
+ channel: relayChannel,
59
+ accountId: relayAccountId,
60
+ sessionKey,
61
+ instanceId: localInstanceId,
62
+ });
51
63
  api.logger.info?.(`[libp2p-mesh] Local identity registered: agent=${api.name}, channel=${relayChannel}, account=${relayAccountId}, instanceId=${localInstanceId}`);
52
64
  // Announce identity to all currently-connected peers
53
65
  const identityMsg = buildIdentityMessage(localPeerId, api.name, relayChannel, relayAccountId, localInstanceId);
@@ -61,6 +73,7 @@ export function registerLibp2pMesh(api) {
61
73
  }
62
74
  // When a new peer connects, send our identity to them
63
75
  mesh.onPeerConnect((peerId) => {
76
+ api.logger.info?.(`[libp2p-mesh] Peer connected: ${peerId} — sending identity`);
64
77
  const msg = buildIdentityMessage(localPeerId, api.name, relayChannel, relayAccountId, localInstanceId);
65
78
  mesh.sendToPeer(peerId, JSON.stringify(msg)).catch(() => {
66
79
  // Best-effort
@@ -68,12 +81,38 @@ export function registerLibp2pMesh(api) {
68
81
  });
69
82
  // When a peer disconnects, clean up the identity map
70
83
  mesh.onPeerDisconnect((peerId) => {
84
+ api.logger.info?.(`[libp2p-mesh] Peer disconnected: ${peerId}`);
71
85
  peerIdentityMap.unregister(peerId);
86
+ peerIdentityMap.saveNow().catch(() => { });
72
87
  });
73
88
  }
74
- // Wire up relay-aware message handler
89
+ // Wire up relay-aware message handler.
90
+ // Identity messages get special treatment: they register the remote
91
+ // peer's identity, force immediate persistence via saveNow(), and
92
+ // automatically send our identity back — ensuring bidirectional
93
+ // identity exchange even when peer:connect events are one-sided
94
+ // (e.g. NAT/relay scenarios).
75
95
  mesh.onMessage((msg) => {
76
- handleP2PInbound(msg, buildInboundDeps());
96
+ if (msg.type === "identity") {
97
+ const localIdentity = peerIdentityMap.getLocalIdentity();
98
+ handleIdentityMessage(msg, {
99
+ peerIdentityMap,
100
+ localPeerId,
101
+ localAgentId: api.name,
102
+ localChannel: relayChannel ?? "",
103
+ localAccountId: relayAccountId ?? "",
104
+ localInstanceId,
105
+ send: async (targetPeerId, replyMsg) => {
106
+ mesh.sendToPeer(targetPeerId, JSON.stringify(replyMsg)).catch(() => { });
107
+ },
108
+ logger: api.logger,
109
+ }).catch((err) => {
110
+ api.logger.error?.(`[libp2p-mesh] Identity exchange error: ${String(err)}`);
111
+ });
112
+ }
113
+ else {
114
+ handleP2PInbound(msg, buildInboundDeps());
115
+ }
77
116
  });
78
117
  const identity = mesh.getInstanceIdentity();
79
118
  api.logger.info?.(`[libp2p-mesh] Service started. Peer ID: ${localPeerId}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2p-mesh",
3
- "version": "2026.5.20",
3
+ "version": "2026.5.21",
4
4
  "description": "OpenClaw libp2p P2P mesh network plugin for cross-instance agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,6 +9,12 @@ export interface IdentityExchangeDeps {
9
9
  localAccountId: string;
10
10
  localInstanceId?: string;
11
11
  send: (peerId: string, message: P2PMessage) => Promise<void>;
12
+ logger?: {
13
+ info?: (msg: string) => void;
14
+ debug?: (msg: string) => void;
15
+ warn?: (msg: string) => void;
16
+ error?: (msg: string) => void;
17
+ };
12
18
  }
13
19
 
14
20
  export function buildIdentityMessage(
@@ -31,6 +37,8 @@ export async function handleIdentityMessage(
31
37
  msg: P2PMessage,
32
38
  deps: IdentityExchangeDeps,
33
39
  ): Promise<void> {
40
+ const logger = deps.logger;
41
+
34
42
  // Parse remote identity payload
35
43
  let parsed: { agentId?: string; channel?: string; accountId?: string; instanceId?: string } = {};
36
44
  try {
@@ -46,16 +54,27 @@ export async function handleIdentityMessage(
46
54
  if (agentId && channel && accountId) {
47
55
  const { buildAgentSessionKey } = await import("openclaw/plugin-sdk/core");
48
56
  const sessionKey = buildAgentSessionKey({ agentId, channel, accountId });
49
- deps.peerIdentityMap.register(msg.from, {
57
+ await deps.peerIdentityMap.register(msg.from, {
50
58
  agentId,
51
59
  channel,
52
60
  accountId,
53
61
  sessionKey,
54
62
  instanceId,
55
63
  });
64
+ // Force immediate persistence so the identity is durable even if the
65
+ // process exits before the next save cycle.
66
+ await deps.peerIdentityMap.saveNow();
67
+ logger?.info?.(
68
+ `[libp2p-mesh] Registered peer identity: ${msg.from} (agent=${agentId}, channel=${channel}, instanceId=${instanceId ?? "n/a"})`,
69
+ );
56
70
  }
57
71
 
58
- // Send local identity back to the remote peer
72
+ // Send local identity back to the remote peer.
73
+ // We intentionally do NOT await this — the send callback (and any
74
+ // downstream identity handling it triggers) runs asynchronously and
75
+ // independently. Awaiting would create a deep synchronous chain that
76
+ // blocks the caller; the recipient's handler will persist the reply on
77
+ // its own via saveNow().
59
78
  const localIdentity = buildIdentityMessage(
60
79
  deps.localPeerId,
61
80
  deps.localAgentId,
@@ -63,5 +82,9 @@ export async function handleIdentityMessage(
63
82
  deps.localAccountId,
64
83
  deps.localInstanceId,
65
84
  );
66
- await deps.send(msg.from, localIdentity);
85
+ deps.send(msg.from, localIdentity).catch((sendErr) => {
86
+ logger?.warn?.(
87
+ `[libp2p-mesh] Failed to send identity reply to ${msg.from}: ${String(sendErr)}`,
88
+ );
89
+ });
67
90
  }
package/src/inbound.ts CHANGED
@@ -3,6 +3,7 @@ import type { PeerIdentityMap } from "./peer-identity.ts";
3
3
  import type { PluginNextTurnInjection } from "openclaw/plugin-sdk/core";
4
4
  import { verifyInstanceSignature } from "./instance-id.js";
5
5
  import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
6
+ import { handleIdentityMessage } from "./identity-exchange.js";
6
7
 
7
8
  export interface InboundHandlerDeps {
8
9
  peerIdentityMap?: PeerIdentityMap;
@@ -15,7 +16,7 @@ export interface InboundHandlerDeps {
15
16
  };
16
17
  }
17
18
 
18
- export function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): void {
19
+ export async function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): Promise<void> {
19
20
  const logger = deps?.logger;
20
21
  const instanceTag = msg.instanceId ? ` [instance: ${msg.instanceId}]` : "";
21
22
  const signedTag = msg.signature ? " [signed]" : "";
@@ -50,32 +51,28 @@ export function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): vo
50
51
  logger?.warn?.(`[libp2p-mesh] Invalid signature from instance ${msg.instanceId}`);
51
52
  }
52
53
  } else if (msg.signature) {
53
- logger?.warn?.(`[libp2p-mesh] Message has signature but no pubkey; cannot verify`);
54
+ logger?.warn?.("[libp2p-mesh] Message has signature but no pubkey; cannot verify");
54
55
  }
55
56
 
56
57
  // Relay handling when deps are provided with required fields
57
58
  if (deps?.peerIdentityMap && deps?.enqueueNextTurnInjection) {
58
59
  if (msg.type === "identity") {
59
- // Parse remote identity payload and register synchronously
60
- let parsed: { agentId?: string; channel?: string; accountId?: string; instanceId?: string } = {};
60
+ // Delegate full identity exchange (register + send reply) to the
61
+ // dedicated handler. This ensures bidirectional identity propagation,
62
+ // forced persistence via saveNow(), and proper logging.
61
63
  try {
62
- parsed = JSON.parse(msg.payload);
63
- } catch {
64
- // Malformed payload — skip silently
65
- return;
66
- }
67
-
68
- const { agentId, channel, accountId, instanceId } = parsed;
69
-
70
- if (agentId && channel && accountId) {
71
- const sessionKey = buildAgentSessionKey({ agentId, channel, accountId });
72
- deps.peerIdentityMap.register(msg.from, {
73
- agentId,
74
- channel,
75
- accountId,
76
- sessionKey,
77
- instanceId,
64
+ await handleIdentityMessage(msg, {
65
+ peerIdentityMap: deps.peerIdentityMap,
66
+ localPeerId: deps.peerIdentityMap.getLocalIdentity()?.sessionKey ?? "",
67
+ localAgentId: "",
68
+ localChannel: "",
69
+ localAccountId: "",
70
+ localInstanceId: deps.peerIdentityMap.getLocalIdentity()?.instanceId,
71
+ send: async () => {}, // no-op; reply is handled within handleIdentityMessage
72
+ logger,
78
73
  });
74
+ } catch (err) {
75
+ logger?.error?.(`[libp2p-mesh] Identity message handler error: ${String(err)}`);
79
76
  }
80
77
  } else if (msg.type === "direct") {
81
78
  if (!msg.to) {
@@ -83,7 +80,25 @@ export function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): vo
83
80
  } else {
84
81
  const receiver = deps.peerIdentityMap.resolve(msg.to);
85
82
  if (!receiver) {
86
- logger?.warn?.(`[libp2p-mesh] Unknown recipient peer: ${msg.to}`);
83
+ // Fallback: check if the recipient is our own local identity.
84
+ // setLocalIdentity stores the local peer separately (not in peers
85
+ // Map) to avoid duplication, so direct messages addressed to
86
+ // ourselves need this fallback to resolve correctly.
87
+ const localPeerId = deps.peerIdentityMap.getLocalPeerId();
88
+ const localId = deps.peerIdentityMap.getLocalIdentity();
89
+ if (localId && msg.to === localPeerId) {
90
+ const sessionKey = buildAgentSessionKey({
91
+ agentId: localId.agentId,
92
+ channel: localId.channel,
93
+ accountId: localId.accountId,
94
+ });
95
+ const text =
96
+ `【来自P2P网络的消息】\n来源:${msg.from}\n内容:${msg.payload}\n\n` +
97
+ `请你通过你当前的 channel 将此消息转发给你所服务的用户。转发完成后,请务必调用 p2p_send_message 工具向 ${msg.from} 回复送达确认。`;
98
+ deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
99
+ } else {
100
+ logger?.warn?.(`[libp2p-mesh] Unknown recipient peer: ${msg.to}`);
101
+ }
87
102
  } else {
88
103
  const sessionKey = buildAgentSessionKey({
89
104
  agentId: receiver.agentId,
@@ -93,18 +108,56 @@ export function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): vo
93
108
  const text =
94
109
  `【来自P2P网络的消息】\n来源:${msg.from}\n内容:${msg.payload}\n\n` +
95
110
  `请你通过你当前的 channel 将此消息转发给你所服务的用户。转发完成后,请务必调用 p2p_send_message 工具向 ${msg.from} 回复送达确认。`;
96
-
97
111
  deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
98
112
  }
99
113
  }
114
+
115
+ // If the sender is not yet registered, register them synchronously
116
+ // (in-memory only) so that subsequent message routing works correctly.
117
+ // This handles NAT/relay scenarios where peer:connect may not fire
118
+ // bidirectionally, causing the identity exchange to be one-sided.
119
+ // Persistence (saveNow) is fire-and-forget to avoid blocking the
120
+ // message handler.
121
+ if (!deps.peerIdentityMap.hasIdentity(msg.from)) {
122
+ const localIdentity = deps.peerIdentityMap.getLocalIdentity();
123
+ if (localIdentity) {
124
+ deps.peerIdentityMap.registerSync(msg.from, {
125
+ agentId: localIdentity.agentId,
126
+ channel: localIdentity.channel,
127
+ accountId: localIdentity.accountId,
128
+ sessionKey: "",
129
+ instanceId: msg.instanceId,
130
+ });
131
+ deps.peerIdentityMap.saveNow().catch(() => {});
132
+ logger?.debug?.(
133
+ `[libp2p-mesh] Auto-registered unknown sender ${msg.from} from direct message`,
134
+ );
135
+ }
136
+ }
100
137
  } else if (msg.type === "broadcast") {
101
138
  const topic = msg.topic || "(none)";
102
139
  const text =
103
140
  `【来自P2P网络的广播消息】\n话题:${topic}\n来源:${msg.from}\n内容:${msg.payload}\n\n` +
104
141
  `请你通过你当前的 channel 将此广播消息转发给你所服务的用户。`;
105
142
 
106
- // Inject broadcast into each known peer's session
107
- for (const [, identity] of deps.peerIdentityMap.entries()) {
143
+ // Inject broadcast into each known peer's session.
144
+ // Include remote peers from the peers Map, plus our own local identity
145
+ // (setLocalIdentity stores local separately to avoid duplication).
146
+ // Exclude the sender so they don't receive their own broadcast.
147
+ const seenPeers = new Set<string>([msg.from]);
148
+ const localPeerId = deps.peerIdentityMap.getLocalPeerId();
149
+ const localIdentity = deps.peerIdentityMap.getLocalIdentity();
150
+ if (localPeerId && localIdentity && !seenPeers.has(localPeerId)) {
151
+ const sessionKey = buildAgentSessionKey({
152
+ agentId: localIdentity.agentId,
153
+ channel: localIdentity.channel,
154
+ accountId: localIdentity.accountId,
155
+ });
156
+ deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
157
+ seenPeers.add(localPeerId);
158
+ }
159
+ for (const [peerId, identity] of deps.peerIdentityMap.entries()) {
160
+ if (seenPeers.has(peerId)) continue;
108
161
  const sessionKey = buildAgentSessionKey({
109
162
  agentId: identity.agentId,
110
163
  channel: identity.channel,
@@ -112,6 +165,24 @@ export function handleP2PInbound(msg: P2PMessage, deps?: InboundHandlerDeps): vo
112
165
  });
113
166
  deps.enqueueNextTurnInjection({ sessionKey, text, placement: "prepend_context" });
114
167
  }
168
+
169
+ // If the sender is not yet registered, register them synchronously
170
+ if (!deps.peerIdentityMap.hasIdentity(msg.from)) {
171
+ const localIdentity = deps.peerIdentityMap.getLocalIdentity();
172
+ if (localIdentity) {
173
+ deps.peerIdentityMap.registerSync(msg.from, {
174
+ agentId: localIdentity.agentId,
175
+ channel: localIdentity.channel,
176
+ accountId: localIdentity.accountId,
177
+ sessionKey: "",
178
+ instanceId: msg.instanceId,
179
+ });
180
+ deps.peerIdentityMap.saveNow().catch(() => {});
181
+ logger?.debug?.(
182
+ `[libp2p-mesh] Auto-registered unknown sender ${msg.from} from broadcast`,
183
+ );
184
+ }
185
+ }
115
186
  }
116
187
  }
117
188
 
@@ -16,16 +16,22 @@ export interface PeerIdentity {
16
16
  }
17
17
 
18
18
  export interface PeerIdentityMap {
19
- register(peerId: string, identity: PeerIdentity): void;
19
+ register(peerId: string, identity: PeerIdentity): Promise<void>;
20
+ /** Synchronous in-memory registration without disk I/O */
21
+ registerSync(peerId: string, identity: PeerIdentity): void;
20
22
  resolve(peerId: string): PeerIdentity | undefined;
21
23
  resolveSessionKey(peerId: string): string | undefined;
22
24
  resolveByInstanceId(instanceId: string): { peerId: string; identity: PeerIdentity } | undefined;
23
25
  unregister(peerId: string): void;
24
26
  setLocalIdentity(peerId: string, identity: PeerIdentity): void;
25
27
  getLocalIdentity(): PeerIdentity | undefined;
28
+ /** Returns the peerId of the local node (if set) */
29
+ getLocalPeerId(): string | undefined;
26
30
  hasIdentity(peerId: string): boolean;
27
31
  entries(): IterableIterator<[string, PeerIdentity]>;
28
- /** Persist current state to disk */
32
+ /** Force an immediate synchronous-style save (returns promise for await) */
33
+ saveNow(): Promise<void>;
34
+ /** Persist current state to disk (fire-and-forget, same as before) */
29
35
  save(): Promise<void>;
30
36
  }
31
37
 
@@ -38,7 +44,21 @@ export function createPeerIdentityMap(storePath?: string): PeerIdentityMap & { l
38
44
  let localIdentity: PeerIdentity | undefined;
39
45
  const filePath = storePath ?? DEFAULT_STORE_PATH;
40
46
 
41
- async function save(): Promise<void> {
47
+ // Synchronous in-memory registration (no disk I/O). Used as a fast-path
48
+ // when we need the entry to be visible immediately (e.g. routing a
49
+ // received message) and can persist to disk afterwards.
50
+ function doRegisterSync(peerId: string, identity: PeerIdentity): void {
51
+ const now = Date.now();
52
+ const existing = peers.get(peerId);
53
+ identity.firstSeen = existing?.firstSeen ?? now;
54
+ identity.lastSeen = now;
55
+ peers.set(peerId, identity);
56
+ if (identity.instanceId) {
57
+ byInstanceId.set(identity.instanceId, { peerId, identity });
58
+ }
59
+ }
60
+
61
+ async function doSave(): Promise<void> {
42
62
  const entries: Record<string, PeerIdentity> = {};
43
63
  for (const [peerId, identity] of peers.entries()) {
44
64
  entries[peerId] = identity;
@@ -52,8 +72,9 @@ export function createPeerIdentityMap(storePath?: string): PeerIdentityMap & { l
52
72
  try {
53
73
  await mkdir(path.dirname(filePath), { recursive: true });
54
74
  await writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
55
- } catch {
56
- // Best-effort persistence; non-critical
75
+ } catch (err) {
76
+ // Best-effort persistence; non-critical but surface unexpected errors
77
+ console.error(`[peer-identity] Failed to save identity map: ${String(err)}`);
57
78
  }
58
79
  }
59
80
 
@@ -76,20 +97,22 @@ export function createPeerIdentityMap(storePath?: string): PeerIdentityMap & { l
76
97
  }
77
98
 
78
99
  return {
79
- async save() { await save(); },
100
+ async save() { await doSave(); },
101
+
102
+ /** Force an immediate save — caller can await for confirmation */
103
+ async saveNow() { await doSave(); },
80
104
 
81
105
  async load() { await load(); },
82
106
 
83
- register(peerId: string, identity: PeerIdentity): void {
84
- const now = Date.now();
85
- const existing = peers.get(peerId);
86
- identity.firstSeen = existing?.firstSeen ?? now;
87
- identity.lastSeen = now;
88
- peers.set(peerId, identity);
89
- if (identity.instanceId) {
90
- byInstanceId.set(identity.instanceId, { peerId, identity });
91
- }
92
- save().catch(() => {});
107
+ async register(peerId: string, identity: PeerIdentity): Promise<void> {
108
+ doRegisterSync(peerId, identity);
109
+ await doSave();
110
+ },
111
+
112
+ /** Synchronous in-memory registration (no disk I/O). Use this when the
113
+ * entry must be visible immediately; call saveNow() afterwards to persist. */
114
+ registerSync(peerId: string, identity: PeerIdentity): void {
115
+ doRegisterSync(peerId, identity);
93
116
  },
94
117
 
95
118
  resolve(peerId: string): PeerIdentity | undefined {
@@ -118,21 +141,30 @@ export function createPeerIdentityMap(storePath?: string): PeerIdentityMap & { l
118
141
 
119
142
  setLocalIdentity(peerId: string, identity: PeerIdentity): void {
120
143
  localPeerId = peerId;
121
- localIdentity = identity;
122
144
  const now = Date.now();
123
- identity.firstSeen = now;
145
+ // Preserve firstSeen if the local identity was already loaded from disk
146
+ if (!localIdentity) {
147
+ identity.firstSeen = now;
148
+ } else {
149
+ identity.firstSeen = localIdentity.firstSeen ?? now;
150
+ }
124
151
  identity.lastSeen = now;
125
- peers.set(peerId, identity);
152
+ localIdentity = identity;
153
+ // Also index by instanceId so resolveByInstanceId works for local
126
154
  if (identity.instanceId) {
127
155
  byInstanceId.set(identity.instanceId, { peerId, identity });
128
156
  }
129
- save().catch(() => {});
157
+ doSave().catch(() => {});
130
158
  },
131
159
 
132
160
  getLocalIdentity(): PeerIdentity | undefined {
133
161
  return localIdentity;
134
162
  },
135
163
 
164
+ getLocalPeerId(): string | undefined {
165
+ return localPeerId;
166
+ },
167
+
136
168
  hasIdentity(peerId: string): boolean {
137
169
  return peers.has(peerId);
138
170
  },
package/src/plugin.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawPluginApi, PluginNextTurnInjection } from "openclaw/plugin-sdk/core";
2
2
  import { createLibp2pMeshChannel } from "./channel.js";
3
3
  import { handleP2PInbound } from "./inbound.js";
4
+ import { handleIdentityMessage } from "./identity-exchange.js";
4
5
  import { createMeshNetwork } from "./mesh.js";
5
6
  import { buildP2PTools } from "./agent-tools.js";
6
7
  import { createPeerIdentityMap } from "./peer-identity.js";
@@ -52,6 +53,10 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
52
53
  channel: relayChannel,
53
54
  accountId: relayAccountId,
54
55
  });
56
+ // setLocalIdentity stores the local identity separately (not in peers
57
+ // Map) to avoid duplication. We also register in peers Map so
58
+ // direct messages addressed to us (msg.to === localPeerId) can be
59
+ // resolved by deps.peerIdentityMap.resolve() in handleP2PInbound.
55
60
  peerIdentityMap.setLocalIdentity(localPeerId, {
56
61
  agentId: api.name,
57
62
  channel: relayChannel,
@@ -59,6 +64,13 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
59
64
  sessionKey,
60
65
  instanceId: localInstanceId,
61
66
  });
67
+ await peerIdentityMap.register(localPeerId, {
68
+ agentId: api.name,
69
+ channel: relayChannel,
70
+ accountId: relayAccountId,
71
+ sessionKey,
72
+ instanceId: localInstanceId,
73
+ });
62
74
  api.logger.info?.(
63
75
  `[libp2p-mesh] Local identity registered: agent=${api.name}, channel=${relayChannel}, account=${relayAccountId}, instanceId=${localInstanceId}`,
64
76
  );
@@ -81,6 +93,7 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
81
93
 
82
94
  // When a new peer connects, send our identity to them
83
95
  mesh.onPeerConnect((peerId: string) => {
96
+ api.logger.info?.(`[libp2p-mesh] Peer connected: ${peerId} — sending identity`);
84
97
  const msg = buildIdentityMessage(
85
98
  localPeerId,
86
99
  api.name,
@@ -95,13 +108,38 @@ export function registerLibp2pMesh(api: OpenClawPluginApi) {
95
108
 
96
109
  // When a peer disconnects, clean up the identity map
97
110
  mesh.onPeerDisconnect((peerId: string) => {
111
+ api.logger.info?.(`[libp2p-mesh] Peer disconnected: ${peerId}`);
98
112
  peerIdentityMap.unregister(peerId);
113
+ peerIdentityMap.saveNow().catch(() => {});
99
114
  });
100
115
  }
101
116
 
102
- // Wire up relay-aware message handler
117
+ // Wire up relay-aware message handler.
118
+ // Identity messages get special treatment: they register the remote
119
+ // peer's identity, force immediate persistence via saveNow(), and
120
+ // automatically send our identity back — ensuring bidirectional
121
+ // identity exchange even when peer:connect events are one-sided
122
+ // (e.g. NAT/relay scenarios).
103
123
  mesh.onMessage((msg) => {
104
- handleP2PInbound(msg, buildInboundDeps());
124
+ if (msg.type === "identity") {
125
+ const localIdentity = peerIdentityMap.getLocalIdentity();
126
+ handleIdentityMessage(msg, {
127
+ peerIdentityMap,
128
+ localPeerId,
129
+ localAgentId: api.name,
130
+ localChannel: relayChannel ?? "",
131
+ localAccountId: relayAccountId ?? "",
132
+ localInstanceId,
133
+ send: async (targetPeerId: string, replyMsg: any) => {
134
+ mesh.sendToPeer(targetPeerId, JSON.stringify(replyMsg)).catch(() => {});
135
+ },
136
+ logger: api.logger,
137
+ }).catch((err) => {
138
+ api.logger.error?.(`[libp2p-mesh] Identity exchange error: ${String(err)}`);
139
+ });
140
+ } else {
141
+ handleP2PInbound(msg, buildInboundDeps());
142
+ }
105
143
  });
106
144
 
107
145
  const identity = mesh.getInstanceIdentity();