@kadi.build/core 0.13.0 → 0.15.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/src/client.ts CHANGED
@@ -61,6 +61,7 @@ import { loadStdioTransport } from './transports/stdio.js';
61
61
  import { loadBrokerTransport } from './transports/broker.js';
62
62
  import { AgentJsonManager } from './agent-json.js';
63
63
  import { ProcessManager } from './process-manager.js';
64
+ import { CryptoService } from './crypto-service.js';
64
65
 
65
66
  // ═══════════════════════════════════════════════════════════════
66
67
  // CONSTANTS
@@ -206,12 +207,18 @@ export class KadiClient {
206
207
  /** Registered disconnect hooks */
207
208
  private readonly disconnectHooks: Array<() => Promise<void>> = [];
208
209
 
210
+ /** Registered reconnect hooks */
211
+ private readonly reconnectHooks: Array<(brokerName: string) => void | Promise<void>> = [];
212
+
209
213
  /** Lazy-initialized AgentJsonManager */
210
214
  private _agentJson: AgentJsonManager | null = null;
211
215
 
212
216
  /** Lazy-initialized ProcessManager */
213
217
  private _processes: ProcessManager | null = null;
214
218
 
219
+ /** Lazy-initialized CryptoService */
220
+ private _crypto: CryptoService | null = null;
221
+
215
222
  // ─────────────────────────────────────────────────────────────
216
223
  // IDENTITY (single keypair shared across all broker connections)
217
224
  // ─────────────────────────────────────────────────────────────
@@ -1698,9 +1705,36 @@ export class KadiClient {
1698
1705
  // Finalize connection (heartbeat + status)
1699
1706
  this.finalizeConnection(state);
1700
1707
 
1708
+ // Re-subscribe event patterns that were active before disconnect.
1709
+ // cleanupBroker() cleared subscribedPatterns but preserved eventHandlers,
1710
+ // so we replay broker-side subscriptions for every pattern that still has handlers.
1711
+ if (state.eventHandlers.size > 0) {
1712
+ const patterns = [...state.eventHandlers.keys()];
1713
+ console.error(`[KADI] Re-subscribing ${patterns.length} event pattern(s) on broker "${state.name}"`);
1714
+ for (const pattern of patterns) {
1715
+ try {
1716
+ await this.sendRequest(state, protocol.eventSubscribe(this.nextRequestId++, pattern));
1717
+ state.subscribedPatterns.add(pattern);
1718
+ } catch (err) {
1719
+ const msg = err instanceof Error ? err.message : String(err);
1720
+ console.error(`[KADI] Failed to re-subscribe "${pattern}" on broker "${state.name}": ${msg}`);
1721
+ }
1722
+ }
1723
+ }
1724
+
1701
1725
  // Success!
1702
1726
  console.error(`[KADI] Reconnected to broker "${state.name}" after ${state.reconnectAttempts} attempts`);
1703
1727
  state.reconnectAttempts = 0;
1728
+
1729
+ // Fire reconnect hooks (best-effort, don't let a failing hook break reconnect)
1730
+ for (const hook of this.reconnectHooks) {
1731
+ try {
1732
+ await hook(state.name);
1733
+ } catch (err) {
1734
+ const msg = err instanceof Error ? err.message : String(err);
1735
+ console.error(`[KADI] Reconnect hook error for broker "${state.name}": ${msg}`);
1736
+ }
1737
+ }
1704
1738
  } catch (error) {
1705
1739
  // Log the error and try again
1706
1740
  const message = error instanceof Error ? error.message : String(error);
@@ -1722,6 +1756,26 @@ export class KadiClient {
1722
1756
  this.disconnectHooks.push(hook);
1723
1757
  }
1724
1758
 
1759
+ /**
1760
+ * Register a hook to run after a successful broker reconnection.
1761
+ *
1762
+ * Event subscriptions are automatically re-established on reconnect,
1763
+ * so most agents won't need this. Use it for additional recovery logic:
1764
+ * re-fetching state, logging, sending a "I'm back" message, etc.
1765
+ *
1766
+ * @param hook - Called with the broker name after each successful reconnect.
1767
+ *
1768
+ * @example
1769
+ * ```typescript
1770
+ * client.onReconnect(async (brokerName) => {
1771
+ * console.log(`Reconnected to ${brokerName} — re-initializing state`);
1772
+ * });
1773
+ * ```
1774
+ */
1775
+ onReconnect(hook: (brokerName: string) => void | Promise<void>): void {
1776
+ this.reconnectHooks.push(hook);
1777
+ }
1778
+
1725
1779
  /**
1726
1780
  * Disconnect from broker(s) and run registered cleanup hooks.
1727
1781
  *
@@ -2481,6 +2535,35 @@ export class KadiClient {
2481
2535
  return this._processes;
2482
2536
  }
2483
2537
 
2538
+ // ─────────────────────────────────────────────────────────────
2539
+ // CRYPTO SERVICE
2540
+ // ─────────────────────────────────────────────────────────────
2541
+
2542
+ /**
2543
+ * Access the CryptoService for encrypting/decrypting messages.
2544
+ *
2545
+ * Lazily created on first access. Uses NaCl sealed boxes (X25519 +
2546
+ * XSalsa20-Poly1305) derived from this agent's Ed25519 identity keys.
2547
+ *
2548
+ * @example
2549
+ * ```typescript
2550
+ * // Encrypt a message for another agent
2551
+ * const encrypted = client.crypto.encryptFor('secret', otherAgent.publicKey);
2552
+ *
2553
+ * // Decrypt a message sent to this agent
2554
+ * const plaintext = client.crypto.decrypt(encrypted);
2555
+ *
2556
+ * // Get this agent's encryption public key (to share with others)
2557
+ * const pubKey = client.crypto.publicKey;
2558
+ * ```
2559
+ */
2560
+ get crypto(): CryptoService {
2561
+ if (!this._crypto) {
2562
+ this._crypto = new CryptoService(this._privateKey, this._publicKeyBase64);
2563
+ }
2564
+ return this._crypto;
2565
+ }
2566
+
2484
2567
  // ─────────────────────────────────────────────────────────────
2485
2568
  // UTILITY
2486
2569
  // ─────────────────────────────────────────────────────────────
@@ -0,0 +1,169 @@
1
+ /**
2
+ * CryptoService — High-level encryption/decryption for KADI agents.
3
+ *
4
+ * Provides NaCl sealed-box encryption using the agent's Ed25519 identity
5
+ * keys (converted to X25519). Sealed boxes allow anyone with the recipient's
6
+ * public key to encrypt a message that only the recipient can decrypt.
7
+ *
8
+ * This is a stateless wrapper around tweetnacl-sealedbox-js, using the
9
+ * key conversion utilities already in crypto.ts.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * // Agent A encrypts a message for Agent B
14
+ * const encrypted = agentA.crypto.encryptFor('secret data', agentB.publicKey);
15
+ *
16
+ * // Agent B decrypts
17
+ * const plaintext = agentB.crypto.decrypt(encrypted);
18
+ * ```
19
+ *
20
+ * @module crypto-service
21
+ */
22
+
23
+ // @ts-expect-error - tweetnacl-sealedbox-js has no type declarations
24
+ import sealedbox from 'tweetnacl-sealedbox-js';
25
+
26
+ import {
27
+ convertToEncryptionKey,
28
+ convertToEncryptionKeyPair,
29
+ type EncryptionKeyPair,
30
+ } from './crypto.js';
31
+
32
+ /**
33
+ * High-level encryption/decryption service for inter-agent communication.
34
+ *
35
+ * Uses NaCl sealed boxes (X25519 + XSalsa20-Poly1305 authenticated encryption).
36
+ * Only the intended recipient can decrypt messages.
37
+ */
38
+ export class CryptoService {
39
+ private readonly _privateKey: Buffer;
40
+ private readonly _publicKeyBase64: string;
41
+
42
+ /** Cached key pairs to avoid repeated conversions */
43
+ private _encryptionKeyPair: EncryptionKeyPair | null = null;
44
+ private _publicKeyX25519Base64: string | null = null;
45
+
46
+ /**
47
+ * @param privateKey - Ed25519 private key in PKCS8 DER format
48
+ * @param publicKeyBase64 - Base64-encoded Ed25519 public key (SPKI DER format)
49
+ */
50
+ constructor(privateKey: Buffer, publicKeyBase64: string) {
51
+ this._privateKey = privateKey;
52
+ this._publicKeyBase64 = publicKeyBase64;
53
+ }
54
+
55
+ /**
56
+ * This agent's X25519 encryption public key (base64).
57
+ *
58
+ * Share this with anyone who needs to encrypt messages for this agent.
59
+ * The key is derived from the agent's Ed25519 identity key.
60
+ */
61
+ get publicKey(): string {
62
+ if (!this._publicKeyX25519Base64) {
63
+ const raw = convertToEncryptionKey(this._publicKeyBase64);
64
+ this._publicKeyX25519Base64 = Buffer.from(raw).toString('base64');
65
+ }
66
+ return this._publicKeyX25519Base64;
67
+ }
68
+
69
+ /**
70
+ * This agent's Ed25519 identity public key (SPKI DER, base64).
71
+ *
72
+ * This is the same key used for broker authentication and agent ID derivation.
73
+ */
74
+ get identityPublicKey(): string {
75
+ return this._publicKeyBase64;
76
+ }
77
+
78
+ /**
79
+ * Encrypt a message so only the specified recipient can decrypt it.
80
+ *
81
+ * Uses NaCl sealed box: the sender generates an ephemeral keypair internally.
82
+ * The recipient cannot determine who sent the message (anonymous encryption).
83
+ * For authentication, use a separate mechanism (e.g., API tokens, signatures).
84
+ *
85
+ * @param plaintext - The string to encrypt
86
+ * @param recipientPublicKey - Recipient's public key. Accepts either:
87
+ * - Ed25519 SPKI DER base64 (the standard `client.publicKey` format) — auto-converted to X25519
88
+ * - Raw X25519 base64 (32 bytes when decoded) — used directly
89
+ * @returns Base64-encoded ciphertext (NaCl sealed box)
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * // Encrypt for another agent using their Ed25519 identity key
94
+ * const encrypted = client.crypto.encryptFor(
95
+ * JSON.stringify({ API_KEY: 'sk-123', DB_PASS: 'hunter2' }),
96
+ * otherAgent.publicKey
97
+ * );
98
+ * ```
99
+ */
100
+ encryptFor(plaintext: string, recipientPublicKey: string): string {
101
+ const x25519Key = this.resolveToX25519(recipientPublicKey);
102
+ const message = new TextEncoder().encode(plaintext);
103
+ const sealed = sealedbox.seal(message, x25519Key);
104
+ return Buffer.from(sealed).toString('base64');
105
+ }
106
+
107
+ /**
108
+ * Decrypt a message that was encrypted for this agent.
109
+ *
110
+ * The message must have been encrypted using this agent's public key
111
+ * (either via `encryptFor()` or directly with NaCl sealed box).
112
+ *
113
+ * @param ciphertext - Base64-encoded NaCl sealed box ciphertext
114
+ * @returns The decrypted plaintext string
115
+ * @throws Error if decryption fails (wrong key, corrupted data, or tampered message)
116
+ *
117
+ * @example
118
+ * ```typescript
119
+ * const plaintext = client.crypto.decrypt(encryptedBase64);
120
+ * const secrets = JSON.parse(plaintext);
121
+ * ```
122
+ */
123
+ decrypt(ciphertext: string): string {
124
+ const keyPair = this.getEncryptionKeyPair();
125
+ const sealed = new Uint8Array(Buffer.from(ciphertext, 'base64'));
126
+
127
+ const decrypted = sealedbox.open(sealed, keyPair.publicKey, keyPair.secretKey);
128
+
129
+ if (!decrypted) {
130
+ throw new Error(
131
+ 'Decryption failed: invalid ciphertext, wrong key, or tampered message'
132
+ );
133
+ }
134
+
135
+ return new TextDecoder().decode(decrypted);
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────
139
+ // INTERNAL
140
+ // ─────────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Resolve a public key to X25519 format.
144
+ * Handles both Ed25519 SPKI DER base64 and raw X25519 base64.
145
+ */
146
+ private resolveToX25519(publicKey: string): Uint8Array {
147
+ const raw = Buffer.from(publicKey, 'base64');
148
+
149
+ // Raw X25519 key is exactly 32 bytes
150
+ // SPKI DER Ed25519 key is 44 bytes (12-byte header + 32-byte key)
151
+ if (raw.length === 32) {
152
+ return new Uint8Array(raw);
153
+ }
154
+
155
+ // Assume SPKI DER format — convert Ed25519 → X25519
156
+ return convertToEncryptionKey(publicKey);
157
+ }
158
+
159
+ /** Get or create cached encryption key pair */
160
+ private getEncryptionKeyPair(): EncryptionKeyPair {
161
+ if (!this._encryptionKeyPair) {
162
+ this._encryptionKeyPair = convertToEncryptionKeyPair(
163
+ this._privateKey,
164
+ this._publicKeyBase64
165
+ );
166
+ }
167
+ return this._encryptionKeyPair;
168
+ }
169
+ }
package/src/index.ts CHANGED
@@ -119,6 +119,9 @@ export { AgentJsonManager } from './agent-json.js';
119
119
  // Process manager
120
120
  export { ProcessManager, ManagedProcess } from './process-manager.js';
121
121
 
122
+ // Crypto service (sealed-box encryption for inter-agent communication)
123
+ export { CryptoService } from './crypto-service.js';
124
+
122
125
  // Dot-path utilities
123
126
  export { getByPath, setByPath, deleteByPath, deepMerge } from './utils.js';
124
127