@clawcrony/claw-crony 1.2.4 → 1.3.0
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/CHANGELOG.md +107 -0
- package/CONFIG.md +341 -0
- package/README.md +147 -18
- package/dist/index.js +509 -164
- package/dist/src/ephemeral-token.d.ts +3 -1
- package/dist/src/ephemeral-token.js +7 -2
- package/dist/src/history.d.ts +44 -0
- package/dist/src/history.js +119 -0
- package/dist/src/hub-match.js +1 -1
- package/dist/src/hub-registration.js +1 -1
- package/dist/src/types.d.ts +4 -1
- package/openclaw.plugin.json +46 -18
- package/package.json +43 -27
- package/scripts/a2a-diagnose.ps1 +15 -0
- package/scripts/a2a-diagnose.sh +22 -0
- package/scripts/a2a-history.ps1 +21 -0
- package/scripts/a2a-history.sh +9 -0
- package/scripts/a2a-match.ps1 +16 -0
- package/scripts/a2a-match.sh +13 -0
- package/scripts/a2a-peers.ps1 +1 -0
- package/scripts/a2a-peers.sh +4 -0
- package/scripts/a2a-send.ps1 +23 -0
- package/scripts/a2a-send.sh +14 -0
- package/scripts/a2a-update.ps1 +3 -0
- package/scripts/a2a-update.sh +6 -0
package/dist/index.js
CHANGED
|
@@ -22,13 +22,14 @@ import { runTaskCleanup } from "./src/task-cleanup.js";
|
|
|
22
22
|
import { FileTaskStore } from "./src/task-store.js";
|
|
23
23
|
import { GatewayTelemetry } from "./src/telemetry.js";
|
|
24
24
|
import { AuditLogger } from "./src/audit.js";
|
|
25
|
+
import { RequestHistoryStore } from "./src/history.js";
|
|
25
26
|
import { PeerHealthManager } from "./src/peer-health.js";
|
|
26
27
|
import { runHubRegistration } from "./src/hub-registration.js";
|
|
27
28
|
import { HubMatchClient } from "./src/hub-match.js";
|
|
28
29
|
import { normalizeAgentCardSkills } from "./src/skill-catalog.js";
|
|
29
30
|
import { parseRoutingRules, matchRule } from "./src/routing-rules.js";
|
|
30
31
|
import { decryptHandshake, encryptHandshake } from "./src/handshake-crypto.js";
|
|
31
|
-
import { issueEphemeralInboundToken } from "./src/ephemeral-token.js";
|
|
32
|
+
import { isValidEphemeralInboundToken, issueEphemeralInboundToken } from "./src/ephemeral-token.js";
|
|
32
33
|
import { loadIdentity } from "./src/identity-store.js";
|
|
33
34
|
import { validateUri, validateMimeType, } from "./src/file-security.js";
|
|
34
35
|
/** Build a JSON-RPC error response. */
|
|
@@ -187,6 +188,9 @@ export function parseConfig(raw, resolvePath) {
|
|
|
187
188
|
metricsPath: normalizeHttpPath(asString(observability.metricsPath, "/a2a/metrics"), "/a2a/metrics"),
|
|
188
189
|
metricsAuth: (asString(observability.metricsAuth, "none") === "bearer" ? "bearer" : "none"),
|
|
189
190
|
auditLogPath: resolveConfiguredPath(observability.auditLogPath, path.join(os.homedir(), ".openclaw", "a2a-audit.jsonl"), resolvePath),
|
|
191
|
+
historyEnabled: asBoolean(observability.historyEnabled, true),
|
|
192
|
+
historyLogPath: resolveConfiguredPath(observability.historyLogPath, path.join(os.homedir(), ".openclaw", "a2a-history.jsonl"), resolvePath),
|
|
193
|
+
historyIncludeEncryptedPayloads: asBoolean(observability.historyIncludeEncryptedPayloads, false),
|
|
190
194
|
},
|
|
191
195
|
timeouts: {
|
|
192
196
|
agentResponseTimeoutMs: asNumber(timeouts.agentResponseTimeoutMs, 300_000),
|
|
@@ -270,7 +274,16 @@ async function waitForHandshakeAnswer(hubClient, matchId, timeoutMs = 45_000) {
|
|
|
270
274
|
}
|
|
271
275
|
throw new Error(`Timed out waiting for handshake answer for match ${matchId}`);
|
|
272
276
|
}
|
|
273
|
-
|
|
277
|
+
function getHandshakeTokenValidationError(token) {
|
|
278
|
+
if (isValidEphemeralInboundToken(token)) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const tokenShape = typeof token === "string"
|
|
282
|
+
? `length ${Array.from(token).length}${token.includes("\u2026") ? ", contains U+2026" : ""}`
|
|
283
|
+
: `type ${typeof token}`;
|
|
284
|
+
return `invalid ephemeral handshake token: expected 48 lowercase hex characters, got ${tokenShape}`;
|
|
285
|
+
}
|
|
286
|
+
async function processPendingHubMatches(api, config, processedMessages, historyStore) {
|
|
274
287
|
let hubClient;
|
|
275
288
|
try {
|
|
276
289
|
hubClient = await HubMatchClient.create();
|
|
@@ -303,12 +316,52 @@ async function processPendingHubMatches(api, config, processedMessages) {
|
|
|
303
316
|
continue;
|
|
304
317
|
}
|
|
305
318
|
const decrypted = decryptHandshake(message.ciphertext, identity);
|
|
319
|
+
historyStore?.record({
|
|
320
|
+
type: message.messageType === "answer" ? "handshake.answer_received" : "handshake.offer_received",
|
|
321
|
+
status: "started",
|
|
322
|
+
direction: "inbound",
|
|
323
|
+
matchId: match.id,
|
|
324
|
+
messageId: message.id,
|
|
325
|
+
peer: match.requester?.name ?? `agent-${message.senderAgentId}`,
|
|
326
|
+
detail: {
|
|
327
|
+
messageType: message.messageType,
|
|
328
|
+
senderAgentId: message.senderAgentId,
|
|
329
|
+
receiverAgentId: message.receiverAgentId,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
const tokenValidationError = getHandshakeTokenValidationError(decrypted.token);
|
|
333
|
+
if (tokenValidationError) {
|
|
334
|
+
await hubClient.consumeHandshakeMessage(match.id, message.id);
|
|
335
|
+
processedMessages.add(message.id);
|
|
336
|
+
historyStore?.record({
|
|
337
|
+
type: "handshake.failed",
|
|
338
|
+
status: "ignored",
|
|
339
|
+
direction: "inbound",
|
|
340
|
+
matchId: match.id,
|
|
341
|
+
messageId: message.id,
|
|
342
|
+
peer: match.requester?.name ?? `agent-${message.senderAgentId}`,
|
|
343
|
+
detail: { reason: tokenValidationError },
|
|
344
|
+
});
|
|
345
|
+
api.logger.warn(`claw-crony: ignored malformed handshake ${message.id} for match ${match.id} - ${tokenValidationError}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
306
348
|
await hubClient.consumeHandshakeMessage(match.id, message.id);
|
|
307
349
|
processedMessages.add(message.id);
|
|
308
350
|
const remoteName = match.callerRole === "provider"
|
|
309
351
|
? (match.requester?.name ?? `agent-${decrypted.fromAgentId}`)
|
|
310
352
|
: (match.provider?.name ?? `agent-${decrypted.fromAgentId}`);
|
|
311
353
|
upsertEphemeralPeer(config, remoteName, decrypted.address, decrypted.token);
|
|
354
|
+
historyStore?.record({
|
|
355
|
+
type: "peer.upserted",
|
|
356
|
+
status: "success",
|
|
357
|
+
direction: "local",
|
|
358
|
+
matchId: match.id,
|
|
359
|
+
peer: remoteName,
|
|
360
|
+
detail: {
|
|
361
|
+
agentCardUrl: config.peers.find((peer) => peer.name === remoteName)?.agentCardUrl,
|
|
362
|
+
tokenExpiresAt: decrypted.tokenExpiresAt,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
312
365
|
if (message.messageType === "offer" && match.callerRole === "provider") {
|
|
313
366
|
const localAddress = getAdvertisedAddress(config);
|
|
314
367
|
if (!localAddress) {
|
|
@@ -340,6 +393,17 @@ async function processPendingHubMatches(api, config, processedMessages) {
|
|
|
340
393
|
ciphertext: encryptHandshake(payload, peerPublicKey),
|
|
341
394
|
expiresAt: issued.expiresAt,
|
|
342
395
|
});
|
|
396
|
+
historyStore?.record({
|
|
397
|
+
type: "handshake.answer_sent",
|
|
398
|
+
status: "success",
|
|
399
|
+
direction: "outbound",
|
|
400
|
+
matchId: match.id,
|
|
401
|
+
peer: remoteName,
|
|
402
|
+
detail: {
|
|
403
|
+
toAgentId: decrypted.fromAgentId,
|
|
404
|
+
expiresAt: issued.expiresAt,
|
|
405
|
+
},
|
|
406
|
+
});
|
|
343
407
|
await hubClient.markReady(match.id);
|
|
344
408
|
api.logger.info(`claw-crony: answered encrypted handshake for match ${match.id}`);
|
|
345
409
|
}
|
|
@@ -364,6 +428,10 @@ const plugin = {
|
|
|
364
428
|
structuredLogs: config.observability.structuredLogs,
|
|
365
429
|
});
|
|
366
430
|
const auditLogger = new AuditLogger(config.observability.auditLogPath);
|
|
431
|
+
const historyStore = new RequestHistoryStore(config.observability.historyLogPath, {
|
|
432
|
+
enabled: config.observability.historyEnabled,
|
|
433
|
+
includeEncryptedPayloads: config.observability.historyIncludeEncryptedPayloads,
|
|
434
|
+
});
|
|
367
435
|
const client = new A2AClient();
|
|
368
436
|
const taskStore = new FileTaskStore(config.storage.tasksDir);
|
|
369
437
|
const executor = new QueueingAgentExecutor(new OpenClawAgentExecutor(api, config), telemetry, config.limits);
|
|
@@ -400,6 +468,13 @@ const plugin = {
|
|
|
400
468
|
// Wire audit logger for inbound task completion
|
|
401
469
|
telemetry.setTaskAuditCallback((taskId, contextId, state, durationMs) => {
|
|
402
470
|
auditLogger.recordInbound(taskId, contextId, state, durationMs);
|
|
471
|
+
historyStore.record({
|
|
472
|
+
type: state === "completed" ? "task.inbound_completed" : "task.inbound_failed",
|
|
473
|
+
status: state === "completed" ? "success" : "failure",
|
|
474
|
+
direction: "inbound",
|
|
475
|
+
durationMs,
|
|
476
|
+
detail: { taskId, contextId, state },
|
|
477
|
+
});
|
|
403
478
|
});
|
|
404
479
|
// SDK expects userBuilder(req) -> Promise<User>
|
|
405
480
|
// When bearer auth is configured, validate the Authorization header.
|
|
@@ -477,6 +552,304 @@ const plugin = {
|
|
|
477
552
|
let hubMatchPollingTimer = null;
|
|
478
553
|
const grpcPort = config.server.port + 1;
|
|
479
554
|
const processedHubMessages = new Set();
|
|
555
|
+
let hubStartupPromise = null;
|
|
556
|
+
let hubShutdownPromise = null;
|
|
557
|
+
const startHubLifecycle = (source) => {
|
|
558
|
+
if (config.hub?.enabled === false) {
|
|
559
|
+
return Promise.resolve();
|
|
560
|
+
}
|
|
561
|
+
if (!hubStartupPromise) {
|
|
562
|
+
hubStartupPromise = (async () => {
|
|
563
|
+
if (config.hub?.registrationEnabled !== false) {
|
|
564
|
+
try {
|
|
565
|
+
const reg = await runHubRegistration(api, config, config.hub, config.registration ?? {});
|
|
566
|
+
if (reg) {
|
|
567
|
+
api.logger.info(`claw-crony: registered with hub (agentId=${reg.agentId}, source=${source})`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
api.logger.warn(`claw-crony: hub registration failed - ${err instanceof Error ? err.message : String(err)}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const hubClient = await HubMatchClient.create();
|
|
576
|
+
await hubClient.updatePresence("online");
|
|
577
|
+
}
|
|
578
|
+
catch (presenceErr) {
|
|
579
|
+
api.logger.warn(`claw-crony: failed to update hub presence - ${presenceErr instanceof Error ? presenceErr.message : String(presenceErr)}`);
|
|
580
|
+
}
|
|
581
|
+
})();
|
|
582
|
+
}
|
|
583
|
+
return hubStartupPromise;
|
|
584
|
+
};
|
|
585
|
+
const stopHubLifecycle = (source) => {
|
|
586
|
+
if (config.hub?.enabled === false) {
|
|
587
|
+
return Promise.resolve();
|
|
588
|
+
}
|
|
589
|
+
if (!hubShutdownPromise) {
|
|
590
|
+
hubShutdownPromise = (async () => {
|
|
591
|
+
try {
|
|
592
|
+
const hubClient = await HubMatchClient.create();
|
|
593
|
+
await hubClient.updatePresence("offline");
|
|
594
|
+
api.logger.info(`claw-crony: hub presence set offline (source=${source})`);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// Ignore best-effort presence shutdown failure.
|
|
598
|
+
}
|
|
599
|
+
})();
|
|
600
|
+
}
|
|
601
|
+
return hubShutdownPromise;
|
|
602
|
+
};
|
|
603
|
+
api.on("gateway_start", async () => {
|
|
604
|
+
await startHubLifecycle("gateway_start");
|
|
605
|
+
}, { priority: 50 });
|
|
606
|
+
api.on("gateway_stop", async () => {
|
|
607
|
+
await stopHubLifecycle("gateway_stop");
|
|
608
|
+
}, { priority: 50 });
|
|
609
|
+
const performMatchRequest = async (input) => {
|
|
610
|
+
const startedAt = Date.now();
|
|
611
|
+
let hubClient;
|
|
612
|
+
try {
|
|
613
|
+
hubClient = await HubMatchClient.create();
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
617
|
+
historyStore.record({
|
|
618
|
+
type: "match.failed",
|
|
619
|
+
status: "failure",
|
|
620
|
+
direction: "outbound",
|
|
621
|
+
durationMs: Date.now() - startedAt,
|
|
622
|
+
detail: { reason: msg },
|
|
623
|
+
});
|
|
624
|
+
return {
|
|
625
|
+
ok: false,
|
|
626
|
+
text: `Not registered with hub: ${msg}`,
|
|
627
|
+
details: { ok: false, error: msg },
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const identity = loadIdentity();
|
|
631
|
+
if (!identity) {
|
|
632
|
+
historyStore.record({
|
|
633
|
+
type: "match.failed",
|
|
634
|
+
status: "failure",
|
|
635
|
+
direction: "outbound",
|
|
636
|
+
durationMs: Date.now() - startedAt,
|
|
637
|
+
detail: { reason: "identity_missing" },
|
|
638
|
+
});
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
text: "No local identity found. Restart the plugin and try again.",
|
|
642
|
+
details: { ok: false, error: "identity_missing" },
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
const localAddress = getAdvertisedAddress(config);
|
|
646
|
+
if (!localAddress) {
|
|
647
|
+
historyStore.record({
|
|
648
|
+
type: "match.failed",
|
|
649
|
+
status: "failure",
|
|
650
|
+
direction: "outbound",
|
|
651
|
+
durationMs: Date.now() - startedAt,
|
|
652
|
+
detail: { reason: "address_missing" },
|
|
653
|
+
});
|
|
654
|
+
return {
|
|
655
|
+
ok: false,
|
|
656
|
+
text: "No advertised address is configured for this agent.",
|
|
657
|
+
details: { ok: false, error: "address_missing" },
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
let match;
|
|
661
|
+
try {
|
|
662
|
+
match = await hubClient.createMatch({
|
|
663
|
+
skills: input.skills,
|
|
664
|
+
description: input.description,
|
|
665
|
+
});
|
|
666
|
+
historyStore.record({
|
|
667
|
+
type: "match.created",
|
|
668
|
+
status: "success",
|
|
669
|
+
direction: "outbound",
|
|
670
|
+
matchId: match.id,
|
|
671
|
+
peer: match.provider?.name,
|
|
672
|
+
detail: {
|
|
673
|
+
skills: input.skills,
|
|
674
|
+
description: input.description,
|
|
675
|
+
providerAgentId: match.provider?.id,
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
catch (err) {
|
|
680
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
681
|
+
historyStore.record({
|
|
682
|
+
type: "match.failed",
|
|
683
|
+
status: "failure",
|
|
684
|
+
direction: "outbound",
|
|
685
|
+
durationMs: Date.now() - startedAt,
|
|
686
|
+
detail: { reason: msg, skills: input.skills },
|
|
687
|
+
});
|
|
688
|
+
return {
|
|
689
|
+
ok: false,
|
|
690
|
+
text: `Failed to create match: ${msg}`,
|
|
691
|
+
details: { ok: false, error: msg },
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
const provider = match.provider;
|
|
695
|
+
const providerPublicKey = provider?.publicKey;
|
|
696
|
+
if (!provider || !providerPublicKey) {
|
|
697
|
+
historyStore.record({
|
|
698
|
+
type: "handshake.failed",
|
|
699
|
+
status: "failure",
|
|
700
|
+
direction: "outbound",
|
|
701
|
+
matchId: match.id,
|
|
702
|
+
peer: provider?.name,
|
|
703
|
+
detail: { reason: "provider_public_key_missing" },
|
|
704
|
+
});
|
|
705
|
+
return {
|
|
706
|
+
ok: false,
|
|
707
|
+
text: `Match created (id=${match.id}) but provider public key is missing`,
|
|
708
|
+
details: { ok: false, matchId: match.id, error: "provider_public_key_missing" },
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
const issued = issueEphemeralInboundToken(config, match.id, provider.id);
|
|
712
|
+
const localPayload = {
|
|
713
|
+
version: 1,
|
|
714
|
+
matchId: match.id,
|
|
715
|
+
sessionId: crypto.randomUUID(),
|
|
716
|
+
fromAgentId: hubClient.agentId,
|
|
717
|
+
toAgentId: provider.id,
|
|
718
|
+
address: localAddress,
|
|
719
|
+
agentCardPath: "/.well-known/agent.json",
|
|
720
|
+
token: issued.token,
|
|
721
|
+
tokenExpiresAt: issued.expiresAt,
|
|
722
|
+
protocols: ["jsonrpc", "rest", "grpc"],
|
|
723
|
+
createdAt: new Date().toISOString(),
|
|
724
|
+
nonce: crypto.randomBytes(12).toString("hex"),
|
|
725
|
+
};
|
|
726
|
+
try {
|
|
727
|
+
await hubClient.sendHandshakeMessage(match.id, {
|
|
728
|
+
messageType: "offer",
|
|
729
|
+
ciphertext: encryptHandshake(localPayload, providerPublicKey),
|
|
730
|
+
expiresAt: issued.expiresAt,
|
|
731
|
+
});
|
|
732
|
+
historyStore.record({
|
|
733
|
+
type: "handshake.offer_sent",
|
|
734
|
+
status: "success",
|
|
735
|
+
direction: "outbound",
|
|
736
|
+
matchId: match.id,
|
|
737
|
+
peer: provider.name,
|
|
738
|
+
detail: {
|
|
739
|
+
toAgentId: provider.id,
|
|
740
|
+
address: localAddress,
|
|
741
|
+
expiresAt: issued.expiresAt,
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
747
|
+
historyStore.record({
|
|
748
|
+
type: "handshake.failed",
|
|
749
|
+
status: "failure",
|
|
750
|
+
direction: "outbound",
|
|
751
|
+
matchId: match.id,
|
|
752
|
+
peer: provider.name,
|
|
753
|
+
durationMs: Date.now() - startedAt,
|
|
754
|
+
detail: { reason: msg, phase: "offer" },
|
|
755
|
+
});
|
|
756
|
+
return {
|
|
757
|
+
ok: false,
|
|
758
|
+
text: `Match created (id=${match.id}) but failed to send encrypted handshake offer: ${msg}`,
|
|
759
|
+
details: { ok: false, matchId: match.id, error: msg },
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
let answer;
|
|
763
|
+
try {
|
|
764
|
+
answer = await waitForHandshakeAnswer(hubClient, match.id);
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
768
|
+
historyStore.record({
|
|
769
|
+
type: "handshake.failed",
|
|
770
|
+
status: "failure",
|
|
771
|
+
direction: "inbound",
|
|
772
|
+
matchId: match.id,
|
|
773
|
+
peer: provider.name,
|
|
774
|
+
durationMs: Date.now() - startedAt,
|
|
775
|
+
detail: { reason: msg, phase: "wait_answer" },
|
|
776
|
+
});
|
|
777
|
+
return {
|
|
778
|
+
ok: false,
|
|
779
|
+
text: `Encrypted handshake offer sent for match ${match.id}, but waiting for answer failed: ${msg}`,
|
|
780
|
+
details: { ok: false, matchId: match.id, error: msg },
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
let remotePayload;
|
|
784
|
+
try {
|
|
785
|
+
remotePayload = decryptHandshake(answer.ciphertext, identity);
|
|
786
|
+
const tokenValidationError = getHandshakeTokenValidationError(remotePayload.token);
|
|
787
|
+
if (tokenValidationError) {
|
|
788
|
+
throw new Error(tokenValidationError);
|
|
789
|
+
}
|
|
790
|
+
await hubClient.consumeHandshakeMessage(match.id, answer.id);
|
|
791
|
+
historyStore.record({
|
|
792
|
+
type: "handshake.answer_received",
|
|
793
|
+
status: "success",
|
|
794
|
+
direction: "inbound",
|
|
795
|
+
matchId: match.id,
|
|
796
|
+
messageId: answer.id,
|
|
797
|
+
peer: provider.name,
|
|
798
|
+
detail: {
|
|
799
|
+
fromAgentId: answer.senderAgentId,
|
|
800
|
+
address: remotePayload.address,
|
|
801
|
+
tokenExpiresAt: remotePayload.tokenExpiresAt,
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
upsertEphemeralPeer(config, provider.name, remotePayload.address, remotePayload.token);
|
|
805
|
+
historyStore.record({
|
|
806
|
+
type: "peer.upserted",
|
|
807
|
+
status: "success",
|
|
808
|
+
direction: "local",
|
|
809
|
+
matchId: match.id,
|
|
810
|
+
peer: provider.name,
|
|
811
|
+
detail: {
|
|
812
|
+
agentCardUrl: config.peers.find((peer) => peer.name === provider.name)?.agentCardUrl,
|
|
813
|
+
tokenExpiresAt: remotePayload.tokenExpiresAt,
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
await hubClient.markReady(match.id);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
820
|
+
historyStore.record({
|
|
821
|
+
type: "handshake.failed",
|
|
822
|
+
status: "failure",
|
|
823
|
+
direction: "inbound",
|
|
824
|
+
matchId: match.id,
|
|
825
|
+
messageId: answer.id,
|
|
826
|
+
peer: provider.name,
|
|
827
|
+
durationMs: Date.now() - startedAt,
|
|
828
|
+
detail: { reason: msg, phase: "process_answer" },
|
|
829
|
+
});
|
|
830
|
+
return {
|
|
831
|
+
ok: false,
|
|
832
|
+
text: `Encrypted handshake answer received for match ${match.id}, but processing failed: ${msg}`,
|
|
833
|
+
details: { ok: false, matchId: match.id, error: msg },
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
ok: true,
|
|
838
|
+
text: `Encrypted handshake ready: match=${match.id}\n` +
|
|
839
|
+
`Provider: ${provider.name}\n` +
|
|
840
|
+
`Address: ${remotePayload.address}\n` +
|
|
841
|
+
`Temporary token: ${remotePayload.token}\n` +
|
|
842
|
+
`Token expires at: ${remotePayload.tokenExpiresAt}`,
|
|
843
|
+
details: {
|
|
844
|
+
ok: true,
|
|
845
|
+
matchId: match.id,
|
|
846
|
+
status: "ready_to_connect",
|
|
847
|
+
providerAddress: remotePayload.address,
|
|
848
|
+
peerToken: remotePayload.token,
|
|
849
|
+
tokenExpiresAt: remotePayload.tokenExpiresAt,
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
};
|
|
480
853
|
api.registerGatewayMethod("a2a.metrics", ({ respond }) => {
|
|
481
854
|
respond(true, {
|
|
482
855
|
metrics: telemetry.snapshot(),
|
|
@@ -490,6 +863,49 @@ const plugin = {
|
|
|
490
863
|
.then((entries) => respond(true, { entries, count: entries.length }))
|
|
491
864
|
.catch((error) => respond(false, { error: String(error?.message || error) }));
|
|
492
865
|
});
|
|
866
|
+
api.registerGatewayMethod("a2a.history", ({ params, respond }) => {
|
|
867
|
+
const payload = asObject(params);
|
|
868
|
+
const matchId = asNumber(payload.matchId, Number.NaN);
|
|
869
|
+
historyStore
|
|
870
|
+
.tail({
|
|
871
|
+
count: Math.min(Math.max(1, asNumber(payload.count ?? payload.limit, 50)), 500),
|
|
872
|
+
type: asString(payload.type, ""),
|
|
873
|
+
status: asString(payload.status, ""),
|
|
874
|
+
direction: asString(payload.direction, ""),
|
|
875
|
+
matchId: Number.isFinite(matchId) ? matchId : undefined,
|
|
876
|
+
peer: asString(payload.peer, ""),
|
|
877
|
+
})
|
|
878
|
+
.then((entries) => respond(true, { entries, count: entries.length }))
|
|
879
|
+
.catch((error) => respond(false, { error: String(error?.message || error) }));
|
|
880
|
+
});
|
|
881
|
+
api.registerGatewayMethod("a2a.peers", ({ respond }) => {
|
|
882
|
+
const peerStates = healthManager?.getAllStates() ?? new Map();
|
|
883
|
+
respond(true, {
|
|
884
|
+
peers: config.peers.map((peer) => ({
|
|
885
|
+
name: peer.name,
|
|
886
|
+
agentCardUrl: peer.agentCardUrl,
|
|
887
|
+
authType: peer.auth?.type,
|
|
888
|
+
hasToken: Boolean(peer.auth?.token),
|
|
889
|
+
health: peerStates.get(peer.name) ?? null,
|
|
890
|
+
})),
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
api.registerGatewayMethod("a2a.match", ({ params, respond }) => {
|
|
894
|
+
const payload = asObject(params);
|
|
895
|
+
const skills = Array.isArray(payload.skills)
|
|
896
|
+
? payload.skills.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
897
|
+
: [];
|
|
898
|
+
if (skills.length === 0) {
|
|
899
|
+
respond(false, { error: "skills must be a non-empty string array" });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
performMatchRequest({
|
|
903
|
+
skills,
|
|
904
|
+
description: asString(payload.description, ""),
|
|
905
|
+
})
|
|
906
|
+
.then((result) => respond(result.ok, result.details))
|
|
907
|
+
.catch((error) => respond(false, { error: String(error?.message || error) }));
|
|
908
|
+
});
|
|
493
909
|
api.registerGatewayMethod("a2a.send", ({ params, respond }) => {
|
|
494
910
|
const payload = asObject(params);
|
|
495
911
|
const peerName = asString(payload.peer || payload.name, "");
|
|
@@ -518,6 +934,16 @@ const plugin = {
|
|
|
518
934
|
message.agentId = resolvedAgentId;
|
|
519
935
|
}
|
|
520
936
|
const startedAt = Date.now();
|
|
937
|
+
historyStore.record({
|
|
938
|
+
type: "send.started",
|
|
939
|
+
status: "started",
|
|
940
|
+
direction: "outbound",
|
|
941
|
+
peer: peer.name,
|
|
942
|
+
detail: {
|
|
943
|
+
resolvedAgentId: message.agentId,
|
|
944
|
+
hasParts: Array.isArray(message.parts),
|
|
945
|
+
},
|
|
946
|
+
});
|
|
521
947
|
const sendOptions = {
|
|
522
948
|
healthManager: healthManager ?? undefined,
|
|
523
949
|
retryConfig: config.resilience.retry,
|
|
@@ -534,6 +960,17 @@ const plugin = {
|
|
|
534
960
|
const outDuration = Date.now() - startedAt;
|
|
535
961
|
telemetry.recordOutboundRequest(peer.name, result.ok, result.statusCode, outDuration);
|
|
536
962
|
auditLogger.recordOutbound(peer.name, result.ok, result.statusCode, outDuration);
|
|
963
|
+
historyStore.record({
|
|
964
|
+
type: result.ok ? "send.completed" : "send.failed",
|
|
965
|
+
status: result.ok ? "success" : "failure",
|
|
966
|
+
direction: "outbound",
|
|
967
|
+
peer: peer.name,
|
|
968
|
+
durationMs: outDuration,
|
|
969
|
+
detail: {
|
|
970
|
+
statusCode: result.statusCode,
|
|
971
|
+
response: result.ok ? undefined : result.response,
|
|
972
|
+
},
|
|
973
|
+
});
|
|
537
974
|
if (result.ok) {
|
|
538
975
|
respond(true, {
|
|
539
976
|
statusCode: result.statusCode,
|
|
@@ -550,6 +987,14 @@ const plugin = {
|
|
|
550
987
|
const errDuration = Date.now() - startedAt;
|
|
551
988
|
telemetry.recordOutboundRequest(peer.name, false, 500, errDuration);
|
|
552
989
|
auditLogger.recordOutbound(peer.name, false, 500, errDuration);
|
|
990
|
+
historyStore.record({
|
|
991
|
+
type: "send.failed",
|
|
992
|
+
status: "failure",
|
|
993
|
+
direction: "outbound",
|
|
994
|
+
peer: peer.name,
|
|
995
|
+
durationMs: errDuration,
|
|
996
|
+
detail: { error: String(error?.message || error) },
|
|
997
|
+
});
|
|
553
998
|
respond(false, { error: String(error?.message || error) });
|
|
554
999
|
});
|
|
555
1000
|
});
|
|
@@ -577,64 +1022,103 @@ const plugin = {
|
|
|
577
1022
|
label: "A2A Send File",
|
|
578
1023
|
parameters: sendFileParams,
|
|
579
1024
|
async execute(toolCallId, params) {
|
|
580
|
-
const
|
|
1025
|
+
const input = params;
|
|
1026
|
+
const peer = config.peers.find((p) => p.name === input.peer);
|
|
581
1027
|
if (!peer) {
|
|
582
1028
|
const available = config.peers.map((p) => p.name).join(", ") || "(none)";
|
|
583
1029
|
return {
|
|
584
|
-
content: [{ type: "text", text: `Peer not found: "${
|
|
1030
|
+
content: [{ type: "text", text: `Peer not found: "${input.peer}". Available peers: ${available}` }],
|
|
585
1031
|
details: { ok: false },
|
|
586
1032
|
};
|
|
587
1033
|
}
|
|
588
1034
|
// Security checks: SSRF, MIME, file size
|
|
589
|
-
const uriCheck = await validateUri(
|
|
1035
|
+
const uriCheck = await validateUri(input.uri, config.security);
|
|
590
1036
|
if (!uriCheck.ok) {
|
|
591
1037
|
return {
|
|
592
1038
|
content: [{ type: "text", text: `URI rejected: ${uriCheck.reason}` }],
|
|
593
1039
|
details: { ok: false, reason: uriCheck.reason },
|
|
594
1040
|
};
|
|
595
1041
|
}
|
|
596
|
-
if (
|
|
1042
|
+
if (input.mimeType && !validateMimeType(input.mimeType, config.security.allowedMimeTypes)) {
|
|
597
1043
|
return {
|
|
598
|
-
content: [{ type: "text", text: `MIME type rejected: "${
|
|
1044
|
+
content: [{ type: "text", text: `MIME type rejected: "${input.mimeType}" is not in the allowed list` }],
|
|
599
1045
|
details: { ok: false },
|
|
600
1046
|
};
|
|
601
1047
|
}
|
|
602
1048
|
const parts = [];
|
|
603
|
-
if (
|
|
604
|
-
parts.push({ kind: "text", text:
|
|
1049
|
+
if (input.text) {
|
|
1050
|
+
parts.push({ kind: "text", text: input.text });
|
|
605
1051
|
}
|
|
606
1052
|
parts.push({
|
|
607
1053
|
kind: "file",
|
|
608
1054
|
file: {
|
|
609
|
-
uri:
|
|
610
|
-
...(
|
|
611
|
-
...(
|
|
1055
|
+
uri: input.uri,
|
|
1056
|
+
...(input.name ? { name: input.name } : {}),
|
|
1057
|
+
...(input.mimeType ? { mimeType: input.mimeType } : {}),
|
|
612
1058
|
},
|
|
613
1059
|
});
|
|
614
1060
|
try {
|
|
615
1061
|
const message = { parts };
|
|
616
|
-
if (
|
|
617
|
-
message.agentId =
|
|
1062
|
+
if (input.agentId) {
|
|
1063
|
+
message.agentId = input.agentId;
|
|
618
1064
|
}
|
|
1065
|
+
const startedAt = Date.now();
|
|
1066
|
+
historyStore.record({
|
|
1067
|
+
type: "send_file.started",
|
|
1068
|
+
status: "started",
|
|
1069
|
+
direction: "outbound",
|
|
1070
|
+
peer: peer.name,
|
|
1071
|
+
detail: {
|
|
1072
|
+
uri: input.uri,
|
|
1073
|
+
name: input.name,
|
|
1074
|
+
mimeType: input.mimeType,
|
|
1075
|
+
agentId: input.agentId,
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
619
1078
|
const result = await client.sendMessage(peer, message, {
|
|
620
1079
|
healthManager: healthManager ?? undefined,
|
|
621
1080
|
retryConfig: config.resilience.retry,
|
|
622
1081
|
});
|
|
1082
|
+
historyStore.record({
|
|
1083
|
+
type: result.ok ? "send_file.completed" : "send_file.failed",
|
|
1084
|
+
status: result.ok ? "success" : "failure",
|
|
1085
|
+
direction: "outbound",
|
|
1086
|
+
peer: peer.name,
|
|
1087
|
+
durationMs: Date.now() - startedAt,
|
|
1088
|
+
detail: {
|
|
1089
|
+
uri: input.uri,
|
|
1090
|
+
name: input.name,
|
|
1091
|
+
mimeType: input.mimeType,
|
|
1092
|
+
response: result.ok ? undefined : result.response,
|
|
1093
|
+
},
|
|
1094
|
+
});
|
|
623
1095
|
if (result.ok) {
|
|
624
1096
|
return {
|
|
625
|
-
content: [{ type: "text", text: `File sent to ${
|
|
1097
|
+
content: [{ type: "text", text: `File sent to ${input.peer} via A2A.\nURI: ${input.uri}\nResponse: ${JSON.stringify(result.response)}` }],
|
|
626
1098
|
details: { ok: true, response: result.response },
|
|
627
1099
|
};
|
|
628
1100
|
}
|
|
629
1101
|
return {
|
|
630
|
-
content: [{ type: "text", text: `Failed to send file to ${
|
|
1102
|
+
content: [{ type: "text", text: `Failed to send file to ${input.peer}: ${JSON.stringify(result.response)}` }],
|
|
631
1103
|
details: { ok: false, response: result.response },
|
|
632
1104
|
};
|
|
633
1105
|
}
|
|
634
1106
|
catch (err) {
|
|
635
1107
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1108
|
+
historyStore.record({
|
|
1109
|
+
type: "send_file.failed",
|
|
1110
|
+
status: "failure",
|
|
1111
|
+
direction: "outbound",
|
|
1112
|
+
peer: peer.name,
|
|
1113
|
+
detail: {
|
|
1114
|
+
uri: input.uri,
|
|
1115
|
+
name: input.name,
|
|
1116
|
+
mimeType: input.mimeType,
|
|
1117
|
+
error: msg,
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
636
1120
|
return {
|
|
637
|
-
content: [{ type: "text", text: `Error sending file to ${
|
|
1121
|
+
content: [{ type: "text", text: `Error sending file to ${input.peer}: ${msg}` }],
|
|
638
1122
|
details: { ok: false, error: msg },
|
|
639
1123
|
};
|
|
640
1124
|
}
|
|
@@ -667,124 +1151,11 @@ const plugin = {
|
|
|
667
1151
|
},
|
|
668
1152
|
},
|
|
669
1153
|
async execute(toolCallId, params) {
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
hubClient = await HubMatchClient.create();
|
|
673
|
-
}
|
|
674
|
-
catch (err) {
|
|
675
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
676
|
-
return {
|
|
677
|
-
content: [{ type: "text", text: `Not registered with hub: ${msg}` }],
|
|
678
|
-
details: { ok: false, error: msg },
|
|
679
|
-
};
|
|
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
|
-
}
|
|
695
|
-
let match;
|
|
696
|
-
try {
|
|
697
|
-
match = await hubClient.createMatch({
|
|
698
|
-
skills: params.skills,
|
|
699
|
-
description: params.description,
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
catch (err) {
|
|
703
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
704
|
-
return {
|
|
705
|
-
content: [{ type: "text", text: `Failed to create match: ${msg}` }],
|
|
706
|
-
details: { ok: false, error: msg },
|
|
707
|
-
};
|
|
708
|
-
}
|
|
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;
|
|
758
|
-
try {
|
|
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);
|
|
763
|
-
}
|
|
764
|
-
catch (err) {
|
|
765
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
766
|
-
return {
|
|
767
|
-
content: [{ type: "text", text: `Encrypted handshake answer received for match ${match.id}, but processing failed: ${msg}` }],
|
|
768
|
-
details: { ok: false, matchId: match.id, error: msg },
|
|
769
|
-
};
|
|
770
|
-
}
|
|
1154
|
+
const input = params;
|
|
1155
|
+
const result = await performMatchRequest(input);
|
|
771
1156
|
return {
|
|
772
|
-
content: [{
|
|
773
|
-
|
|
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}`,
|
|
779
|
-
}],
|
|
780
|
-
details: {
|
|
781
|
-
ok: true,
|
|
782
|
-
matchId: match.id,
|
|
783
|
-
status: "ready_to_connect",
|
|
784
|
-
providerAddress: remotePayload.address,
|
|
785
|
-
peerToken: remotePayload.token,
|
|
786
|
-
tokenExpiresAt: remotePayload.tokenExpiresAt,
|
|
787
|
-
},
|
|
1157
|
+
content: [{ type: "text", text: result.text }],
|
|
1158
|
+
details: result.details,
|
|
788
1159
|
};
|
|
789
1160
|
},
|
|
790
1161
|
});
|
|
@@ -799,26 +1170,7 @@ const plugin = {
|
|
|
799
1170
|
if (server) {
|
|
800
1171
|
return;
|
|
801
1172
|
}
|
|
802
|
-
|
|
803
|
-
if (config.hub?.enabled !== false && config.hub?.registrationEnabled !== false) {
|
|
804
|
-
try {
|
|
805
|
-
const reg = await runHubRegistration(api, config, config.hub, config.registration ?? {});
|
|
806
|
-
if (reg) {
|
|
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
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
catch (err) {
|
|
818
|
-
api.logger.warn(`claw-crony: hub registration failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
819
|
-
// Continue startup anyway — hub is optional
|
|
820
|
-
}
|
|
821
|
-
}
|
|
1173
|
+
await startHubLifecycle("service");
|
|
822
1174
|
// Start peer health checks
|
|
823
1175
|
healthManager?.start();
|
|
824
1176
|
// Start HTTP server (JSON-RPC + REST)
|
|
@@ -886,7 +1238,7 @@ const plugin = {
|
|
|
886
1238
|
if (config.hub?.enabled !== false) {
|
|
887
1239
|
const pollingIntervalMs = Math.max(5_000, config.resilience.healthCheck.intervalMs);
|
|
888
1240
|
const pollHubMatches = () => {
|
|
889
|
-
void processPendingHubMatches(api, config, processedHubMessages);
|
|
1241
|
+
void processPendingHubMatches(api, config, processedHubMessages, historyStore);
|
|
890
1242
|
};
|
|
891
1243
|
pollHubMatches();
|
|
892
1244
|
hubMatchPollingTimer = setInterval(pollHubMatches, pollingIntervalMs);
|
|
@@ -898,15 +1250,8 @@ const plugin = {
|
|
|
898
1250
|
// Stop peer health checks
|
|
899
1251
|
healthManager?.stop();
|
|
900
1252
|
auditLogger.close();
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const hubClient = await HubMatchClient.create();
|
|
904
|
-
await hubClient.updatePresence("offline");
|
|
905
|
-
}
|
|
906
|
-
catch {
|
|
907
|
-
// Ignore best-effort presence shutdown failure.
|
|
908
|
-
}
|
|
909
|
-
}
|
|
1253
|
+
historyStore.close();
|
|
1254
|
+
await stopHubLifecycle("service");
|
|
910
1255
|
// Stop task cleanup timer
|
|
911
1256
|
if (cleanupTimer) {
|
|
912
1257
|
clearInterval(cleanupTimer);
|