@clawcrony/claw-crony 1.2.2 → 1.2.4

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/README.md CHANGED
@@ -1,95 +1,97 @@
1
- # Claw Crony
2
-
3
- OpenClaw A2A v0.3.0 Gateway Auto-discovery and secure communication between OpenClaw Agents on different servers.
4
-
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
- [![A2A v0.3.0](https://img.shields.io/badge/A2A-v0.3.0-green.svg)](https://github.com/google/A2A)
7
-
8
- ## Key Features
9
-
10
- - **A2A Protocol v0.3.0** JSON-RPC / REST / gRPC with automatic fallback
11
- - **Hub Matchmaking** Auto-match peer Agents by skills with token exchange
12
- - **Smart Routing** Auto-select targets by message patterns, tags, or peer skills
13
- - **Secure Auth** Bearer Token + zero-downtime multi-token rotation
14
- - **Resilience** Health checks + exponential backoff + circuit breaker
15
- - **File Transfer** URI / base64 / MIME whitelist + SSRF protection
16
- - **Observability** JSONL audit logs + Telemetry metrics endpoint
17
-
1
+ # Claw Crony
2
+
3
+ OpenClaw A2A v0.3.0 Gateway - Auto-discovery and secure communication between OpenClaw Agents on different servers.
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+ [![A2A v0.3.0](https://img.shields.io/badge/A2A-v0.3.0-green.svg)](https://github.com/google/A2A)
7
+
8
+ ## Key Features
9
+
10
+ - **A2A Protocol v0.3.0** - JSON-RPC / REST / gRPC with automatic fallback
11
+ - **Hub Matchmaking** - Auto-match peer Agents by skills with encrypted handshake relay
12
+ - **Smart Routing** - Auto-select targets by message patterns, tags, or peer skills
13
+ - **Secure Auth** - Bearer Token + zero-downtime multi-token rotation
14
+ - **Private Hub Identity** - Register with `client_id + public_key` instead of publishing long-lived connection secrets
15
+ - **Resilience** - Health checks + exponential backoff + circuit breaker
16
+ - **File Transfer** - URI / base64 / MIME whitelist + SSRF protection
17
+ - **Observability** - JSONL audit logs + Telemetry metrics endpoint
18
+
18
19
  ## Hub Server
19
20
 
20
- Default Hub: `https://www.factormining.cn`
21
+ Default Hub: `https://www.clawcrony.com`
22
+
23
+ After installation, the plugin auto-registers with the Hub (requires `registrationEnabled: true`). Registration now uses a local `client_id + public_key` identity pair stored under `~/.openclaw`.
21
24
 
22
- After installation, the plugin auto-registers with the Hub (requires `registrationEnabled: true`). Once registered, use the `a2a_match_request` tool to send a matchmaking request, and the Hub will return a matched peer Agent address together with the tokens needed for the current session.
25
+ Once registered, use the `a2a_match_request` tool to send a matchmaking request. The Hub matches a peer by skills, then relays encrypted handshake messages between the two plugins. The handshake returns temporary A2A connection details for the current session without requiring the Hub to persist peer `IP/port/token` in plaintext.
23
26
 
24
27
  After the user signs in to the Hub web dashboard, they can currently see:
25
28
 
26
- - Their own Agent profile, address, and normalized skill tags
29
+ - Their own Agent profile, public identity, and normalized skill tags
27
30
  - A match timeline for requests created by this Agent
28
31
  - Per-request request summary, required skills, and current status
29
- - Matched result details including provider name, provider address, and update time
30
- - Whether requester/provider tokens have already been submitted for the match
32
+ - Matched result details including peer name, handshake state, and update time
31
33
 
32
34
  A2A service port: **18800** (default)
33
-
34
- ## Installation
35
-
36
- ### Via npm (Recommended)
37
-
38
- ```bash
39
- npm install @clawcrony/claw-crony
40
- ```
41
-
42
- ### Via Git Clone
43
-
44
- ```bash
45
- git clone https://github.com/ccccl8/claw-crony.git
46
- cd claw-crony
47
- npm install
48
- openclaw plugins install .
49
- openclaw gateway restart
50
-
51
- # Verify
52
- curl -s http://localhost:18800/.well-known/agent-card.json
53
- ```
54
-
55
- ## Adding a Peer
56
-
57
- ```bash
58
- openclaw config set plugins.entries.claw-crony.config.peers '[{
59
- "name": "Peer Name",
60
- "agentCardUrl": "http://<peerIP>:18800/.well-known/agent-card.json",
61
- "auth": { "type": "bearer", "token": "<peerToken>" }
62
- }]'
63
- openclaw gateway restart
64
- ```
65
-
35
+
36
+ ## Installation
37
+
38
+ ### Via npm (Recommended)
39
+
40
+ ```bash
41
+ npm install @clawcrony/claw-crony
42
+ ```
43
+
44
+ ### Via Git Clone
45
+
46
+ ```bash
47
+ git clone https://github.com/ccccl8/claw-crony.git
48
+ cd claw-crony
49
+ npm install
50
+ openclaw plugins install .
51
+ openclaw gateway restart
52
+
53
+ # Verify
54
+ curl -s http://localhost:18800/.well-known/agent-card.json
55
+ ```
56
+
57
+ ## Adding a Peer
58
+
59
+ ```bash
60
+ openclaw config set plugins.entries.claw-crony.config.peers '[{
61
+ "name": "Peer Name",
62
+ "agentCardUrl": "http://<peerIP>:18800/.well-known/agent-card.json",
63
+ "auth": { "type": "bearer", "token": "<peerToken>" }
64
+ }]'
65
+ openclaw gateway restart
66
+ ```
67
+
66
68
  ## Hub Matchmaking (a2a_match_request)
67
69
 
68
70
  Send a matchmaking request to the Hub, which automatically finds registered Agents with the required skills:
69
-
70
- ```bash
71
- # Agent calls a2a_match_request tool with params:
72
- # { skills: ["chat"], description?: "optional description" }
73
- #
74
- # Returns: provider address + yourToken + peerToken
75
- # Both sides configure each other as peers using the returned tokens to communicate
76
- ```
77
-
78
- For detailed configuration steps, see [CONFIG.md](CONFIG.md).
79
-
80
- ## Endpoints
81
-
82
- | Endpoint | Method | Description |
83
- |----------|--------|-------------|
84
- | `/.well-known/agent-card.json` | GET | Agent Card (discovery) |
85
- | `/a2a/jsonrpc` | POST | A2A JSON-RPC |
86
- | `/a2a/rest` | POST | A2A REST transport |
87
- | `/a2a/metrics` | GET | Telemetry snapshot (when enabled) |
88
-
89
- ## License
90
-
91
- MIT License
92
-
93
- ## Acknowledgments
94
-
95
- This project is based on [win4r/openclaw-a2a-gateway](https://github.com/win4r/openclaw-a2a-gateway), MIT License.
71
+
72
+ ```bash
73
+ # Agent calls a2a_match_request tool with params:
74
+ # { skills: ["chat"], description?: "optional description" }
75
+ #
76
+ # Returns: temporary peer address + temporary inbound token from encrypted handshake
77
+ # Both sides then communicate directly over A2A without the Hub relaying task payloads
78
+ ```
79
+
80
+ For detailed configuration steps, see [CONFIG.md](CONFIG.md).
81
+
82
+ ## Endpoints
83
+
84
+ | Endpoint | Method | Description |
85
+ |----------|--------|-------------|
86
+ | `/.well-known/agent-card.json` | GET | Agent Card (discovery) |
87
+ | `/a2a/jsonrpc` | POST | A2A JSON-RPC |
88
+ | `/a2a/rest` | POST | A2A REST transport |
89
+ | `/a2a/metrics` | GET | Telemetry snapshot (when enabled) |
90
+
91
+ ## License
92
+
93
+ MIT License
94
+
95
+ ## Acknowledgments
96
+
97
+ This project is based on [win4r/openclaw-a2a-gateway](https://github.com/win4r/openclaw-a2a-gateway), MIT License.
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * - /a2a/rest (REST transport)
6
6
  * - gRPC on port+1 (gRPC transport)
7
7
  */
8
+ import crypto from "node:crypto";
8
9
  import os from "node:os";
9
10
  import path from "node:path";
10
11
  import { AGENT_CARD_PATH } from "@a2a-js/sdk";
@@ -26,6 +27,9 @@ import { runHubRegistration } from "./src/hub-registration.js";
26
27
  import { HubMatchClient } from "./src/hub-match.js";
27
28
  import { normalizeAgentCardSkills } from "./src/skill-catalog.js";
28
29
  import { parseRoutingRules, matchRule } from "./src/routing-rules.js";
30
+ import { decryptHandshake, encryptHandshake } from "./src/handshake-crypto.js";
31
+ import { issueEphemeralInboundToken } from "./src/ephemeral-token.js";
32
+ import { loadIdentity } from "./src/identity-store.js";
29
33
  import { validateUri, validateMimeType, } from "./src/file-security.js";
30
34
  /** Build a JSON-RPC error response. */
31
35
  function jsonRpcError(id, code, message) {
@@ -204,7 +208,7 @@ export function parseConfig(raw, resolvePath) {
204
208
  },
205
209
  },
206
210
  hub: {
207
- url: asString(hub.url, "https://www.factormining.cn"),
211
+ url: asString(hub.url, "https://www.clawcrony.com"),
208
212
  enabled: asBoolean(hub.enabled, true),
209
213
  registrationEnabled: asBoolean(hub.registrationEnabled, true),
210
214
  },
@@ -212,6 +216,7 @@ export function parseConfig(raw, resolvePath) {
212
216
  username: asString(registration.username, ""),
213
217
  email: asString(registration.email, ""),
214
218
  password: asString(registration.password, ""),
219
+ clientId: asString(registration.clientId, ""),
215
220
  },
216
221
  };
217
222
  }
@@ -221,16 +226,51 @@ function normalizeCardPath() {
221
226
  }
222
227
  return `/${AGENT_CARD_PATH}`;
223
228
  }
224
- function getAdvertisedInboundToken(config, hubClient) {
225
- if (config.security.tokens && config.security.tokens.length > 0) {
226
- return config.security.tokens[0] ?? null;
229
+ function getAdvertisedAddress(config) {
230
+ if (config.agentCard.url) {
231
+ try {
232
+ const url = new URL(config.agentCard.url);
233
+ return `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`;
234
+ }
235
+ catch {
236
+ // Ignore invalid configured URL and fall back below.
237
+ }
227
238
  }
228
- if (config.security.token) {
229
- return config.security.token;
239
+ if (config.server.host && config.server.port) {
240
+ return `${config.server.host}:${config.server.port}`;
230
241
  }
231
- return hubClient?.registrationToken ?? null;
242
+ return null;
232
243
  }
233
- async function processPendingHubMatches(api, config, processedMatches) {
244
+ function upsertEphemeralPeer(config, peerName, address, token) {
245
+ const normalizedAddress = address.startsWith("http://") || address.startsWith("https://")
246
+ ? address
247
+ : `http://${address}`;
248
+ const agentCardUrl = `${normalizedAddress}/.well-known/agent.json`;
249
+ const existing = config.peers.find((peer) => peer.name === peerName);
250
+ if (existing) {
251
+ existing.agentCardUrl = agentCardUrl;
252
+ existing.auth = { type: "bearer", token };
253
+ return;
254
+ }
255
+ config.peers.push({
256
+ name: peerName,
257
+ agentCardUrl,
258
+ auth: { type: "bearer", token },
259
+ });
260
+ }
261
+ async function waitForHandshakeAnswer(hubClient, matchId, timeoutMs = 45_000) {
262
+ const deadline = Date.now() + timeoutMs;
263
+ while (Date.now() < deadline) {
264
+ const pending = await hubClient.getPendingHandshakeMessages(matchId);
265
+ const answer = pending.find((message) => message.messageType === "answer");
266
+ if (answer) {
267
+ return answer;
268
+ }
269
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
270
+ }
271
+ throw new Error(`Timed out waiting for handshake answer for match ${matchId}`);
272
+ }
273
+ async function processPendingHubMatches(api, config, processedMessages) {
234
274
  let hubClient;
235
275
  try {
236
276
  hubClient = await HubMatchClient.create();
@@ -239,9 +279,9 @@ async function processPendingHubMatches(api, config, processedMatches) {
239
279
  api.logger.warn(`claw-crony: pending match polling skipped - ${err instanceof Error ? err.message : String(err)}`);
240
280
  return;
241
281
  }
242
- const inboundToken = getAdvertisedInboundToken(config, hubClient);
243
- if (!inboundToken) {
244
- api.logger.warn("claw-crony: pending match polling skipped - no inbound token available");
282
+ const identity = loadIdentity();
283
+ if (!identity) {
284
+ api.logger.warn("claw-crony: pending match polling skipped - no local identity available");
245
285
  return;
246
286
  }
247
287
  let matches;
@@ -257,17 +297,56 @@ async function processPendingHubMatches(api, config, processedMatches) {
257
297
  continue;
258
298
  }
259
299
  try {
260
- const alreadySubmitted = match.providerTokenSubmitted === true || processedMatches.has(match.id);
261
- let currentMatch = match;
262
- if (!alreadySubmitted) {
263
- currentMatch = await hubClient.submitToken(match.id, inboundToken);
264
- processedMatches.add(match.id);
265
- api.logger.info(`claw-crony: submitted provider token for hub match ${match.id}`);
266
- }
267
- if (currentMatch.readyForComplete === true && currentMatch.status === "token_exchange") {
268
- await hubClient.completeMatch(match.id, inboundToken);
269
- processedMatches.delete(match.id);
270
- api.logger.info(`claw-crony: completed hub match ${match.id}`);
300
+ const pendingMessages = await hubClient.getPendingHandshakeMessages(match.id);
301
+ for (const message of pendingMessages) {
302
+ if (processedMessages.has(message.id)) {
303
+ continue;
304
+ }
305
+ const decrypted = decryptHandshake(message.ciphertext, identity);
306
+ await hubClient.consumeHandshakeMessage(match.id, message.id);
307
+ processedMessages.add(message.id);
308
+ const remoteName = match.callerRole === "provider"
309
+ ? (match.requester?.name ?? `agent-${decrypted.fromAgentId}`)
310
+ : (match.provider?.name ?? `agent-${decrypted.fromAgentId}`);
311
+ upsertEphemeralPeer(config, remoteName, decrypted.address, decrypted.token);
312
+ if (message.messageType === "offer" && match.callerRole === "provider") {
313
+ const localAddress = getAdvertisedAddress(config);
314
+ if (!localAddress) {
315
+ api.logger.warn(`claw-crony: cannot answer match ${match.id} because no advertised address is configured`);
316
+ continue;
317
+ }
318
+ const issued = issueEphemeralInboundToken(config, match.id, decrypted.fromAgentId);
319
+ const payload = {
320
+ version: 1,
321
+ matchId: match.id,
322
+ sessionId: crypto.randomUUID(),
323
+ fromAgentId: hubClient.agentId,
324
+ toAgentId: decrypted.fromAgentId,
325
+ address: localAddress,
326
+ agentCardPath: "/.well-known/agent.json",
327
+ token: issued.token,
328
+ tokenExpiresAt: issued.expiresAt,
329
+ protocols: ["jsonrpc", "rest", "grpc"],
330
+ createdAt: new Date().toISOString(),
331
+ nonce: crypto.randomBytes(12).toString("hex"),
332
+ };
333
+ const peerPublicKey = match.requester?.publicKey;
334
+ if (!peerPublicKey) {
335
+ api.logger.warn(`claw-crony: cannot answer match ${match.id} because requester public key is missing`);
336
+ continue;
337
+ }
338
+ await hubClient.sendHandshakeMessage(match.id, {
339
+ messageType: "answer",
340
+ ciphertext: encryptHandshake(payload, peerPublicKey),
341
+ expiresAt: issued.expiresAt,
342
+ });
343
+ await hubClient.markReady(match.id);
344
+ api.logger.info(`claw-crony: answered encrypted handshake for match ${match.id}`);
345
+ }
346
+ else if (message.messageType === "answer") {
347
+ await hubClient.markReady(match.id);
348
+ api.logger.info(`claw-crony: received encrypted handshake answer for match ${match.id}`);
349
+ }
271
350
  }
272
351
  }
273
352
  catch (err) {
@@ -397,7 +476,7 @@ const plugin = {
397
476
  let cleanupTimer = null;
398
477
  let hubMatchPollingTimer = null;
399
478
  const grpcPort = config.server.port + 1;
400
- const processedHubMatches = new Set();
479
+ const processedHubMessages = new Set();
401
480
  api.registerGatewayMethod("a2a.metrics", ({ respond }) => {
402
481
  respond(true, {
403
482
  metrics: telemetry.snapshot(),
@@ -563,14 +642,13 @@ const plugin = {
563
642
  });
564
643
  // ------------------------------------------------------------------
565
644
  // Agent tool: a2a_match_request
566
- // Creates a match request on the hub and submits this agent's token.
567
- // Returns provider address + yourToken + peerToken for A2A communication.
645
+ // Creates a match request on the hub and performs encrypted connection-info handshake.
568
646
  // ------------------------------------------------------------------
569
647
  api.registerTool({
570
648
  name: "a2a_match_request",
571
649
  description: "Request a match with another agent via the hub. " +
572
650
  "The hub finds a provider agent with matching skills, creates a match record, " +
573
- "and returns the provider's address along with tokens for secure A2A communication. " +
651
+ "and relays encrypted handshake messages so both agents can exchange temporary A2A connection details. " +
574
652
  "Use this to discover and connect with peer agents through the hub's registry.",
575
653
  label: "A2A Match Request",
576
654
  parameters: {
@@ -589,9 +667,9 @@ const plugin = {
589
667
  },
590
668
  },
591
669
  async execute(toolCallId, params) {
592
- let client;
670
+ let hubClient;
593
671
  try {
594
- client = await HubMatchClient.create();
672
+ hubClient = await HubMatchClient.create();
595
673
  }
596
674
  catch (err) {
597
675
  const msg = err instanceof Error ? err.message : String(err);
@@ -600,12 +678,25 @@ const plugin = {
600
678
  details: { ok: false, error: msg },
601
679
  };
602
680
  }
681
+ const identity = loadIdentity();
682
+ if (!identity) {
683
+ return {
684
+ content: [{ type: "text", text: "No local identity found. Restart the plugin and try again." }],
685
+ details: { ok: false, error: "identity_missing" },
686
+ };
687
+ }
688
+ const localAddress = getAdvertisedAddress(config);
689
+ if (!localAddress) {
690
+ return {
691
+ content: [{ type: "text", text: "No advertised address is configured for this agent." }],
692
+ details: { ok: false, error: "address_missing" },
693
+ };
694
+ }
603
695
  let match;
604
696
  try {
605
- match = await client.createMatch({
697
+ match = await hubClient.createMatch({
606
698
  skills: params.skills,
607
699
  description: params.description,
608
- token: getAdvertisedInboundToken(config, client) ?? client.registrationToken,
609
700
  });
610
701
  }
611
702
  catch (err) {
@@ -615,38 +706,84 @@ const plugin = {
615
706
  details: { ok: false, error: msg },
616
707
  };
617
708
  }
618
- // Submit our token
619
- let updatedMatch;
709
+ const provider = match.provider;
710
+ const providerPublicKey = provider?.publicKey;
711
+ if (!provider || !providerPublicKey) {
712
+ return {
713
+ content: [{ type: "text", text: `Match created (id=${match.id}) but provider public key is missing` }],
714
+ details: { ok: false, matchId: match.id, error: "provider_public_key_missing" },
715
+ };
716
+ }
717
+ const issued = issueEphemeralInboundToken(config, match.id, provider.id);
718
+ const localPayload = {
719
+ version: 1,
720
+ matchId: match.id,
721
+ sessionId: crypto.randomUUID(),
722
+ fromAgentId: hubClient.agentId,
723
+ toAgentId: provider.id,
724
+ address: localAddress,
725
+ agentCardPath: "/.well-known/agent.json",
726
+ token: issued.token,
727
+ tokenExpiresAt: issued.expiresAt,
728
+ protocols: ["jsonrpc", "rest", "grpc"],
729
+ createdAt: new Date().toISOString(),
730
+ nonce: crypto.randomBytes(12).toString("hex"),
731
+ };
732
+ try {
733
+ await hubClient.sendHandshakeMessage(match.id, {
734
+ messageType: "offer",
735
+ ciphertext: encryptHandshake(localPayload, providerPublicKey),
736
+ expiresAt: issued.expiresAt,
737
+ });
738
+ }
739
+ catch (err) {
740
+ const msg = err instanceof Error ? err.message : String(err);
741
+ return {
742
+ content: [{ type: "text", text: `Match created (id=${match.id}) but failed to send encrypted handshake offer: ${msg}` }],
743
+ details: { ok: false, matchId: match.id, error: msg },
744
+ };
745
+ }
746
+ let answer;
747
+ try {
748
+ answer = await waitForHandshakeAnswer(hubClient, match.id);
749
+ }
750
+ catch (err) {
751
+ const msg = err instanceof Error ? err.message : String(err);
752
+ return {
753
+ content: [{ type: "text", text: `Encrypted handshake offer sent for match ${match.id}, but waiting for answer failed: ${msg}` }],
754
+ details: { ok: false, matchId: match.id, error: msg },
755
+ };
756
+ }
757
+ let remotePayload;
620
758
  try {
621
- updatedMatch = await client.submitToken(match.id, getAdvertisedInboundToken(config, client) ?? client.registrationToken);
759
+ remotePayload = decryptHandshake(answer.ciphertext, identity);
760
+ await hubClient.consumeHandshakeMessage(match.id, answer.id);
761
+ upsertEphemeralPeer(config, provider.name, remotePayload.address, remotePayload.token);
762
+ await hubClient.markReady(match.id);
622
763
  }
623
764
  catch (err) {
624
765
  const msg = err instanceof Error ? err.message : String(err);
625
766
  return {
626
- content: [{ type: "text", text: `Match created (id=${match.id}) but failed to submit token: ${msg}` }],
767
+ content: [{ type: "text", text: `Encrypted handshake answer received for match ${match.id}, but processing failed: ${msg}` }],
627
768
  details: { ok: false, matchId: match.id, error: msg },
628
769
  };
629
770
  }
630
- const provider = updatedMatch.provider;
631
- const providerAddress = provider?.address ?? "(unknown)";
632
- const providerAccessToken = updatedMatch.yourToken ?? "(none)";
633
- const requesterInboundToken = updatedMatch.peerToken ?? "(none)";
634
- const status = updatedMatch.status;
635
771
  return {
636
772
  content: [{
637
773
  type: "text",
638
- text: `Match ${status}: id=${updatedMatch.id}\n` +
639
- `Provider: ${provider?.name ?? "(unknown)"} at ${providerAddress}\n` +
640
- `Provider access token (use to contact provider): ${providerAccessToken}\n` +
641
- `Requester inbound token (provider uses this to contact you): ${requesterInboundToken}`,
774
+ text: `Encrypted handshake ready: match=${match.id}\n` +
775
+ `Provider: ${provider.name}\n` +
776
+ `Address: ${remotePayload.address}\n` +
777
+ `Temporary token: ${remotePayload.token}\n` +
778
+ `Token expires at: ${remotePayload.tokenExpiresAt}`,
642
779
  }],
643
780
  details: {
644
781
  ok: true,
645
- matchId: updatedMatch.id,
646
- status: updatedMatch.status,
647
- providerAddress,
648
- yourToken: providerAccessToken,
649
- peerToken: requesterInboundToken,
782
+ matchId: match.id,
783
+ status: "ready_to_connect",
784
+ providerAddress: remotePayload.address,
785
+ peerToken: remotePayload.token,
786
+ tokenExpiresAt: remotePayload.tokenExpiresAt,
650
787
  },
651
788
  };
652
789
  },
@@ -668,6 +805,13 @@ const plugin = {
668
805
  const reg = await runHubRegistration(api, config, config.hub, config.registration ?? {});
669
806
  if (reg) {
670
807
  api.logger.info(`claw-crony: registered with hub (agentId=${reg.agentId})`);
808
+ try {
809
+ const hubClient = await HubMatchClient.create();
810
+ await hubClient.updatePresence("online");
811
+ }
812
+ catch (presenceErr) {
813
+ api.logger.warn(`claw-crony: failed to update hub presence - ${presenceErr instanceof Error ? presenceErr.message : String(presenceErr)}`);
814
+ }
671
815
  }
672
816
  }
673
817
  catch (err) {
@@ -742,7 +886,7 @@ const plugin = {
742
886
  if (config.hub?.enabled !== false) {
743
887
  const pollingIntervalMs = Math.max(5_000, config.resilience.healthCheck.intervalMs);
744
888
  const pollHubMatches = () => {
745
- void processPendingHubMatches(api, config, processedHubMatches);
889
+ void processPendingHubMatches(api, config, processedHubMessages);
746
890
  };
747
891
  pollHubMatches();
748
892
  hubMatchPollingTimer = setInterval(pollHubMatches, pollingIntervalMs);
@@ -754,6 +898,15 @@ const plugin = {
754
898
  // Stop peer health checks
755
899
  healthManager?.stop();
756
900
  auditLogger.close();
901
+ if (config.hub?.enabled !== false) {
902
+ try {
903
+ const hubClient = await HubMatchClient.create();
904
+ await hubClient.updatePresence("offline");
905
+ }
906
+ catch {
907
+ // Ignore best-effort presence shutdown failure.
908
+ }
909
+ }
757
910
  // Stop task cleanup timer
758
911
  if (cleanupTimer) {
759
912
  clearInterval(cleanupTimer);
@@ -0,0 +1,5 @@
1
+ import type { GatewayConfig } from "./types.js";
2
+ export declare function issueEphemeralInboundToken(config: GatewayConfig, matchId: number, peerAgentId: number, ttlMs?: number): {
3
+ token: string;
4
+ expiresAt: string;
5
+ };
@@ -0,0 +1,12 @@
1
+ import crypto from "node:crypto";
2
+ export function issueEphemeralInboundToken(config, matchId, peerAgentId, ttlMs = 5 * 60_000) {
3
+ const token = `match-${matchId}-peer-${peerAgentId}-${crypto.randomBytes(18).toString("hex")}`;
4
+ config.security.validTokens.add(token);
5
+ setTimeout(() => {
6
+ config.security.validTokens.delete(token);
7
+ }, ttlMs).unref?.();
8
+ return {
9
+ token,
10
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
11
+ };
12
+ }
@@ -0,0 +1,3 @@
1
+ import type { HandshakePayload, IdentityData } from "./types.js";
2
+ export declare function encryptHandshake(payload: HandshakePayload, recipientPublicKeyPem: string): string;
3
+ export declare function decryptHandshake(ciphertext: string, identity: IdentityData): HandshakePayload;
@@ -0,0 +1,58 @@
1
+ import crypto from "node:crypto";
2
+ function toBase64(value) {
3
+ return Buffer.from(value instanceof Buffer ? value : new Uint8Array(value)).toString("base64");
4
+ }
5
+ function fromBase64(value) {
6
+ return Buffer.from(value, "base64");
7
+ }
8
+ function exportPublicPem(key) {
9
+ return key.export({ format: "pem", type: "spki" }).toString();
10
+ }
11
+ function hkdf(secret, salt, info) {
12
+ return Buffer.from(crypto.hkdfSync("sha256", secret, salt, Buffer.from(info, "utf-8"), 32));
13
+ }
14
+ const HANDSHAKE_INFO = "claw-crony:handshake:v1";
15
+ export function encryptHandshake(payload, recipientPublicKeyPem) {
16
+ const recipientPublicKey = crypto.createPublicKey(recipientPublicKeyPem);
17
+ const ephemeral = crypto.generateKeyPairSync("x25519");
18
+ const sharedSecret = crypto.diffieHellman({
19
+ privateKey: ephemeral.privateKey,
20
+ publicKey: recipientPublicKey,
21
+ });
22
+ const iv = crypto.randomBytes(12);
23
+ const key = hkdf(sharedSecret, iv, HANDSHAKE_INFO);
24
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
25
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf-8");
26
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
27
+ const authTag = cipher.getAuthTag();
28
+ const encrypted = {
29
+ version: 1,
30
+ algorithm: "x25519-aes-256-gcm",
31
+ senderPublicKey: exportPublicPem(ephemeral.publicKey),
32
+ iv: toBase64(iv),
33
+ ciphertext: toBase64(ciphertext),
34
+ authTag: toBase64(authTag),
35
+ };
36
+ return JSON.stringify(encrypted);
37
+ }
38
+ export function decryptHandshake(ciphertext, identity) {
39
+ const envelope = JSON.parse(ciphertext);
40
+ if (envelope.algorithm !== "x25519-aes-256-gcm") {
41
+ throw new Error(`Unsupported handshake algorithm: ${envelope.algorithm}`);
42
+ }
43
+ const privateKey = crypto.createPrivateKey(identity.privateKey);
44
+ const senderPublicKey = crypto.createPublicKey(envelope.senderPublicKey);
45
+ const iv = fromBase64(envelope.iv);
46
+ const sharedSecret = crypto.diffieHellman({
47
+ privateKey,
48
+ publicKey: senderPublicKey,
49
+ });
50
+ const key = hkdf(sharedSecret, iv, HANDSHAKE_INFO);
51
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
52
+ decipher.setAuthTag(fromBase64(envelope.authTag));
53
+ const plaintext = Buffer.concat([
54
+ decipher.update(fromBase64(envelope.ciphertext)),
55
+ decipher.final(),
56
+ ]);
57
+ return JSON.parse(plaintext.toString("utf-8"));
58
+ }