@clawcrony/claw-crony 1.2.3 → 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 +9 -7
- package/dist/index.js +201 -48
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,10 @@ OpenClaw A2A v0.3.0 Gateway - Auto-discovery and secure communication between Op
|
|
|
8
8
|
## Key Features
|
|
9
9
|
|
|
10
10
|
- **A2A Protocol v0.3.0** - JSON-RPC / REST / gRPC with automatic fallback
|
|
11
|
-
- **Hub Matchmaking** - Auto-match peer Agents by skills with
|
|
11
|
+
- **Hub Matchmaking** - Auto-match peer Agents by skills with encrypted handshake relay
|
|
12
12
|
- **Smart Routing** - Auto-select targets by message patterns, tags, or peer skills
|
|
13
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
|
|
14
15
|
- **Resilience** - Health checks + exponential backoff + circuit breaker
|
|
15
16
|
- **File Transfer** - URI / base64 / MIME whitelist + SSRF protection
|
|
16
17
|
- **Observability** - JSONL audit logs + Telemetry metrics endpoint
|
|
@@ -19,15 +20,16 @@ OpenClaw A2A v0.3.0 Gateway - Auto-discovery and secure communication between Op
|
|
|
19
20
|
|
|
20
21
|
Default Hub: `https://www.clawcrony.com`
|
|
21
22
|
|
|
22
|
-
After installation, the plugin auto-registers with the Hub (requires `registrationEnabled: true`).
|
|
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`.
|
|
24
|
+
|
|
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
35
|
|
|
@@ -71,8 +73,8 @@ Send a matchmaking request to the Hub, which automatically finds registered Agen
|
|
|
71
73
|
# Agent calls a2a_match_request tool with params:
|
|
72
74
|
# { skills: ["chat"], description?: "optional description" }
|
|
73
75
|
#
|
|
74
|
-
# Returns:
|
|
75
|
-
# Both sides
|
|
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
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
For detailed configuration steps, see [CONFIG.md](CONFIG.md).
|
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) {
|
|
@@ -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
|
+
}
|
package/dist/src/hub-match.d.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hub Match API client for
|
|
3
|
-
*
|
|
4
|
-
* Provides a typed client for the hub's /api/matches endpoints:
|
|
5
|
-
* - POST /api/matches createMatch
|
|
6
|
-
* - GET /api/matches/{id} getMatch
|
|
7
|
-
* - GET /api/matches/pending getPendingMatches
|
|
8
|
-
* - POST /api/matches/{id}/token submitToken
|
|
9
|
-
* - POST /api/matches/{id}/complete completeMatch
|
|
10
|
-
* - POST /api/matches/{id}/cancel cancelMatch
|
|
2
|
+
* Hub Match API client for claw-crony.
|
|
11
3
|
*/
|
|
12
4
|
import type { HubRegistrationData } from "./types.js";
|
|
13
5
|
export interface HubAgentDto {
|
|
14
6
|
id: number;
|
|
15
7
|
name: string;
|
|
16
|
-
address: string;
|
|
17
8
|
skills: string[];
|
|
9
|
+
clientId?: string;
|
|
10
|
+
publicKey?: string;
|
|
11
|
+
presenceStatus?: string;
|
|
18
12
|
}
|
|
19
13
|
export interface HubMatchResult {
|
|
20
14
|
id: number;
|
|
@@ -22,58 +16,53 @@ export interface HubMatchResult {
|
|
|
22
16
|
status: string;
|
|
23
17
|
requester: HubAgentDto | null;
|
|
24
18
|
provider: HubAgentDto | null;
|
|
25
|
-
yourToken
|
|
26
|
-
peerToken
|
|
19
|
+
yourToken?: string | null;
|
|
20
|
+
peerToken?: string | null;
|
|
27
21
|
callerRole?: "requester" | "provider" | "observer" | null;
|
|
28
22
|
requesterTokenSubmitted?: boolean;
|
|
29
23
|
providerTokenSubmitted?: boolean;
|
|
30
24
|
readyForComplete?: boolean;
|
|
25
|
+
requesterHandshakeSent?: boolean;
|
|
26
|
+
providerHandshakeSent?: boolean;
|
|
27
|
+
requesterHandshakeConsumed?: boolean;
|
|
28
|
+
providerHandshakeConsumed?: boolean;
|
|
29
|
+
requesterReady?: boolean;
|
|
30
|
+
providerReady?: boolean;
|
|
31
|
+
readyForConnect?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface HubHandshakeMessage {
|
|
34
|
+
id: number;
|
|
35
|
+
senderAgentId: number;
|
|
36
|
+
receiverAgentId: number;
|
|
37
|
+
messageType: "offer" | "answer";
|
|
38
|
+
ciphertext: string;
|
|
39
|
+
status: string;
|
|
40
|
+
expiresAt: string;
|
|
41
|
+
createdAt?: string;
|
|
42
|
+
consumedAt?: string | null;
|
|
31
43
|
}
|
|
32
44
|
export declare class HubMatchClient {
|
|
33
45
|
private readonly hubUrl;
|
|
34
46
|
private readonly registration;
|
|
35
47
|
constructor(hubUrl: string, registration: HubRegistrationData);
|
|
36
48
|
get agentId(): number;
|
|
37
|
-
get registrationToken(): string;
|
|
38
49
|
static create(): Promise<HubMatchClient>;
|
|
39
50
|
private request;
|
|
40
|
-
/**
|
|
41
|
-
* Create a new match request.
|
|
42
|
-
* @param params.skills - Skills to search for in a provider
|
|
43
|
-
* @param params.description - Optional description of the match request
|
|
44
|
-
* @param params.token - Optional bearer token to include for this agent
|
|
45
|
-
*/
|
|
46
51
|
createMatch(params: {
|
|
47
52
|
skills: string[];
|
|
48
53
|
description?: string;
|
|
49
|
-
token?: string;
|
|
50
54
|
}): Promise<HubMatchResult>;
|
|
51
|
-
/**
|
|
52
|
-
* Get a match result by ID.
|
|
53
|
-
* @param matchId - The match ID
|
|
54
|
-
* @param callerId - Optional agent ID to set callerId query param (affects yourToken)
|
|
55
|
-
*/
|
|
56
55
|
getMatch(matchId: number, callerId?: number): Promise<HubMatchResult>;
|
|
57
|
-
/**
|
|
58
|
-
* Get all pending matches for this agent.
|
|
59
|
-
*/
|
|
60
56
|
getPendingMatches(): Promise<HubMatchResult[]>;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
completeMatch(matchId: number, token: string): Promise<HubMatchResult>;
|
|
73
|
-
/**
|
|
74
|
-
* Cancel a pending or token_exchange match.
|
|
75
|
-
* @param matchId - The match ID
|
|
76
|
-
* @param token - This agent's bearer token (for authorization)
|
|
77
|
-
*/
|
|
78
|
-
cancelMatch(matchId: number, token: string): Promise<HubMatchResult>;
|
|
57
|
+
updatePresence(presenceStatus: "online" | "offline" | "busy", clientVersion?: string): Promise<HubAgentDto>;
|
|
58
|
+
sendHandshakeMessage(matchId: number, params: {
|
|
59
|
+
messageType: "offer" | "answer";
|
|
60
|
+
ciphertext: string;
|
|
61
|
+
expiresAt: string;
|
|
62
|
+
}): Promise<HubHandshakeMessage>;
|
|
63
|
+
getPendingHandshakeMessages(matchId: number): Promise<HubHandshakeMessage[]>;
|
|
64
|
+
consumeHandshakeMessage(matchId: number, messageId: number): Promise<HubHandshakeMessage>;
|
|
65
|
+
markReady(matchId: number): Promise<HubMatchResult>;
|
|
66
|
+
completeMatch(matchId: number): Promise<HubMatchResult>;
|
|
67
|
+
cancelMatch(matchId: number): Promise<HubMatchResult>;
|
|
79
68
|
}
|
package/dist/src/hub-match.js
CHANGED
|
@@ -1,18 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hub Match API client for
|
|
3
|
-
*
|
|
4
|
-
* Provides a typed client for the hub's /api/matches endpoints:
|
|
5
|
-
* - POST /api/matches createMatch
|
|
6
|
-
* - GET /api/matches/{id} getMatch
|
|
7
|
-
* - GET /api/matches/pending getPendingMatches
|
|
8
|
-
* - POST /api/matches/{id}/token submitToken
|
|
9
|
-
* - POST /api/matches/{id}/complete completeMatch
|
|
10
|
-
* - POST /api/matches/{id}/cancel cancelMatch
|
|
2
|
+
* Hub Match API client for claw-crony.
|
|
11
3
|
*/
|
|
12
4
|
import { loadRegistration } from "./hub-registration.js";
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// HubMatchClient
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
5
|
export class HubMatchClient {
|
|
17
6
|
hubUrl;
|
|
18
7
|
registration;
|
|
@@ -23,16 +12,12 @@ export class HubMatchClient {
|
|
|
23
12
|
get agentId() {
|
|
24
13
|
return this.registration.agentId;
|
|
25
14
|
}
|
|
26
|
-
get registrationToken() {
|
|
27
|
-
return this.registration.token;
|
|
28
|
-
}
|
|
29
15
|
static async create() {
|
|
30
16
|
const registration = loadRegistration();
|
|
31
17
|
if (!registration) {
|
|
32
18
|
throw new Error("No hub registration found. Run the gateway first to register with the hub.");
|
|
33
19
|
}
|
|
34
|
-
|
|
35
|
-
return new HubMatchClient(configUrl, registration);
|
|
20
|
+
return new HubMatchClient(registration.hubUrl, registration);
|
|
36
21
|
}
|
|
37
22
|
async request(path, options = {}) {
|
|
38
23
|
const url = `${this.hubUrl}${path}`;
|
|
@@ -40,7 +25,6 @@ export class HubMatchClient {
|
|
|
40
25
|
...options,
|
|
41
26
|
headers: {
|
|
42
27
|
"Content-Type": "application/json",
|
|
43
|
-
"Authorization": `Bearer ${this.registration.token}`,
|
|
44
28
|
...(options.headers ?? {}),
|
|
45
29
|
},
|
|
46
30
|
});
|
|
@@ -50,12 +34,6 @@ export class HubMatchClient {
|
|
|
50
34
|
}
|
|
51
35
|
return res.json();
|
|
52
36
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Create a new match request.
|
|
55
|
-
* @param params.skills - Skills to search for in a provider
|
|
56
|
-
* @param params.description - Optional description of the match request
|
|
57
|
-
* @param params.token - Optional bearer token to include for this agent
|
|
58
|
-
*/
|
|
59
37
|
async createMatch(params) {
|
|
60
38
|
return this.request("/api/matches", {
|
|
61
39
|
method: "POST",
|
|
@@ -63,45 +41,57 @@ export class HubMatchClient {
|
|
|
63
41
|
agentId: this.registration.agentId,
|
|
64
42
|
requiredSkills: params.skills,
|
|
65
43
|
description: params.description ?? "",
|
|
66
|
-
token: params.token,
|
|
67
44
|
}),
|
|
68
45
|
});
|
|
69
46
|
}
|
|
70
|
-
/**
|
|
71
|
-
* Get a match result by ID.
|
|
72
|
-
* @param matchId - The match ID
|
|
73
|
-
* @param callerId - Optional agent ID to set callerId query param (affects yourToken)
|
|
74
|
-
*/
|
|
75
47
|
async getMatch(matchId, callerId) {
|
|
76
48
|
const path = callerId != null ? `/api/matches/${matchId}?callerId=${callerId}` : `/api/matches/${matchId}`;
|
|
77
49
|
return this.request(path);
|
|
78
50
|
}
|
|
79
|
-
/**
|
|
80
|
-
* Get all pending matches for this agent.
|
|
81
|
-
*/
|
|
82
51
|
async getPendingMatches() {
|
|
83
52
|
return this.request(`/api/matches/pending?agentId=${this.registration.agentId}`);
|
|
84
53
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
54
|
+
async updatePresence(presenceStatus, clientVersion = "claw-crony/1.2.4") {
|
|
55
|
+
return this.request(`/api/agents/${this.registration.agentId}/presence`, {
|
|
56
|
+
method: "PUT",
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
presenceStatus,
|
|
59
|
+
clientVersion,
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async sendHandshakeMessage(matchId, params) {
|
|
64
|
+
return this.request(`/api/matches/${matchId}/handshake`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
agentId: this.registration.agentId,
|
|
68
|
+
messageType: params.messageType,
|
|
69
|
+
ciphertext: params.ciphertext,
|
|
70
|
+
expiresAt: params.expiresAt,
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async getPendingHandshakeMessages(matchId) {
|
|
75
|
+
const result = await this.request(`/api/matches/${matchId}/handshake/pending?agentId=${this.registration.agentId}`);
|
|
76
|
+
return result.messages ?? [];
|
|
77
|
+
}
|
|
78
|
+
async consumeHandshakeMessage(matchId, messageId) {
|
|
79
|
+
return this.request(`/api/matches/${matchId}/handshake/${messageId}/consume`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
agentId: this.registration.agentId,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async markReady(matchId) {
|
|
87
|
+
return this.request(`/api/matches/${matchId}/ready`, {
|
|
92
88
|
method: "POST",
|
|
93
89
|
body: JSON.stringify({
|
|
94
90
|
agentId: this.registration.agentId,
|
|
95
|
-
token,
|
|
96
91
|
}),
|
|
97
92
|
});
|
|
98
93
|
}
|
|
99
|
-
|
|
100
|
-
* Mark a match as completed (both parties have submitted tokens).
|
|
101
|
-
* @param matchId - The match ID
|
|
102
|
-
* @param token - This agent's bearer token (for authorization)
|
|
103
|
-
*/
|
|
104
|
-
async completeMatch(matchId, token) {
|
|
94
|
+
async completeMatch(matchId) {
|
|
105
95
|
return this.request(`/api/matches/${matchId}/complete`, {
|
|
106
96
|
method: "POST",
|
|
107
97
|
body: JSON.stringify({
|
|
@@ -109,12 +99,7 @@ export class HubMatchClient {
|
|
|
109
99
|
}),
|
|
110
100
|
});
|
|
111
101
|
}
|
|
112
|
-
|
|
113
|
-
* Cancel a pending or token_exchange match.
|
|
114
|
-
* @param matchId - The match ID
|
|
115
|
-
* @param token - This agent's bearer token (for authorization)
|
|
116
|
-
*/
|
|
117
|
-
async cancelMatch(matchId, token) {
|
|
102
|
+
async cancelMatch(matchId) {
|
|
118
103
|
return this.request(`/api/matches/${matchId}/cancel`, {
|
|
119
104
|
method: "POST",
|
|
120
105
|
body: JSON.stringify({
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hub registration module for
|
|
2
|
+
* Hub registration module for claw-crony.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Registers the local plugin with the hub using client_id + public_key
|
|
5
|
+
* and persists the resulting agent binding locally.
|
|
6
6
|
*/
|
|
7
7
|
import type { GatewayConfig, HubConfig, HubRegistrationData, OpenClawPluginApi, RegistrationConfig } from "./types.js";
|
|
8
8
|
export declare function loadRegistration(configDir?: string): HubRegistrationData | null;
|
|
@@ -13,11 +13,4 @@ export interface HubRegistration {
|
|
|
13
13
|
address: string;
|
|
14
14
|
name: string;
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
17
|
-
* Run the full hub registration flow:
|
|
18
|
-
* 1. Load existing registration (if any)
|
|
19
|
-
* 2. Validate existing token with hub
|
|
20
|
-
* 3. If no valid registration, create new one (handling 409 conflicts)
|
|
21
|
-
* 4. Save registration file atomically
|
|
22
|
-
*/
|
|
23
16
|
export declare function runHubRegistration(api: OpenClawPluginApi, config: GatewayConfig, hubConfig: HubConfig, registrationConfig: RegistrationConfig): Promise<HubRegistration | null>;
|
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hub registration module for
|
|
2
|
+
* Hub registration module for claw-crony.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Registers the local plugin with the hub using client_id + public_key
|
|
5
|
+
* and persists the resulting agent binding locally.
|
|
6
6
|
*/
|
|
7
|
-
import crypto from "node:crypto";
|
|
8
7
|
import fs from "node:fs";
|
|
9
8
|
import os from "node:os";
|
|
10
9
|
import path from "node:path";
|
|
11
|
-
|
|
12
|
-
// Token generation
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
function generateToken() {
|
|
15
|
-
return crypto.randomBytes(32).toString("hex");
|
|
16
|
-
}
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Registration file path & I/O
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
10
|
+
import { loadOrCreateIdentity } from "./identity-store.js";
|
|
20
11
|
const REGISTRATION_FILENAME = "a2a-registration.json";
|
|
21
12
|
function getRegistrationPath(configDir) {
|
|
22
13
|
return path.join(configDir, REGISTRATION_FILENAME);
|
|
@@ -48,7 +39,6 @@ async function registerHubUser(hubUrl, agentId, username, password) {
|
|
|
48
39
|
body: JSON.stringify({ agentId, username, password }),
|
|
49
40
|
});
|
|
50
41
|
if (res.status === 409) {
|
|
51
|
-
// Already registered — this is fine, idempotent
|
|
52
42
|
return;
|
|
53
43
|
}
|
|
54
44
|
if (!res.ok) {
|
|
@@ -63,18 +53,14 @@ async function registerWithHub(hubUrl, payload) {
|
|
|
63
53
|
headers: { "Content-Type": "application/json" },
|
|
64
54
|
body: JSON.stringify(payload),
|
|
65
55
|
});
|
|
66
|
-
if (res.status === 409) {
|
|
67
|
-
const err = await res.json().catch(() => ({}));
|
|
68
|
-
throw Object.assign(new Error("Agent address already registered"), { status: 409, body: err });
|
|
69
|
-
}
|
|
70
56
|
if (!res.ok) {
|
|
71
57
|
const err = await res.json().catch(() => ({}));
|
|
72
58
|
throw Object.assign(new Error(`Hub rejected registration: ${JSON.stringify(err)}`), { status: res.status });
|
|
73
59
|
}
|
|
74
60
|
return res.json();
|
|
75
61
|
}
|
|
76
|
-
async function
|
|
77
|
-
const url = `${hubUrl.replace(/\/$/, "")}/api/agents?
|
|
62
|
+
async function lookupAgentByClientId(hubUrl, clientId) {
|
|
63
|
+
const url = `${hubUrl.replace(/\/$/, "")}/api/agents?clientId=${encodeURIComponent(clientId)}`;
|
|
78
64
|
const res = await fetch(url);
|
|
79
65
|
if (!res.ok) {
|
|
80
66
|
throw Object.assign(new Error(`Hub lookup failed: ${res.status}`), { status: res.status });
|
|
@@ -82,9 +68,6 @@ async function lookupAgentByAddress(hubUrl, address) {
|
|
|
82
68
|
const agents = await res.json();
|
|
83
69
|
return agents.length > 0 ? agents[0] : null;
|
|
84
70
|
}
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
// Retry with exponential backoff
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
71
|
async function retryWithBackoff(fn, maxRetries, baseDelayMs, maxDelayMs) {
|
|
89
72
|
let lastError;
|
|
90
73
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
@@ -101,106 +84,37 @@ async function retryWithBackoff(fn, maxRetries, baseDelayMs, maxDelayMs) {
|
|
|
101
84
|
}
|
|
102
85
|
throw lastError;
|
|
103
86
|
}
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Flatten skills from agent card config
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
87
|
function flattenSkills(skills) {
|
|
108
88
|
return skills.map((s) => (typeof s === "string" ? s : s.name));
|
|
109
89
|
}
|
|
110
|
-
/**
|
|
111
|
-
* Run the full hub registration flow:
|
|
112
|
-
* 1. Load existing registration (if any)
|
|
113
|
-
* 2. Validate existing token with hub
|
|
114
|
-
* 3. If no valid registration, create new one (handling 409 conflicts)
|
|
115
|
-
* 4. Save registration file atomically
|
|
116
|
-
*/
|
|
117
90
|
export async function runHubRegistration(api, config, hubConfig, registrationConfig) {
|
|
118
91
|
const configDir = getConfigDir();
|
|
119
92
|
const hubUrl = hubConfig.url;
|
|
120
|
-
|
|
121
|
-
// e.g. "http://100.10.10.1:18800/a2a/jsonrpc" -> "100.10.10.1:18800"
|
|
122
|
-
let address;
|
|
123
|
-
if (config.agentCard.url) {
|
|
124
|
-
try {
|
|
125
|
-
const urlObj = new URL(config.agentCard.url);
|
|
126
|
-
address = `${urlObj.hostname}:${urlObj.port || (urlObj.protocol === "https:" ? 443 : 80)}`;
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
address = `${config.server.host}:${config.server.port}`;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
address = `${config.server.host}:${config.server.port}`;
|
|
134
|
-
}
|
|
135
|
-
// Ensure config directory exists
|
|
93
|
+
const identity = loadOrCreateIdentity(registrationConfig.clientId);
|
|
136
94
|
if (!fs.existsSync(configDir)) {
|
|
137
95
|
fs.mkdirSync(configDir, { recursive: true });
|
|
138
96
|
}
|
|
139
|
-
// Load existing registration
|
|
140
97
|
const existing = loadRegistration(configDir);
|
|
141
|
-
|
|
142
|
-
|
|
98
|
+
if (existing &&
|
|
99
|
+
existing.hubUrl === hubUrl &&
|
|
100
|
+
existing.clientId === identity.clientId &&
|
|
101
|
+
existing.publicKey === identity.publicKey) {
|
|
143
102
|
try {
|
|
144
|
-
const agent = await
|
|
103
|
+
const agent = await lookupAgentByClientId(hubUrl, identity.clientId);
|
|
145
104
|
if (agent && agent.id === existing.agentId) {
|
|
146
105
|
api.logger.info(`claw-crony: using existing hub registration (agentId=${existing.agentId})`);
|
|
147
106
|
return {
|
|
148
107
|
agentId: existing.agentId,
|
|
149
|
-
token:
|
|
150
|
-
address:
|
|
108
|
+
token: "",
|
|
109
|
+
address: "",
|
|
151
110
|
name: existing.name,
|
|
152
111
|
};
|
|
153
112
|
}
|
|
154
113
|
}
|
|
155
114
|
catch {
|
|
156
|
-
|
|
157
|
-
api.logger.warn("claw-crony: existing registration invalid, trying with existing token");
|
|
158
|
-
const existingToken = existing.token;
|
|
159
|
-
try {
|
|
160
|
-
const existingPayload = {
|
|
161
|
-
name: config.agentCard.name,
|
|
162
|
-
description: config.agentCard.description ?? "",
|
|
163
|
-
skills: flattenSkills(config.agentCard.skills),
|
|
164
|
-
address,
|
|
165
|
-
token: existingToken,
|
|
166
|
-
username: registrationConfig.username ?? config.agentCard.name,
|
|
167
|
-
email: registrationConfig.email ?? "",
|
|
168
|
-
};
|
|
169
|
-
const agent = await retryWithBackoff(() => registerWithHub(hubUrl, existingPayload), 3, 1000, 10000);
|
|
170
|
-
api.logger.info(`claw-crony: re-registered with hub using existing token (agentId=${agent.id})`);
|
|
171
|
-
// Also register hub user if password is configured
|
|
172
|
-
if (registrationConfig.password) {
|
|
173
|
-
try {
|
|
174
|
-
await retryWithBackoff(() => registerHubUser(hubUrl, agent.id, registrationConfig.username ?? config.agentCard.name, registrationConfig.password), 3, 1000, 10000);
|
|
175
|
-
api.logger.info(`claw-crony: registered hub user for web login (agentId=${agent.id})`);
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
api.logger.warn(`claw-crony: hub user registration failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
// Re-save to update registration file
|
|
182
|
-
const updatedData = {
|
|
183
|
-
...existing,
|
|
184
|
-
registeredAt: new Date().toISOString(),
|
|
185
|
-
};
|
|
186
|
-
saveRegistration(configDir, updatedData);
|
|
187
|
-
return { agentId: agent.id, token: existingToken, address, name: config.agentCard.name };
|
|
188
|
-
}
|
|
189
|
-
catch (err) {
|
|
190
|
-
// Existing token also failed (409 means address conflict from elsewhere)
|
|
191
|
-
const status = typeof err === "object" && err !== null ? err.status : undefined;
|
|
192
|
-
if (status === 409) {
|
|
193
|
-
api.logger.warn("claw-crony: existing token rejected, generating new token");
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
api.logger.warn(`claw-crony: re-registration with existing token failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
197
|
-
}
|
|
198
|
-
// fall through to generate new token
|
|
199
|
-
}
|
|
115
|
+
api.logger.warn("claw-crony: existing registration not confirmed remotely, re-registering");
|
|
200
116
|
}
|
|
201
117
|
}
|
|
202
|
-
// Generate new token
|
|
203
|
-
const token = generateToken();
|
|
204
118
|
const name = config.agentCard.name;
|
|
205
119
|
const description = config.agentCard.description ?? "";
|
|
206
120
|
const skills = flattenSkills(config.agentCard.skills);
|
|
@@ -210,8 +124,10 @@ export async function runHubRegistration(api, config, hubConfig, registrationCon
|
|
|
210
124
|
name,
|
|
211
125
|
description,
|
|
212
126
|
skills,
|
|
213
|
-
|
|
214
|
-
|
|
127
|
+
clientId: identity.clientId,
|
|
128
|
+
publicKey: identity.publicKey,
|
|
129
|
+
keyVersion: identity.keyVersion,
|
|
130
|
+
clientVersion: "claw-crony/1.2.4",
|
|
215
131
|
username,
|
|
216
132
|
email,
|
|
217
133
|
};
|
|
@@ -220,15 +136,13 @@ export async function runHubRegistration(api, config, hubConfig, registrationCon
|
|
|
220
136
|
const agent = await retryWithBackoff(() => registerWithHub(hubUrl, payload), 3, 1000, 10000);
|
|
221
137
|
agentId = agent.id;
|
|
222
138
|
api.logger.info(`claw-crony: registered with hub (agentId=${agentId})`);
|
|
223
|
-
// Also register hub user for web dashboard login (if password provided)
|
|
224
139
|
if (registrationConfig.password) {
|
|
225
140
|
try {
|
|
226
141
|
await retryWithBackoff(() => registerHubUser(hubUrl, agentId, username, registrationConfig.password), 3, 1000, 10000);
|
|
227
142
|
api.logger.info(`claw-crony: registered hub user for web login (agentId=${agentId})`);
|
|
228
143
|
}
|
|
229
144
|
catch (err) {
|
|
230
|
-
api.logger.warn(`claw-crony: hub user registration failed
|
|
231
|
-
// Non-fatal — agent is registered, web login may already exist
|
|
145
|
+
api.logger.warn(`claw-crony: hub user registration failed - ${err instanceof Error ? err.message : String(err)}`);
|
|
232
146
|
}
|
|
233
147
|
}
|
|
234
148
|
else {
|
|
@@ -237,46 +151,33 @@ export async function runHubRegistration(api, config, hubConfig, registrationCon
|
|
|
237
151
|
}
|
|
238
152
|
}
|
|
239
153
|
catch (err) {
|
|
240
|
-
// 409 Conflict: address already registered by someone else — try to find our agentId
|
|
241
154
|
if (typeof err === "object" && err !== null && err.status === 409) {
|
|
242
155
|
try {
|
|
243
|
-
const existingAgent = await
|
|
244
|
-
if (existingAgent) {
|
|
245
|
-
|
|
246
|
-
api.logger.info(`claw-crony: found existing registration (agentId=${agentId})`);
|
|
247
|
-
// Also register hub user if password is configured
|
|
248
|
-
if (registrationConfig.password) {
|
|
249
|
-
try {
|
|
250
|
-
await retryWithBackoff(() => registerHubUser(hubUrl, agentId, registrationConfig.username ?? config.agentCard.name, registrationConfig.password), 3, 1000, 10000);
|
|
251
|
-
api.logger.info(`claw-crony: registered hub user for web login (agentId=${agentId})`);
|
|
252
|
-
}
|
|
253
|
-
catch (err) {
|
|
254
|
-
api.logger.warn(`claw-crony: hub user registration failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
else {
|
|
259
|
-
api.logger.error("claw-crony: address conflict but could not find existing agent");
|
|
156
|
+
const existingAgent = await lookupAgentByClientId(hubUrl, identity.clientId);
|
|
157
|
+
if (!existingAgent) {
|
|
158
|
+
api.logger.error("claw-crony: identity conflict but could not find existing agent");
|
|
260
159
|
return null;
|
|
261
160
|
}
|
|
161
|
+
agentId = existingAgent.id;
|
|
162
|
+
api.logger.info(`claw-crony: found existing registration (agentId=${agentId})`);
|
|
262
163
|
}
|
|
263
164
|
catch {
|
|
264
|
-
api.logger.error("claw-crony:
|
|
165
|
+
api.logger.error("claw-crony: identity conflict and hub lookup failed");
|
|
265
166
|
return null;
|
|
266
167
|
}
|
|
267
168
|
}
|
|
268
169
|
else {
|
|
269
|
-
api.logger.warn(`claw-crony: hub registration failed
|
|
170
|
+
api.logger.warn(`claw-crony: hub registration failed - ${err instanceof Error ? err.message : String(err)}`);
|
|
270
171
|
return null;
|
|
271
172
|
}
|
|
272
173
|
}
|
|
273
|
-
// Save registration file
|
|
274
174
|
const registrationData = {
|
|
275
|
-
version:
|
|
175
|
+
version: 2,
|
|
276
176
|
hubUrl,
|
|
277
177
|
agentId,
|
|
278
|
-
|
|
279
|
-
|
|
178
|
+
clientId: identity.clientId,
|
|
179
|
+
publicKey: identity.publicKey,
|
|
180
|
+
keyVersion: identity.keyVersion,
|
|
280
181
|
registeredAt: new Date().toISOString(),
|
|
281
182
|
name,
|
|
282
183
|
description,
|
|
@@ -286,7 +187,7 @@ export async function runHubRegistration(api, config, hubConfig, registrationCon
|
|
|
286
187
|
saveRegistration(configDir, registrationData);
|
|
287
188
|
}
|
|
288
189
|
catch (saveErr) {
|
|
289
|
-
api.logger.warn(`claw-crony: failed to save registration file
|
|
190
|
+
api.logger.warn(`claw-crony: failed to save registration file - ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`);
|
|
290
191
|
}
|
|
291
|
-
return { agentId, token, address, name };
|
|
192
|
+
return { agentId, token: "", address: "", name };
|
|
292
193
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { IdentityData } from "./types.js";
|
|
2
|
+
export declare function loadIdentity(configDir?: string): IdentityData | null;
|
|
3
|
+
export declare function saveIdentity(configDir: string, data: IdentityData): void;
|
|
4
|
+
export declare function loadOrCreateIdentity(clientId?: string): IdentityData;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const IDENTITY_FILENAME = "a2a-identity.json";
|
|
6
|
+
function getConfigDir() {
|
|
7
|
+
return path.join(os.homedir(), ".openclaw");
|
|
8
|
+
}
|
|
9
|
+
function getIdentityPath(configDir) {
|
|
10
|
+
return path.join(configDir, IDENTITY_FILENAME);
|
|
11
|
+
}
|
|
12
|
+
function randomClientId() {
|
|
13
|
+
return crypto.randomUUID();
|
|
14
|
+
}
|
|
15
|
+
export function loadIdentity(configDir) {
|
|
16
|
+
const identityPath = getIdentityPath(configDir ?? getConfigDir());
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(identityPath, "utf-8");
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function saveIdentity(configDir, data) {
|
|
26
|
+
const identityPath = getIdentityPath(configDir);
|
|
27
|
+
const tmpPath = `${identityPath}.tmp`;
|
|
28
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
|
|
29
|
+
fs.renameSync(tmpPath, identityPath);
|
|
30
|
+
}
|
|
31
|
+
export function loadOrCreateIdentity(clientId) {
|
|
32
|
+
const configDir = getConfigDir();
|
|
33
|
+
if (!fs.existsSync(configDir)) {
|
|
34
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
const existing = loadIdentity(configDir);
|
|
37
|
+
if (existing) {
|
|
38
|
+
if (clientId && existing.clientId !== clientId) {
|
|
39
|
+
existing.clientId = clientId;
|
|
40
|
+
saveIdentity(configDir, existing);
|
|
41
|
+
}
|
|
42
|
+
return existing;
|
|
43
|
+
}
|
|
44
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
|
45
|
+
const identity = {
|
|
46
|
+
version: 1,
|
|
47
|
+
clientId: clientId?.trim() || randomClientId(),
|
|
48
|
+
publicKey: publicKey.export({ format: "pem", type: "spki" }).toString(),
|
|
49
|
+
privateKey: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
|
50
|
+
keyVersion: 1,
|
|
51
|
+
createdAt: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
saveIdentity(configDir, identity);
|
|
54
|
+
return identity;
|
|
55
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -92,18 +92,56 @@ export interface RegistrationConfig {
|
|
|
92
92
|
email?: string;
|
|
93
93
|
password?: string;
|
|
94
94
|
skills?: string[];
|
|
95
|
+
clientId?: string;
|
|
95
96
|
}
|
|
96
97
|
export interface HubRegistrationData {
|
|
97
98
|
version: number;
|
|
98
99
|
hubUrl: string;
|
|
99
100
|
agentId: number;
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
clientId: string;
|
|
102
|
+
publicKey: string;
|
|
103
|
+
keyVersion: number;
|
|
102
104
|
registeredAt: string;
|
|
103
105
|
name: string;
|
|
104
106
|
description: string;
|
|
105
107
|
skills: string[];
|
|
106
108
|
}
|
|
109
|
+
export interface IdentityData {
|
|
110
|
+
version: number;
|
|
111
|
+
clientId: string;
|
|
112
|
+
publicKey: string;
|
|
113
|
+
privateKey: string;
|
|
114
|
+
keyVersion: number;
|
|
115
|
+
createdAt: string;
|
|
116
|
+
}
|
|
117
|
+
export interface EphemeralTokenRecord {
|
|
118
|
+
token: string;
|
|
119
|
+
matchId: number;
|
|
120
|
+
peerAgentId: number;
|
|
121
|
+
expiresAt: number;
|
|
122
|
+
}
|
|
123
|
+
export interface HandshakePayload {
|
|
124
|
+
version: number;
|
|
125
|
+
matchId: number;
|
|
126
|
+
sessionId: string;
|
|
127
|
+
fromAgentId: number;
|
|
128
|
+
toAgentId: number;
|
|
129
|
+
address: string;
|
|
130
|
+
agentCardPath: string;
|
|
131
|
+
token: string;
|
|
132
|
+
tokenExpiresAt: string;
|
|
133
|
+
protocols: string[];
|
|
134
|
+
createdAt: string;
|
|
135
|
+
nonce: string;
|
|
136
|
+
}
|
|
137
|
+
export interface EncryptedHandshakeMessage {
|
|
138
|
+
version: number;
|
|
139
|
+
algorithm: "x25519-aes-256-gcm";
|
|
140
|
+
senderPublicKey: string;
|
|
141
|
+
iv: string;
|
|
142
|
+
ciphertext: string;
|
|
143
|
+
authTag: string;
|
|
144
|
+
}
|
|
107
145
|
export interface HealthCheckConfig {
|
|
108
146
|
enabled: boolean;
|
|
109
147
|
intervalMs: number;
|