@clawcrony/claw-crony 1.2.3 → 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/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";
@@ -21,11 +22,15 @@ import { runTaskCleanup } from "./src/task-cleanup.js";
21
22
  import { FileTaskStore } from "./src/task-store.js";
22
23
  import { GatewayTelemetry } from "./src/telemetry.js";
23
24
  import { AuditLogger } from "./src/audit.js";
25
+ import { RequestHistoryStore } from "./src/history.js";
24
26
  import { PeerHealthManager } from "./src/peer-health.js";
25
27
  import { runHubRegistration } from "./src/hub-registration.js";
26
28
  import { HubMatchClient } from "./src/hub-match.js";
27
29
  import { normalizeAgentCardSkills } from "./src/skill-catalog.js";
28
30
  import { parseRoutingRules, matchRule } from "./src/routing-rules.js";
31
+ import { decryptHandshake, encryptHandshake } from "./src/handshake-crypto.js";
32
+ import { isValidEphemeralInboundToken, issueEphemeralInboundToken } from "./src/ephemeral-token.js";
33
+ import { loadIdentity } from "./src/identity-store.js";
29
34
  import { validateUri, validateMimeType, } from "./src/file-security.js";
30
35
  /** Build a JSON-RPC error response. */
31
36
  function jsonRpcError(id, code, message) {
@@ -183,6 +188,9 @@ export function parseConfig(raw, resolvePath) {
183
188
  metricsPath: normalizeHttpPath(asString(observability.metricsPath, "/a2a/metrics"), "/a2a/metrics"),
184
189
  metricsAuth: (asString(observability.metricsAuth, "none") === "bearer" ? "bearer" : "none"),
185
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),
186
194
  },
187
195
  timeouts: {
188
196
  agentResponseTimeoutMs: asNumber(timeouts.agentResponseTimeoutMs, 300_000),
@@ -212,6 +220,7 @@ export function parseConfig(raw, resolvePath) {
212
220
  username: asString(registration.username, ""),
213
221
  email: asString(registration.email, ""),
214
222
  password: asString(registration.password, ""),
223
+ clientId: asString(registration.clientId, ""),
215
224
  },
216
225
  };
217
226
  }
@@ -221,16 +230,60 @@ function normalizeCardPath() {
221
230
  }
222
231
  return `/${AGENT_CARD_PATH}`;
223
232
  }
224
- function getAdvertisedInboundToken(config, hubClient) {
225
- if (config.security.tokens && config.security.tokens.length > 0) {
226
- return config.security.tokens[0] ?? null;
233
+ function getAdvertisedAddress(config) {
234
+ if (config.agentCard.url) {
235
+ try {
236
+ const url = new URL(config.agentCard.url);
237
+ return `${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`;
238
+ }
239
+ catch {
240
+ // Ignore invalid configured URL and fall back below.
241
+ }
242
+ }
243
+ if (config.server.host && config.server.port) {
244
+ return `${config.server.host}:${config.server.port}`;
245
+ }
246
+ return null;
247
+ }
248
+ function upsertEphemeralPeer(config, peerName, address, token) {
249
+ const normalizedAddress = address.startsWith("http://") || address.startsWith("https://")
250
+ ? address
251
+ : `http://${address}`;
252
+ const agentCardUrl = `${normalizedAddress}/.well-known/agent.json`;
253
+ const existing = config.peers.find((peer) => peer.name === peerName);
254
+ if (existing) {
255
+ existing.agentCardUrl = agentCardUrl;
256
+ existing.auth = { type: "bearer", token };
257
+ return;
227
258
  }
228
- if (config.security.token) {
229
- return config.security.token;
259
+ config.peers.push({
260
+ name: peerName,
261
+ agentCardUrl,
262
+ auth: { type: "bearer", token },
263
+ });
264
+ }
265
+ async function waitForHandshakeAnswer(hubClient, matchId, timeoutMs = 45_000) {
266
+ const deadline = Date.now() + timeoutMs;
267
+ while (Date.now() < deadline) {
268
+ const pending = await hubClient.getPendingHandshakeMessages(matchId);
269
+ const answer = pending.find((message) => message.messageType === "answer");
270
+ if (answer) {
271
+ return answer;
272
+ }
273
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
230
274
  }
231
- return hubClient?.registrationToken ?? null;
275
+ throw new Error(`Timed out waiting for handshake answer for match ${matchId}`);
232
276
  }
233
- async function processPendingHubMatches(api, config, processedMatches) {
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) {
234
287
  let hubClient;
235
288
  try {
236
289
  hubClient = await HubMatchClient.create();
@@ -239,9 +292,9 @@ async function processPendingHubMatches(api, config, processedMatches) {
239
292
  api.logger.warn(`claw-crony: pending match polling skipped - ${err instanceof Error ? err.message : String(err)}`);
240
293
  return;
241
294
  }
242
- const inboundToken = getAdvertisedInboundToken(config, hubClient);
243
- if (!inboundToken) {
244
- api.logger.warn("claw-crony: pending match polling skipped - no inbound token available");
295
+ const identity = loadIdentity();
296
+ if (!identity) {
297
+ api.logger.warn("claw-crony: pending match polling skipped - no local identity available");
245
298
  return;
246
299
  }
247
300
  let matches;
@@ -257,17 +310,107 @@ async function processPendingHubMatches(api, config, processedMatches) {
257
310
  continue;
258
311
  }
259
312
  try {
260
- const alreadySubmitted = match.providerTokenSubmitted === true || processedMatches.has(match.id);
261
- let currentMatch = match;
262
- if (!alreadySubmitted) {
263
- currentMatch = await hubClient.submitToken(match.id, inboundToken);
264
- processedMatches.add(match.id);
265
- api.logger.info(`claw-crony: submitted provider token for hub match ${match.id}`);
266
- }
267
- if (currentMatch.readyForComplete === true && currentMatch.status === "token_exchange") {
268
- await hubClient.completeMatch(match.id, inboundToken);
269
- processedMatches.delete(match.id);
270
- api.logger.info(`claw-crony: completed hub match ${match.id}`);
313
+ const pendingMessages = await hubClient.getPendingHandshakeMessages(match.id);
314
+ for (const message of pendingMessages) {
315
+ if (processedMessages.has(message.id)) {
316
+ continue;
317
+ }
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
+ }
348
+ await hubClient.consumeHandshakeMessage(match.id, message.id);
349
+ processedMessages.add(message.id);
350
+ const remoteName = match.callerRole === "provider"
351
+ ? (match.requester?.name ?? `agent-${decrypted.fromAgentId}`)
352
+ : (match.provider?.name ?? `agent-${decrypted.fromAgentId}`);
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
+ });
365
+ if (message.messageType === "offer" && match.callerRole === "provider") {
366
+ const localAddress = getAdvertisedAddress(config);
367
+ if (!localAddress) {
368
+ api.logger.warn(`claw-crony: cannot answer match ${match.id} because no advertised address is configured`);
369
+ continue;
370
+ }
371
+ const issued = issueEphemeralInboundToken(config, match.id, decrypted.fromAgentId);
372
+ const payload = {
373
+ version: 1,
374
+ matchId: match.id,
375
+ sessionId: crypto.randomUUID(),
376
+ fromAgentId: hubClient.agentId,
377
+ toAgentId: decrypted.fromAgentId,
378
+ address: localAddress,
379
+ agentCardPath: "/.well-known/agent.json",
380
+ token: issued.token,
381
+ tokenExpiresAt: issued.expiresAt,
382
+ protocols: ["jsonrpc", "rest", "grpc"],
383
+ createdAt: new Date().toISOString(),
384
+ nonce: crypto.randomBytes(12).toString("hex"),
385
+ };
386
+ const peerPublicKey = match.requester?.publicKey;
387
+ if (!peerPublicKey) {
388
+ api.logger.warn(`claw-crony: cannot answer match ${match.id} because requester public key is missing`);
389
+ continue;
390
+ }
391
+ await hubClient.sendHandshakeMessage(match.id, {
392
+ messageType: "answer",
393
+ ciphertext: encryptHandshake(payload, peerPublicKey),
394
+ expiresAt: issued.expiresAt,
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
+ });
407
+ await hubClient.markReady(match.id);
408
+ api.logger.info(`claw-crony: answered encrypted handshake for match ${match.id}`);
409
+ }
410
+ else if (message.messageType === "answer") {
411
+ await hubClient.markReady(match.id);
412
+ api.logger.info(`claw-crony: received encrypted handshake answer for match ${match.id}`);
413
+ }
271
414
  }
272
415
  }
273
416
  catch (err) {
@@ -285,6 +428,10 @@ const plugin = {
285
428
  structuredLogs: config.observability.structuredLogs,
286
429
  });
287
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
+ });
288
435
  const client = new A2AClient();
289
436
  const taskStore = new FileTaskStore(config.storage.tasksDir);
290
437
  const executor = new QueueingAgentExecutor(new OpenClawAgentExecutor(api, config), telemetry, config.limits);
@@ -321,6 +468,13 @@ const plugin = {
321
468
  // Wire audit logger for inbound task completion
322
469
  telemetry.setTaskAuditCallback((taskId, contextId, state, durationMs) => {
323
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
+ });
324
478
  });
325
479
  // SDK expects userBuilder(req) -> Promise<User>
326
480
  // When bearer auth is configured, validate the Authorization header.
@@ -397,7 +551,305 @@ const plugin = {
397
551
  let cleanupTimer = null;
398
552
  let hubMatchPollingTimer = null;
399
553
  const grpcPort = config.server.port + 1;
400
- const processedHubMatches = new Set();
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
+ };
401
853
  api.registerGatewayMethod("a2a.metrics", ({ respond }) => {
402
854
  respond(true, {
403
855
  metrics: telemetry.snapshot(),
@@ -411,6 +863,49 @@ const plugin = {
411
863
  .then((entries) => respond(true, { entries, count: entries.length }))
412
864
  .catch((error) => respond(false, { error: String(error?.message || error) }));
413
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
+ });
414
909
  api.registerGatewayMethod("a2a.send", ({ params, respond }) => {
415
910
  const payload = asObject(params);
416
911
  const peerName = asString(payload.peer || payload.name, "");
@@ -439,6 +934,16 @@ const plugin = {
439
934
  message.agentId = resolvedAgentId;
440
935
  }
441
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
+ });
442
947
  const sendOptions = {
443
948
  healthManager: healthManager ?? undefined,
444
949
  retryConfig: config.resilience.retry,
@@ -455,6 +960,17 @@ const plugin = {
455
960
  const outDuration = Date.now() - startedAt;
456
961
  telemetry.recordOutboundRequest(peer.name, result.ok, result.statusCode, outDuration);
457
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
+ });
458
974
  if (result.ok) {
459
975
  respond(true, {
460
976
  statusCode: result.statusCode,
@@ -471,6 +987,14 @@ const plugin = {
471
987
  const errDuration = Date.now() - startedAt;
472
988
  telemetry.recordOutboundRequest(peer.name, false, 500, errDuration);
473
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
+ });
474
998
  respond(false, { error: String(error?.message || error) });
475
999
  });
476
1000
  });
@@ -498,64 +1022,103 @@ const plugin = {
498
1022
  label: "A2A Send File",
499
1023
  parameters: sendFileParams,
500
1024
  async execute(toolCallId, params) {
501
- const peer = config.peers.find((p) => p.name === params.peer);
1025
+ const input = params;
1026
+ const peer = config.peers.find((p) => p.name === input.peer);
502
1027
  if (!peer) {
503
1028
  const available = config.peers.map((p) => p.name).join(", ") || "(none)";
504
1029
  return {
505
- content: [{ type: "text", text: `Peer not found: "${params.peer}". Available peers: ${available}` }],
1030
+ content: [{ type: "text", text: `Peer not found: "${input.peer}". Available peers: ${available}` }],
506
1031
  details: { ok: false },
507
1032
  };
508
1033
  }
509
1034
  // Security checks: SSRF, MIME, file size
510
- const uriCheck = await validateUri(params.uri, config.security);
1035
+ const uriCheck = await validateUri(input.uri, config.security);
511
1036
  if (!uriCheck.ok) {
512
1037
  return {
513
1038
  content: [{ type: "text", text: `URI rejected: ${uriCheck.reason}` }],
514
1039
  details: { ok: false, reason: uriCheck.reason },
515
1040
  };
516
1041
  }
517
- if (params.mimeType && !validateMimeType(params.mimeType, config.security.allowedMimeTypes)) {
1042
+ if (input.mimeType && !validateMimeType(input.mimeType, config.security.allowedMimeTypes)) {
518
1043
  return {
519
- content: [{ type: "text", text: `MIME type rejected: "${params.mimeType}" is not in the allowed list` }],
1044
+ content: [{ type: "text", text: `MIME type rejected: "${input.mimeType}" is not in the allowed list` }],
520
1045
  details: { ok: false },
521
1046
  };
522
1047
  }
523
1048
  const parts = [];
524
- if (params.text) {
525
- parts.push({ kind: "text", text: params.text });
1049
+ if (input.text) {
1050
+ parts.push({ kind: "text", text: input.text });
526
1051
  }
527
1052
  parts.push({
528
1053
  kind: "file",
529
1054
  file: {
530
- uri: params.uri,
531
- ...(params.name ? { name: params.name } : {}),
532
- ...(params.mimeType ? { mimeType: params.mimeType } : {}),
1055
+ uri: input.uri,
1056
+ ...(input.name ? { name: input.name } : {}),
1057
+ ...(input.mimeType ? { mimeType: input.mimeType } : {}),
533
1058
  },
534
1059
  });
535
1060
  try {
536
1061
  const message = { parts };
537
- if (params.agentId) {
538
- message.agentId = params.agentId;
1062
+ if (input.agentId) {
1063
+ message.agentId = input.agentId;
539
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
+ });
540
1078
  const result = await client.sendMessage(peer, message, {
541
1079
  healthManager: healthManager ?? undefined,
542
1080
  retryConfig: config.resilience.retry,
543
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
+ });
544
1095
  if (result.ok) {
545
1096
  return {
546
- content: [{ type: "text", text: `File sent to ${params.peer} via A2A.\nURI: ${params.uri}\nResponse: ${JSON.stringify(result.response)}` }],
1097
+ content: [{ type: "text", text: `File sent to ${input.peer} via A2A.\nURI: ${input.uri}\nResponse: ${JSON.stringify(result.response)}` }],
547
1098
  details: { ok: true, response: result.response },
548
1099
  };
549
1100
  }
550
1101
  return {
551
- content: [{ type: "text", text: `Failed to send file to ${params.peer}: ${JSON.stringify(result.response)}` }],
1102
+ content: [{ type: "text", text: `Failed to send file to ${input.peer}: ${JSON.stringify(result.response)}` }],
552
1103
  details: { ok: false, response: result.response },
553
1104
  };
554
1105
  }
555
1106
  catch (err) {
556
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
+ });
557
1120
  return {
558
- content: [{ type: "text", text: `Error sending file to ${params.peer}: ${msg}` }],
1121
+ content: [{ type: "text", text: `Error sending file to ${input.peer}: ${msg}` }],
559
1122
  details: { ok: false, error: msg },
560
1123
  };
561
1124
  }
@@ -563,14 +1126,13 @@ const plugin = {
563
1126
  });
564
1127
  // ------------------------------------------------------------------
565
1128
  // Agent tool: a2a_match_request
566
- // Creates a match request on the hub and submits this agent's token.
567
- // Returns provider address + yourToken + peerToken for A2A communication.
1129
+ // Creates a match request on the hub and performs encrypted connection-info handshake.
568
1130
  // ------------------------------------------------------------------
569
1131
  api.registerTool({
570
1132
  name: "a2a_match_request",
571
1133
  description: "Request a match with another agent via the hub. " +
572
1134
  "The hub finds a provider agent with matching skills, creates a match record, " +
573
- "and returns the provider's address along with tokens for secure A2A communication. " +
1135
+ "and relays encrypted handshake messages so both agents can exchange temporary A2A connection details. " +
574
1136
  "Use this to discover and connect with peer agents through the hub's registry.",
575
1137
  label: "A2A Match Request",
576
1138
  parameters: {
@@ -589,65 +1151,11 @@ const plugin = {
589
1151
  },
590
1152
  },
591
1153
  async execute(toolCallId, params) {
592
- let client;
593
- try {
594
- client = await HubMatchClient.create();
595
- }
596
- catch (err) {
597
- const msg = err instanceof Error ? err.message : String(err);
598
- return {
599
- content: [{ type: "text", text: `Not registered with hub: ${msg}` }],
600
- details: { ok: false, error: msg },
601
- };
602
- }
603
- let match;
604
- try {
605
- match = await client.createMatch({
606
- skills: params.skills,
607
- description: params.description,
608
- token: getAdvertisedInboundToken(config, client) ?? client.registrationToken,
609
- });
610
- }
611
- catch (err) {
612
- const msg = err instanceof Error ? err.message : String(err);
613
- return {
614
- content: [{ type: "text", text: `Failed to create match: ${msg}` }],
615
- details: { ok: false, error: msg },
616
- };
617
- }
618
- // Submit our token
619
- let updatedMatch;
620
- try {
621
- updatedMatch = await client.submitToken(match.id, getAdvertisedInboundToken(config, client) ?? client.registrationToken);
622
- }
623
- catch (err) {
624
- const msg = err instanceof Error ? err.message : String(err);
625
- return {
626
- content: [{ type: "text", text: `Match created (id=${match.id}) but failed to submit token: ${msg}` }],
627
- details: { ok: false, matchId: match.id, error: msg },
628
- };
629
- }
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;
1154
+ const input = params;
1155
+ const result = await performMatchRequest(input);
635
1156
  return {
636
- content: [{
637
- type: "text",
638
- text: `Match ${status}: id=${updatedMatch.id}\n` +
639
- `Provider: ${provider?.name ?? "(unknown)"} at ${providerAddress}\n` +
640
- `Provider access token (use to contact provider): ${providerAccessToken}\n` +
641
- `Requester inbound token (provider uses this to contact you): ${requesterInboundToken}`,
642
- }],
643
- details: {
644
- ok: true,
645
- matchId: updatedMatch.id,
646
- status: updatedMatch.status,
647
- providerAddress,
648
- yourToken: providerAccessToken,
649
- peerToken: requesterInboundToken,
650
- },
1157
+ content: [{ type: "text", text: result.text }],
1158
+ details: result.details,
651
1159
  };
652
1160
  },
653
1161
  });
@@ -662,19 +1170,7 @@ const plugin = {
662
1170
  if (server) {
663
1171
  return;
664
1172
  }
665
- // Hub registration (runs before server starts)
666
- if (config.hub?.enabled !== false && config.hub?.registrationEnabled !== false) {
667
- try {
668
- const reg = await runHubRegistration(api, config, config.hub, config.registration ?? {});
669
- if (reg) {
670
- api.logger.info(`claw-crony: registered with hub (agentId=${reg.agentId})`);
671
- }
672
- }
673
- catch (err) {
674
- api.logger.warn(`claw-crony: hub registration failed — ${err instanceof Error ? err.message : String(err)}`);
675
- // Continue startup anyway — hub is optional
676
- }
677
- }
1173
+ await startHubLifecycle("service");
678
1174
  // Start peer health checks
679
1175
  healthManager?.start();
680
1176
  // Start HTTP server (JSON-RPC + REST)
@@ -742,7 +1238,7 @@ const plugin = {
742
1238
  if (config.hub?.enabled !== false) {
743
1239
  const pollingIntervalMs = Math.max(5_000, config.resilience.healthCheck.intervalMs);
744
1240
  const pollHubMatches = () => {
745
- void processPendingHubMatches(api, config, processedHubMatches);
1241
+ void processPendingHubMatches(api, config, processedHubMessages, historyStore);
746
1242
  };
747
1243
  pollHubMatches();
748
1244
  hubMatchPollingTimer = setInterval(pollHubMatches, pollingIntervalMs);
@@ -754,6 +1250,8 @@ const plugin = {
754
1250
  // Stop peer health checks
755
1251
  healthManager?.stop();
756
1252
  auditLogger.close();
1253
+ historyStore.close();
1254
+ await stopHubLifecycle("service");
757
1255
  // Stop task cleanup timer
758
1256
  if (cleanupTimer) {
759
1257
  clearInterval(cleanupTimer);