@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 +84 -82
- package/dist/index.js +202 -49
- package/dist/src/ephemeral-token.d.ts +5 -0
- package/dist/src/ephemeral-token.js +12 -0
- package/dist/src/handshake-crypto.d.ts +3 -0
- package/dist/src/handshake-crypto.js +58 -0
- package/dist/src/hub-match.d.ts +35 -46
- package/dist/src/hub-match.js +38 -53
- package/dist/src/hub-registration.d.ts +3 -10
- package/dist/src/hub-registration.js +33 -132
- package/dist/src/identity-store.d.ts +4 -0
- package/dist/src/identity-store.js +55 -0
- package/dist/src/types.d.ts +40 -2
- package/openclaw.plugin.json +7 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,95 +1,97 @@
|
|
|
1
|
-
# Claw Crony
|
|
2
|
-
|
|
3
|
-
OpenClaw A2A v0.3.0 Gateway
|
|
4
|
-
|
|
5
|
-
[](LICENSE)
|
|
6
|
-
[](https://github.com/google/A2A)
|
|
7
|
-
|
|
8
|
-
## Key Features
|
|
9
|
-
|
|
10
|
-
- **A2A Protocol v0.3.0**
|
|
11
|
-
- **Hub Matchmaking**
|
|
12
|
-
- **Smart Routing**
|
|
13
|
-
- **Secure Auth**
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
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)
|
|
6
|
+
[](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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
75
|
-
# Both sides
|
|
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.
|
|
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
|
|
225
|
-
if (config.
|
|
226
|
-
|
|
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.
|
|
229
|
-
return config.
|
|
239
|
+
if (config.server.host && config.server.port) {
|
|
240
|
+
return `${config.server.host}:${config.server.port}`;
|
|
230
241
|
}
|
|
231
|
-
return
|
|
242
|
+
return null;
|
|
232
243
|
}
|
|
233
|
-
|
|
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
|
|
243
|
-
if (!
|
|
244
|
-
api.logger.warn("claw-crony: pending match polling skipped - no
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
670
|
+
let hubClient;
|
|
593
671
|
try {
|
|
594
|
-
|
|
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
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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: `
|
|
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: `
|
|
639
|
-
`Provider: ${provider
|
|
640
|
-
`
|
|
641
|
-
`
|
|
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:
|
|
646
|
-
status:
|
|
647
|
-
providerAddress,
|
|
648
|
-
|
|
649
|
-
|
|
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,
|
|
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,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
|
+
}
|