@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/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
- async function processPendingHubMatches(api, config, processedMessages) {
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 peer = config.peers.find((p) => p.name === params.peer);
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: "${params.peer}". Available peers: ${available}` }],
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(params.uri, config.security);
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 (params.mimeType && !validateMimeType(params.mimeType, config.security.allowedMimeTypes)) {
1042
+ if (input.mimeType && !validateMimeType(input.mimeType, config.security.allowedMimeTypes)) {
597
1043
  return {
598
- 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` }],
599
1045
  details: { ok: false },
600
1046
  };
601
1047
  }
602
1048
  const parts = [];
603
- if (params.text) {
604
- parts.push({ kind: "text", text: params.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: params.uri,
610
- ...(params.name ? { name: params.name } : {}),
611
- ...(params.mimeType ? { mimeType: params.mimeType } : {}),
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 (params.agentId) {
617
- message.agentId = params.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 ${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)}` }],
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 ${params.peer}: ${JSON.stringify(result.response)}` }],
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 ${params.peer}: ${msg}` }],
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
- let hubClient;
671
- try {
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
- type: "text",
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
- // Hub registration (runs before server starts)
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
- 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
- }
1253
+ historyStore.close();
1254
+ await stopHubLifecycle("service");
910
1255
  // Stop task cleanup timer
911
1256
  if (cleanupTimer) {
912
1257
  clearInterval(cleanupTimer);