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