@vaultysclaw/agent-runtime 0.0.1

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.
@@ -0,0 +1,1003 @@
1
+ /**
2
+ * BaseAgentRuntime — abstract protocol layer for VaultysClaw agents.
3
+ *
4
+ * Handles WebSocket/WebRTC connection, VaultysId auth handshake, intent
5
+ * routing, policy enforcement, peer catalog management, and delegation
6
+ * verification. Subclasses implement `executeIntent` and `executeChat`
7
+ * to add LLM/tool execution on top.
8
+ *
9
+ * Emitted events (same contract as the original Agent class):
10
+ * status_changed { status: AgentStatus }
11
+ * log { level: 'info'|'warn'|'error'|'debug', message: string, data?: unknown }
12
+ * heartbeat { uptime: number }
13
+ * intent_received { intentId: string; action: string; params: Record<string, unknown> }
14
+ * intent_result { intentId: string; status: 'success'|'failed'; output?: unknown; error?: string }
15
+ * config_updated { source: 'remote'|'env'; provider?: string; model?: string }
16
+ */
17
+ import EventEmitter from "events";
18
+ import fs from "fs";
19
+ import path from "path";
20
+ import { createRequire } from "module";
21
+ import { WebSocket } from "ws";
22
+ // peerjs is CJS — ESM dynamic import() puts Peer inside mod["module.exports"],
23
+ // not as a named export. Load via createRequire so destructuring works and the
24
+ // module-level support-detection IIFE sees the polyfills set by the caller.
25
+ const _require = createRequire(import.meta.url);
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ const PeerJS = _require("peerjs");
28
+ import { Challenger, VaultysId, crypto } from "@vaultys/id";
29
+ import { PeerManager } from "./peer-manager.js";
30
+ import { verifyIntentMessage } from "./intent-verify.js";
31
+ const Buffer = crypto.Buffer;
32
+ // ---- Ring buffer ----
33
+ class RingBuffer {
34
+ constructor(max) {
35
+ this.max = max;
36
+ this.buf = [];
37
+ }
38
+ push(item) {
39
+ this.buf.push(item);
40
+ if (this.buf.length > this.max)
41
+ this.buf.shift();
42
+ }
43
+ toArray() {
44
+ return [...this.buf];
45
+ }
46
+ }
47
+ // ---- BaseAgentRuntime ----
48
+ export class BaseAgentRuntime extends EventEmitter {
49
+ constructor(config) {
50
+ super();
51
+ // Identity
52
+ this.vaultysId = null;
53
+ // Connection
54
+ this.ws = null;
55
+ /** Active PeerJS DataConnection (when connecting via WebRTC instead of WebSocket). */
56
+ this.peerjsConn = null;
57
+ /** Underlying PeerJS Peer instance (kept for cleanup on reconnect). */
58
+ this.peerjsPeer = null;
59
+ this.heartbeatTimer = null;
60
+ this.reconnectTimer = null;
61
+ this.stopped = false;
62
+ /** Consecutive failed connection attempts — drives exponential backoff. */
63
+ this.reconnectAttempts = 0;
64
+ // Status
65
+ this._status = "initializing";
66
+ this.id = "";
67
+ this.capabilities = [];
68
+ this.startedAt = Date.now();
69
+ this.lastHeartbeat = null;
70
+ // Auth handshake state
71
+ this.authChallenger = null;
72
+ this.authSessionId = null;
73
+ this.reAuthPending = false;
74
+ // Server key (extracted after first auth for delegation verification)
75
+ this.serverPublicKey = null;
76
+ // Peer-to-peer agent communication
77
+ this.peerManager = null;
78
+ this.peerCatalog = [];
79
+ this._peerListenerStarted = false;
80
+ // Ring buffers
81
+ this.logBuffer = new RingBuffer(200);
82
+ this.intentBuffer = new RingBuffer(100);
83
+ // Token usage tracking (base layer tracks totals for heartbeat reporting)
84
+ this._tokenUsageSinceLastSync = { promptTokens: 0, completionTokens: 0 };
85
+ this._tokenUsageTotal = { promptTokens: 0, completionTokens: 0 };
86
+ // Active policy enforcement (populated from cert metadata or update_capabilities)
87
+ this.resourceLimits = null;
88
+ this.policyId = null;
89
+ this.policyExpiresAt = null;
90
+ /** Rolling hourly request counter for maxRequestsPerHour enforcement. */
91
+ this._requestsThisHour = { count: 0, hourStart: 0 };
92
+ this.config = config;
93
+ this.capabilities = config.requestedCapabilities;
94
+ }
95
+ // ---- Protected hooks (subclass can override) ----
96
+ getDailyTokenUsageForBudget() {
97
+ return { promptTokens: 0, completionTokens: 0 };
98
+ }
99
+ async onAuthComplete(_payload) { }
100
+ async onDelegationUpdate(_payload) { }
101
+ async onPeerCatalogUpdated(_grants) { }
102
+ async onLlmConfig(_config) { }
103
+ async onSkillsConfig(_payload) { }
104
+ async onKnowledgeSources(_sources) { }
105
+ async handleGetChatSessions(_msg) { }
106
+ async handleGetChatHistory(_msg) { }
107
+ async handleToolApprovalResponse(_msg) { }
108
+ async handleTaskEnqueue(_msg) { }
109
+ async handleScheduleUpdate(_msg) { }
110
+ async handleScheduleDelete(_msg) { }
111
+ async handleKnowledgeSync(_msg) { }
112
+ // ---- Public API ----
113
+ async start() {
114
+ this.log("info", `Initializing agent "${this.config.name}"`);
115
+ this.vaultysId = await this.initVaultysId(this.config.vaultysIdPath);
116
+ this.log("info", `VaultysId identity ready`, { did: this.vaultysId.did });
117
+ // Initialize peer manager for agent-to-agent communication
118
+ this.peerManager = new PeerManager(this.vaultysId);
119
+ this.peerManager.onInvoke(async (remoteDid, action, params) => {
120
+ return this.executeIntent(action, params, remoteDid);
121
+ });
122
+ this.connect();
123
+ }
124
+ stop() {
125
+ this.stopped = true;
126
+ if (this.reconnectTimer) {
127
+ clearTimeout(this.reconnectTimer);
128
+ this.reconnectTimer = null;
129
+ }
130
+ if (this.heartbeatTimer) {
131
+ clearInterval(this.heartbeatTimer);
132
+ this.heartbeatTimer = null;
133
+ }
134
+ if (this.ws) {
135
+ this.ws.close();
136
+ this.ws = null;
137
+ }
138
+ if (this.peerjsConn) {
139
+ this.peerjsConn.close();
140
+ this.peerjsConn = null;
141
+ }
142
+ if (this.peerjsPeer) {
143
+ this.peerjsPeer.destroy();
144
+ this.peerjsPeer = null;
145
+ }
146
+ this.peerManager?.shutdown().catch(() => { });
147
+ this.setStatus("disconnected");
148
+ }
149
+ getInfo() {
150
+ return {
151
+ id: this.id,
152
+ name: this.config.name,
153
+ version: "0.0.1",
154
+ status: this._status,
155
+ capabilities: this.capabilities,
156
+ uptime: Math.floor((Date.now() - this.startedAt) / 1000),
157
+ lastHeartbeat: this.lastHeartbeat?.toISOString() ?? null,
158
+ recentLogs: this.logBuffer.toArray(),
159
+ recentIntents: this.intentBuffer.toArray(),
160
+ };
161
+ }
162
+ /**
163
+ * Returns the agent's DID (stable identifier derived from its VaultysId).
164
+ * Falls back to the control-plane-assigned id if VaultysId is not yet loaded.
165
+ */
166
+ getDid() {
167
+ return this.vaultysId?.toVersion(1).did ?? this.id;
168
+ }
169
+ getStatus() {
170
+ return this._status;
171
+ }
172
+ /** Returns the current peer catalog (agents this agent has grants to talk to). */
173
+ getPeerCatalog() {
174
+ return [...this.peerCatalog];
175
+ }
176
+ /**
177
+ * Invoke a peer agent via WebRTC.
178
+ * Throws if the peer manager is not ready or the target is not in the catalog.
179
+ */
180
+ async invokePeer(targetDid, action, params = {}) {
181
+ if (!this.peerManager)
182
+ throw new Error("Peer manager not initialised");
183
+ return this.peerManager.invoke(targetDid, action, params);
184
+ }
185
+ getRecentLogs(limit = 200) {
186
+ return this.logBuffer.toArray().slice(-limit);
187
+ }
188
+ getRecentIntents(limit = 100) {
189
+ return this.intentBuffer.toArray().slice(-limit);
190
+ }
191
+ // ---- Private helpers ----
192
+ setStatus(s) {
193
+ if (this._status === s)
194
+ return;
195
+ this._status = s;
196
+ this.emit("status_changed", { status: s });
197
+ }
198
+ log(level, message, data) {
199
+ const entry = {
200
+ ts: new Date().toISOString(),
201
+ level,
202
+ message,
203
+ data,
204
+ };
205
+ this.logBuffer.push(entry);
206
+ this.emit("log", entry);
207
+ }
208
+ async initVaultysId(identityPath) {
209
+ const dir = path.dirname(identityPath);
210
+ if (!fs.existsSync(dir))
211
+ fs.mkdirSync(dir, { recursive: true });
212
+ if (fs.existsSync(identityPath)) {
213
+ this.log("info", `Loading existing VaultysId identity from ${identityPath}`);
214
+ const secret = fs.readFileSync(identityPath, "utf-8").trim();
215
+ return VaultysId.fromSecret(secret, "base64").toVersion(1);
216
+ }
217
+ this.log("info", `Creating new VaultysId identity at ${identityPath}`);
218
+ const vid = await VaultysId.generateMachine();
219
+ fs.writeFileSync(identityPath, vid.toVersion(1).getSecret("base64"), "utf-8");
220
+ return vid.toVersion(1);
221
+ }
222
+ // ---- Transport connection ----
223
+ connect() {
224
+ if (this.stopped)
225
+ return;
226
+ if (this.config.peerjsControlPlaneId) {
227
+ this.connectViaPeerjs();
228
+ }
229
+ else {
230
+ this.connectViaWs();
231
+ }
232
+ }
233
+ /**
234
+ * Schedule a reconnect attempt with exponential backoff + ±20 % jitter.
235
+ * Delay starts at 2 s and doubles each attempt, capped at 60 s.
236
+ * Resets to 0 after a successful authentication.
237
+ */
238
+ scheduleReconnect() {
239
+ if (this.stopped)
240
+ return;
241
+ if (this.reconnectTimer) {
242
+ clearTimeout(this.reconnectTimer);
243
+ this.reconnectTimer = null;
244
+ }
245
+ const base = Math.min(2000 * 2 ** this.reconnectAttempts, 60000);
246
+ const jitter = base * 0.2 * (Math.random() * 2 - 1);
247
+ const delay = Math.round(base + jitter);
248
+ this.reconnectAttempts++;
249
+ this.log("info", `Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.reconnectAttempts})`);
250
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
251
+ }
252
+ resetReconnectBackoff() {
253
+ this.reconnectAttempts = 0;
254
+ }
255
+ connectViaPeerjs() {
256
+ if (this.stopped)
257
+ return;
258
+ const controlPlanePeerId = this.config.peerjsControlPlaneId;
259
+ this.log("info", `Connecting to control plane via PeerJS: peer=${controlPlanePeerId}`);
260
+ this.setStatus("connecting");
261
+ this.authChallenger = null;
262
+ this.authSessionId = null;
263
+ this.reAuthPending = false;
264
+ // Destroy old peer before creating a new one
265
+ if (this.peerjsPeer) {
266
+ this.peerjsPeer.destroy();
267
+ this.peerjsPeer = null;
268
+ }
269
+ this.peerjsConn = null;
270
+ // Parse optional custom signaling server
271
+ const serverUrl = this.config.peerjsServerUrl;
272
+ const peerOptions = serverUrl
273
+ ? (() => {
274
+ try {
275
+ const parsed = new URL(serverUrl);
276
+ return {
277
+ host: parsed.hostname,
278
+ port: parsed.port
279
+ ? parseInt(parsed.port, 10)
280
+ : parsed.protocol === "https:"
281
+ ? 443
282
+ : 80,
283
+ path: parsed.pathname || "/",
284
+ secure: parsed.protocol === "https:",
285
+ debug: 1,
286
+ };
287
+ }
288
+ catch {
289
+ return { host: serverUrl, secure: true, debug: 1 };
290
+ }
291
+ })()
292
+ : { host: "0.peerjs.com", port: 443, path: "/", secure: true, debug: 1 };
293
+ // peerjs is required at module load (after polyfills) — construct directly.
294
+ const { Peer } = PeerJS;
295
+ const peer = new Peer(peerOptions);
296
+ this.peerjsPeer = peer;
297
+ peer.on("open", () => {
298
+ if (this.stopped) {
299
+ peer.destroy();
300
+ return;
301
+ }
302
+ // Guard: a newer peer may have been created while this one was reconnecting.
303
+ if (this.peerjsPeer !== peer) {
304
+ peer.destroy();
305
+ return;
306
+ }
307
+ this.log("info", `PeerJS peer ready (id=${peer.id}) — connecting to control plane`);
308
+ const conn = peer.connect(controlPlanePeerId, { reliable: true });
309
+ this.peerjsConn = conn;
310
+ conn.on("open", () => {
311
+ this.log("info", "PeerJS DataConnection open — awaiting auth challenge");
312
+ });
313
+ conn.on("data", (raw) => {
314
+ const data = typeof raw === "string" ? raw : JSON.stringify(raw);
315
+ this.handleMessage(data);
316
+ });
317
+ conn.on("error", (err) => {
318
+ if (this.stopped)
319
+ return;
320
+ if (this.peerjsConn !== conn)
321
+ return; // stale
322
+ this.log("error", "PeerJS connection error", err);
323
+ // close event may not fire after a DataChannel error — schedule directly
324
+ this.peerjsConn = null;
325
+ if (this.heartbeatTimer) {
326
+ clearInterval(this.heartbeatTimer);
327
+ this.heartbeatTimer = null;
328
+ }
329
+ this.setStatus("disconnected");
330
+ this.scheduleReconnect();
331
+ });
332
+ conn.on("close", () => {
333
+ if (this.stopped)
334
+ return;
335
+ if (this.peerjsConn !== conn)
336
+ return; // stale connection
337
+ this.log("warn", "PeerJS connection closed");
338
+ this.setStatus("disconnected");
339
+ if (this.heartbeatTimer) {
340
+ clearInterval(this.heartbeatTimer);
341
+ this.heartbeatTimer = null;
342
+ }
343
+ this.peerjsConn = null;
344
+ this.scheduleReconnect();
345
+ });
346
+ });
347
+ peer.on("error", (err) => {
348
+ if (this.stopped)
349
+ return;
350
+ if (this.peerjsPeer !== peer)
351
+ return; // stale peer
352
+ this.log("error", "PeerJS peer error", err);
353
+ // Null out before destroy() for the same recursion reason as "disconnected".
354
+ this.peerjsPeer = null;
355
+ this.peerjsConn = null;
356
+ if (this.heartbeatTimer) {
357
+ clearInterval(this.heartbeatTimer);
358
+ this.heartbeatTimer = null;
359
+ }
360
+ peer.destroy();
361
+ this.setStatus("disconnected");
362
+ this.scheduleReconnect();
363
+ });
364
+ peer.on("disconnected", () => {
365
+ if (this.stopped)
366
+ return;
367
+ if (this.peerjsPeer !== peer)
368
+ return; // stale peer — ignore
369
+ // Don't use peer.reconnect(): if it fails silently, peerjs destroys the peer
370
+ // with no error event, the event loop drains, and the process exits quietly.
371
+ // Instead, destroy this peer and let our backoff loop create a fresh one.
372
+ //
373
+ // IMPORTANT: null out peerjsPeer BEFORE calling peer.destroy().
374
+ // peer.destroy() → disconnect() emits "disconnected" synchronously, which
375
+ // re-enters this handler. If peerjsPeer is still set at that point the guard
376
+ // passes and scheduleReconnect() fires twice, double-incrementing the backoff.
377
+ this.log("warn", "PeerJS signaling server disconnected — scheduling reconnect");
378
+ this.peerjsPeer = null;
379
+ this.peerjsConn = null;
380
+ if (this.heartbeatTimer) {
381
+ clearInterval(this.heartbeatTimer);
382
+ this.heartbeatTimer = null;
383
+ }
384
+ peer.destroy(); // recursive "disconnected" now hits the stale-peer guard → no-op
385
+ this.setStatus("disconnected");
386
+ this.scheduleReconnect();
387
+ });
388
+ }
389
+ // ---- WebSocket connection ----
390
+ connectViaWs() {
391
+ if (this.stopped)
392
+ return;
393
+ const wsUrl = this.config.controlPlaneWsUrl ?? "ws://localhost:8080";
394
+ this.log("info", `Connecting to control plane: ${wsUrl}`);
395
+ this.setStatus("connecting");
396
+ this.authChallenger = null;
397
+ this.authSessionId = null;
398
+ this.reAuthPending = false;
399
+ let ws;
400
+ try {
401
+ // Capture socket reference locally so the onclose closure can detect if it
402
+ // belongs to a stale socket that has been superseded by a newer connect() call.
403
+ // When the server closes the OLD socket after the new one authenticates
404
+ // ("replacing old connection"), that close event must not trigger yet another
405
+ // reconnect — this.ws already points to the new socket at that point.
406
+ ws = new WebSocket(wsUrl);
407
+ }
408
+ catch (err) {
409
+ this.log("error", "Failed to create WebSocket (invalid URL?)", err);
410
+ this.setStatus("disconnected");
411
+ this.scheduleReconnect();
412
+ return;
413
+ }
414
+ this.ws = ws;
415
+ ws.onopen = () => {
416
+ this.log("info", "Connected to control plane — awaiting auth challenge");
417
+ };
418
+ ws.onmessage = (event) => {
419
+ this.handleMessage(event.data);
420
+ };
421
+ ws.onerror = (error) => {
422
+ this.log("error", "WebSocket error", error);
423
+ };
424
+ ws.onclose = () => {
425
+ if (this.stopped)
426
+ return;
427
+ // Guard: if this.ws has already moved to a newer socket, this close event
428
+ // is from a superseded connection (e.g., the server closed our old socket
429
+ // when a newer connection authenticated). Ignore it — the active socket is fine.
430
+ if (this.ws !== ws)
431
+ return;
432
+ this.log("warn", "Disconnected from control plane");
433
+ this.setStatus("disconnected");
434
+ if (this.heartbeatTimer) {
435
+ clearInterval(this.heartbeatTimer);
436
+ this.heartbeatTimer = null;
437
+ }
438
+ this.scheduleReconnect();
439
+ };
440
+ }
441
+ send(message) {
442
+ const data = JSON.stringify(message);
443
+ if (this.peerjsConn) {
444
+ if (!this.peerjsConn.open) {
445
+ this.log("error", "PeerJS connection not open — cannot send message");
446
+ return;
447
+ }
448
+ try {
449
+ this.peerjsConn.send(data);
450
+ }
451
+ catch (err) {
452
+ this.log("error", "Failed to send PeerJS message", err);
453
+ }
454
+ return;
455
+ }
456
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
457
+ this.log("error", "WebSocket not open — cannot send message");
458
+ return;
459
+ }
460
+ try {
461
+ this.ws.send(data);
462
+ }
463
+ catch (err) {
464
+ this.log("error", "Failed to send message", err);
465
+ }
466
+ }
467
+ sendHeartbeat() {
468
+ const daily = this.getDailyTokenUsageForBudget();
469
+ const msg = {
470
+ messageId: `heartbeat-${Date.now()}`,
471
+ type: "heartbeat",
472
+ agentId: this.id,
473
+ payload: {
474
+ uptime: process.uptime(),
475
+ memory: process.memoryUsage(),
476
+ name: this.config.name,
477
+ tokenUsage: {
478
+ total: this._tokenUsageTotal,
479
+ sinceLastSync: this._tokenUsageSinceLastSync,
480
+ daily,
481
+ },
482
+ },
483
+ timestamp: new Date().toISOString(),
484
+ };
485
+ this.send(msg);
486
+ this.lastHeartbeat = new Date();
487
+ // Reset the sync counter for next heartbeat
488
+ this._tokenUsageSinceLastSync = { promptTokens: 0, completionTokens: 0 };
489
+ this.emit("heartbeat", { uptime: process.uptime() });
490
+ }
491
+ sendResult(intentId, result) {
492
+ this.send({
493
+ messageId: `result-${Date.now()}`,
494
+ type: "result",
495
+ agentId: this.id,
496
+ payload: result,
497
+ timestamp: new Date().toISOString(),
498
+ });
499
+ }
500
+ sendAck(messageId, success, reason) {
501
+ this.send({
502
+ messageId: `ack-${Date.now()}`,
503
+ type: "intent_ack",
504
+ agentId: this.id,
505
+ payload: { messageId, success, reason },
506
+ timestamp: new Date().toISOString(),
507
+ });
508
+ }
509
+ // ---- Message routing ----
510
+ handleMessage(data) {
511
+ try {
512
+ const message = JSON.parse(data);
513
+ switch (message.type) {
514
+ case "auth_challenge":
515
+ this.handleAuthChallenge(message).catch((e) => this.log("error", "handleAuthChallenge error", e));
516
+ break;
517
+ case "auth_complete":
518
+ this.handleAuthComplete(message).catch((e) => this.log("error", "handleAuthComplete error", e));
519
+ break;
520
+ case "auth_failed":
521
+ this.handleAuthFailed(message);
522
+ break;
523
+ case "registration_pending":
524
+ this.handleRegistrationPending(message);
525
+ break;
526
+ case "registration_approved":
527
+ this.handleRegistrationApproved(message);
528
+ break;
529
+ case "registration_rejected":
530
+ this.handleRegistrationRejected(message);
531
+ break;
532
+ case "update_capabilities":
533
+ this.handleUpdateCapabilities(message);
534
+ break;
535
+ case "delegation_update":
536
+ this.handleDelegationUpdateMsg(message);
537
+ break;
538
+ case "agent_peer_catalog":
539
+ this.handleAgentPeerCatalog(message);
540
+ break;
541
+ case "llm_config":
542
+ this.onLlmConfig(message.payload).catch((e) => this.log("error", "onLlmConfig error", e));
543
+ break;
544
+ case "skills_config":
545
+ this.onSkillsConfig(message.payload).catch((e) => this.log("error", "onSkillsConfig error", e));
546
+ break;
547
+ case "tool_approval_response":
548
+ this.handleToolApprovalResponse(message).catch((e) => this.log("error", "handleToolApprovalResponse error", e));
549
+ break;
550
+ case "task_enqueue":
551
+ this.handleTaskEnqueue(message).catch((e) => this.log("error", "handleTaskEnqueue error", e));
552
+ break;
553
+ case "schedule_update":
554
+ this.handleScheduleUpdate(message).catch((e) => this.log("error", "handleScheduleUpdate error", e));
555
+ break;
556
+ case "schedule_delete":
557
+ this.handleScheduleDelete(message).catch((e) => this.log("error", "handleScheduleDelete error", e));
558
+ break;
559
+ case "intent":
560
+ if (this._status !== "connected") {
561
+ this.log("warn", "Received intent before auth — ignoring");
562
+ return;
563
+ }
564
+ this.handleIntent(message);
565
+ break;
566
+ case "chat_message":
567
+ if (this._status !== "connected") {
568
+ this.log("warn", "Received chat_message before auth — ignoring");
569
+ return;
570
+ }
571
+ this.handleChatMessageProtocol(message);
572
+ break;
573
+ case "get_chat_sessions":
574
+ this.handleGetChatSessions(message).catch((e) => this.log("error", "handleGetChatSessions error", e));
575
+ break;
576
+ case "get_chat_history":
577
+ this.handleGetChatHistory(message).catch((e) => this.log("error", "handleGetChatHistory error", e));
578
+ break;
579
+ case "policy_update":
580
+ if (this._status !== "connected") {
581
+ this.log("warn", "Received policy before auth — ignoring");
582
+ return;
583
+ }
584
+ this.handlePolicyUpdate(message);
585
+ break;
586
+ case "knowledge_sync":
587
+ this.handleKnowledgeSync(message).catch((e) => this.log("error", "handleKnowledgeSync error", e));
588
+ break;
589
+ case "pong":
590
+ break;
591
+ case "error":
592
+ this.log("error", "Error from control plane", message.payload);
593
+ break;
594
+ default:
595
+ this.log("warn", `Unknown message type: ${message.type}`);
596
+ }
597
+ }
598
+ catch (err) {
599
+ this.log("error", "Error handling message", err);
600
+ }
601
+ }
602
+ // ---- Auth ----
603
+ async handleAuthChallenge(message) {
604
+ const payload = message.payload;
605
+ if (!this.vaultysId)
606
+ return;
607
+ try {
608
+ if (!this.authChallenger && !payload.data && this.reAuthPending) {
609
+ this.authSessionId = payload.sessionId;
610
+ this.reAuthPending = false;
611
+ this.startAuthHandshake();
612
+ }
613
+ else if (!this.authChallenger && !payload.data && !this.authSessionId) {
614
+ this.authSessionId = payload.sessionId;
615
+ this.send({
616
+ messageId: `register-${Date.now()}`,
617
+ type: "register",
618
+ payload: { name: this.config.name, version: "0.0.1" },
619
+ timestamp: new Date().toISOString(),
620
+ });
621
+ this.log("info", "Sent registration request");
622
+ }
623
+ else if (!this.authChallenger && !payload.data && this.authSessionId) {
624
+ this.authSessionId = payload.sessionId;
625
+ this.startAuthHandshake();
626
+ }
627
+ else if (this.authChallenger) {
628
+ const serverCert = Buffer.from(payload.data, "base64");
629
+ await this.authChallenger.update(serverCert);
630
+ const cert = this.authChallenger.getCertificate();
631
+ this.send({
632
+ messageId: `auth-${Date.now()}`,
633
+ type: "auth_challenge",
634
+ payload: {
635
+ sessionId: this.authSessionId,
636
+ data: Buffer.from(cert).toString("base64"),
637
+ name: this.config.name,
638
+ capabilities: this.capabilities,
639
+ },
640
+ timestamp: new Date().toISOString(),
641
+ });
642
+ }
643
+ }
644
+ catch (err) {
645
+ this.log("error", "Error in auth challenge", err);
646
+ this.authChallenger = null;
647
+ this.authSessionId = null;
648
+ }
649
+ }
650
+ startAuthHandshake() {
651
+ if (!this.vaultysId)
652
+ return;
653
+ this.authChallenger = new Challenger(this.vaultysId.toVersion(1));
654
+ this.authChallenger.createChallenge("p2p", "auth");
655
+ const cert = this.authChallenger.getCertificate();
656
+ this.send({
657
+ messageId: `auth-${Date.now()}`,
658
+ type: "auth_challenge",
659
+ payload: {
660
+ sessionId: this.authSessionId,
661
+ data: Buffer.from(cert).toString("base64"),
662
+ name: this.config.name,
663
+ capabilities: this.capabilities,
664
+ },
665
+ timestamp: new Date().toISOString(),
666
+ });
667
+ this.log("debug", "Sent initial auth challenge");
668
+ }
669
+ async handleAuthComplete(message) {
670
+ const payload = message.payload;
671
+ this.id = payload.agentId;
672
+ if (Array.isArray(payload.capabilities)) {
673
+ this.capabilities = payload.capabilities;
674
+ }
675
+ else if (this.authChallenger) {
676
+ try {
677
+ const ctx = this.authChallenger.getContext();
678
+ const metaCaps = ctx.metadata?.pk2?.capabilities;
679
+ // Handle both native array (new certs) and legacy JSON-stringified string
680
+ if (Array.isArray(metaCaps))
681
+ this.capabilities = metaCaps;
682
+ else if (typeof metaCaps === "string")
683
+ this.capabilities = JSON.parse(metaCaps);
684
+ }
685
+ catch {
686
+ /* keep existing */
687
+ }
688
+ }
689
+ // Read policy governance metadata from cert (native types — no JSON.parse needed)
690
+ if (this.authChallenger) {
691
+ try {
692
+ const ctx = this.authChallenger.getContext();
693
+ const pk2 = ctx.metadata?.pk2;
694
+ if (pk2) {
695
+ this.resourceLimits =
696
+ pk2.resourceLimits ?? null;
697
+ this.policyId = pk2.policyId ?? null;
698
+ this.policyExpiresAt =
699
+ pk2.policyExpiresAt ?? null;
700
+ if (this.resourceLimits || this.policyId) {
701
+ this.log("info", `Policy applied from cert — id: ${this.policyId ?? "none"}, limits: ${JSON.stringify(this.resourceLimits)}`);
702
+ }
703
+ }
704
+ }
705
+ catch {
706
+ /* keep existing limits */
707
+ }
708
+ }
709
+ // Extract server public key from the completed cert before clearing the challenger.
710
+ // In the Challenger protocol the agent is the initiator (pk1 = agent key, pk2 = server key).
711
+ // We need pk2 — the server's (responder's) key — for intent signature verification.
712
+ if (this.authChallenger) {
713
+ try {
714
+ const certBuf = this.authChallenger.getCertificate();
715
+ const deserialized = Challenger.deserializeCertificate(certBuf);
716
+ if (deserialized?.pk2) {
717
+ const normalizedKey = Buffer.from(VaultysId.fromId(deserialized.pk2).toVersion(1).id);
718
+ this.serverPublicKey = normalizedKey;
719
+ this.peerManager?.setServerPublicKey(normalizedKey);
720
+ }
721
+ }
722
+ catch {
723
+ /* non-fatal — verification will warn on first intent */
724
+ }
725
+ }
726
+ this.authChallenger = null;
727
+ this.authSessionId = null;
728
+ this.reAuthPending = false;
729
+ this.resetReconnectBackoff();
730
+ this.setStatus("connected");
731
+ this.log("info", `Auth complete — agent id: ${this.id}, did: ${payload.did}`);
732
+ // Start P2P listener (idempotent — only starts once)
733
+ if (this.peerManager && !this._peerListenerStarted) {
734
+ this._peerListenerStarted = true;
735
+ this.peerManager.startListening().catch((err) => {
736
+ this.log("warn", "Failed to start P2P listener", err);
737
+ });
738
+ }
739
+ if (this.heartbeatTimer)
740
+ clearInterval(this.heartbeatTimer);
741
+ this.heartbeatTimer = setInterval(() => {
742
+ // Send heartbeat over whichever transport is currently open.
743
+ const wsOpen = this.ws?.readyState === WebSocket.OPEN;
744
+ const pjOpen = !!this.peerjsConn?.open;
745
+ if (wsOpen || pjOpen)
746
+ this.sendHeartbeat();
747
+ }, 30000);
748
+ // Call the subclass hook for additional auth-complete processing
749
+ await this.onAuthComplete(payload);
750
+ }
751
+ handleAuthFailed(message) {
752
+ const payload = message.payload;
753
+ this.log("error", `Auth failed: ${payload.reason}`);
754
+ this.authChallenger = null;
755
+ this.authSessionId = null;
756
+ this.reAuthPending = false;
757
+ }
758
+ handleRegistrationPending(message) {
759
+ const payload = message.payload;
760
+ this.setStatus("pending_approval");
761
+ this.log("info", `Registration pending (id: ${payload.registrationId}): ${payload.message}`);
762
+ }
763
+ handleRegistrationApproved(message) {
764
+ const payload = message.payload;
765
+ this.capabilities = payload.capabilities;
766
+ this.log("info", `Registration approved — capabilities: ${payload.capabilities.join(", ")}`);
767
+ }
768
+ handleRegistrationRejected(message) {
769
+ const payload = message.payload;
770
+ this.log("error", `Registration rejected: ${payload.reason}`);
771
+ }
772
+ handleUpdateCapabilities(message) {
773
+ const payload = message.payload;
774
+ this.capabilities = payload.capabilities;
775
+ // Store incoming policy metadata so it is available after the re-auth cert is issued
776
+ if (payload.resourceLimits !== undefined)
777
+ this.resourceLimits = payload.resourceLimits ?? null;
778
+ if (payload.policyId !== undefined)
779
+ this.policyId = payload.policyId ?? null;
780
+ if (payload.policyExpiresAt !== undefined)
781
+ this.policyExpiresAt = payload.policyExpiresAt ?? null;
782
+ this.authChallenger = null;
783
+ this.authSessionId = null;
784
+ this.reAuthPending = true;
785
+ this.log("info", `Capabilities updated: ${payload.capabilities.join(", ")} — re-auth pending`);
786
+ }
787
+ // ---- Intent handling ----
788
+ handleIntent(message) {
789
+ const { messageId, payload } = message;
790
+ const { action, params, userDid } = payload;
791
+ // Verify the control-plane signature before doing anything
792
+ if (!this.verifyIntentSignature(message)) {
793
+ this.log("error", `Rejected unsigned/invalid intent ${messageId} (${action})`);
794
+ const result = {
795
+ intentId: messageId,
796
+ status: "failed",
797
+ error: "Intent signature verification failed",
798
+ executedAt: new Date(),
799
+ };
800
+ this.sendResult(messageId, result);
801
+ this.sendAck(messageId, false, "Intent signature verification failed");
802
+ return;
803
+ }
804
+ const entry = {
805
+ intentId: messageId,
806
+ action,
807
+ params,
808
+ status: "pending",
809
+ receivedAt: new Date().toISOString(),
810
+ };
811
+ this.intentBuffer.push(entry);
812
+ this.emit("intent_received", { intentId: messageId, action, params });
813
+ (async () => {
814
+ try {
815
+ this.log("info", `Intent received: ${action} (${messageId})`);
816
+ // "agent" is the legacy name for "agent_communication"
817
+ const effectiveAction = action === "agent" ? "agent_communication" : action;
818
+ if (!this.capabilities.includes(effectiveAction)) {
819
+ throw new Error(`Capability '${action}' not granted`);
820
+ }
821
+ // ---- Policy enforcement ----
822
+ // 1. Reject if the governing policy has expired
823
+ if (this.policyExpiresAt) {
824
+ const expiry = new Date(this.policyExpiresAt).getTime();
825
+ if (!isNaN(expiry) && Date.now() > expiry) {
826
+ throw new Error(`Policy '${this.policyId ?? "unknown"}' has expired — action blocked`);
827
+ }
828
+ }
829
+ // 2. Reject if the daily token budget is exhausted
830
+ if (this.resourceLimits?.maxTokensPerDay != null) {
831
+ const daily = this.getDailyTokenUsageForBudget();
832
+ const usedToday = (daily?.promptTokens ?? 0) + (daily?.completionTokens ?? 0);
833
+ if (usedToday >= this.resourceLimits.maxTokensPerDay) {
834
+ throw new Error(`Daily token budget exhausted (used ${usedToday} / limit ${this.resourceLimits.maxTokensPerDay})`);
835
+ }
836
+ }
837
+ // 3. Reject if the hourly request rate is exceeded
838
+ if (this.resourceLimits?.maxRequestsPerHour != null) {
839
+ const now = Date.now();
840
+ const hourMs = 60 * 60 * 1000;
841
+ if (now - this._requestsThisHour.hourStart > hourMs) {
842
+ // Roll over to a fresh window
843
+ this._requestsThisHour = { count: 0, hourStart: now };
844
+ }
845
+ if (this._requestsThisHour.count >=
846
+ this.resourceLimits.maxRequestsPerHour) {
847
+ const resetIn = Math.ceil((this._requestsThisHour.hourStart + hourMs - now) / 1000);
848
+ throw new Error(`Hourly request limit reached (${this.resourceLimits.maxRequestsPerHour} req/h) — resets in ${resetIn}s`);
849
+ }
850
+ this._requestsThisHour.count++;
851
+ }
852
+ if (userDid) {
853
+ const ok = await this.verifyUserDelegation(userDid, effectiveAction);
854
+ if (!ok)
855
+ throw new Error(`User '${userDid}' has no valid delegation for '${action}'`);
856
+ this.log("info", `Delegation verified for ${userDid}`);
857
+ }
858
+ const output = await this.executeIntent(action, params, userDid, messageId);
859
+ entry.status = "success";
860
+ entry.output = output;
861
+ entry.completedAt = new Date().toISOString();
862
+ const result = {
863
+ intentId: messageId,
864
+ status: "success",
865
+ output,
866
+ executedAt: new Date(),
867
+ };
868
+ this.sendResult(messageId, result);
869
+ this.sendAck(messageId, true);
870
+ this.emit("intent_result", {
871
+ intentId: messageId,
872
+ status: "success",
873
+ output,
874
+ });
875
+ }
876
+ catch (error) {
877
+ const errMsg = error instanceof Error ? error.message : String(error);
878
+ entry.status = "failed";
879
+ entry.error = errMsg;
880
+ entry.completedAt = new Date().toISOString();
881
+ const result = {
882
+ intentId: messageId,
883
+ status: "failed",
884
+ error: errMsg,
885
+ executedAt: new Date(),
886
+ };
887
+ this.sendResult(messageId, result);
888
+ this.sendAck(messageId, false, errMsg);
889
+ this.emit("intent_result", {
890
+ intentId: messageId,
891
+ status: "failed",
892
+ error: errMsg,
893
+ });
894
+ this.log("error", `Intent ${messageId} failed: ${errMsg}`);
895
+ }
896
+ })();
897
+ }
898
+ // ---- Chat (streaming via WS) ----
899
+ handleChatMessageProtocol(message) {
900
+ const payload = message.payload;
901
+ const { conversationId, messages } = payload;
902
+ this.log("info", `Chat request ${conversationId} (${messages.length} messages)`);
903
+ const sendChunk = (chunk, done, isError, errorCode) => {
904
+ if (isError) {
905
+ this.send({
906
+ messageId: `chat-resp-${Date.now()}`,
907
+ type: "chat_response",
908
+ agentId: this.id,
909
+ payload: {
910
+ conversationId,
911
+ error: chunk,
912
+ ...(errorCode ? { errorCode } : {}),
913
+ done: true,
914
+ },
915
+ timestamp: new Date().toISOString(),
916
+ });
917
+ }
918
+ else if (done && !chunk) {
919
+ this.send({
920
+ messageId: `chat-resp-${Date.now()}`,
921
+ type: "chat_response",
922
+ agentId: this.id,
923
+ payload: { conversationId, done: true },
924
+ timestamp: new Date().toISOString(),
925
+ });
926
+ }
927
+ else {
928
+ this.send({
929
+ messageId: `chat-resp-${Date.now()}`,
930
+ type: "chat_response",
931
+ agentId: this.id,
932
+ payload: {
933
+ conversationId,
934
+ chunk,
935
+ ...(done ? { done: true } : {}),
936
+ },
937
+ timestamp: new Date().toISOString(),
938
+ });
939
+ }
940
+ };
941
+ this.executeChat(messages, conversationId, sendChunk).catch((err) => {
942
+ const errMsg = err instanceof Error ? err.message : String(err);
943
+ this.log("error", `Chat ${conversationId} failed: ${errMsg}`);
944
+ sendChunk(errMsg, true, true);
945
+ });
946
+ }
947
+ // ---- Delegations ----
948
+ handleDelegationUpdateMsg(message) {
949
+ const payload = message.payload;
950
+ this.onDelegationUpdate(payload).catch((e) => this.log("error", "onDelegationUpdate error", e));
951
+ }
952
+ handleAgentPeerCatalog(message) {
953
+ try {
954
+ const payload = message.payload;
955
+ const peers = payload.peers ?? [];
956
+ this.peerCatalog = peers;
957
+ this.peerManager?.updatePeerCatalog(peers);
958
+ this.log("info", `Peer catalog updated: ${peers.length} peer grant(s)`);
959
+ this.onPeerCatalogUpdated(peers).catch((e) => this.log("error", "onPeerCatalogUpdated error", e));
960
+ }
961
+ catch (err) {
962
+ this.log("error", "Error handling agent peer catalog", err);
963
+ }
964
+ }
965
+ /**
966
+ * Verify an intent message's ECDSA signature produced by the control plane.
967
+ */
968
+ verifyIntentSignature(message) {
969
+ if (!this.serverPublicKey) {
970
+ this.log("warn", "Server public key unavailable — cannot verify intent signature");
971
+ return false;
972
+ }
973
+ const ok = verifyIntentMessage(message, this.serverPublicKey);
974
+ if (!ok) {
975
+ this.log("warn", `Intent signature verification failed for ${message.messageId}`);
976
+ }
977
+ return ok;
978
+ }
979
+ async verifyUserDelegation(userDid, capability) {
980
+ if (!this.serverPublicKey) {
981
+ this.log("warn", "Server public key not available — cannot verify delegation");
982
+ return false;
983
+ }
984
+ // Base implementation: no delegations stored. Subclass overrides this if it has a DB.
985
+ void userDid;
986
+ void capability;
987
+ return false;
988
+ }
989
+ // ---- Policy ----
990
+ /**
991
+ * @deprecated The `policy_update` message is superseded by the cert-reissue path.
992
+ */
993
+ handlePolicyUpdate(message) {
994
+ const { messageId } = message;
995
+ this.log("warn", "Received deprecated policy_update message — policies are now enforced via cert reissue");
996
+ this.sendAck(messageId, true);
997
+ }
998
+ // ---- PeerJS server URL helper ----
999
+ getPeerjsServerUrl() {
1000
+ return this.config.peerjsServer ?? null;
1001
+ }
1002
+ }
1003
+ //# sourceMappingURL=base-agent.js.map