@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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/base-agent.d.ts +182 -0
- package/dist/base-agent.d.ts.map +1 -0
- package/dist/base-agent.js +1003 -0
- package/dist/base-agent.js.map +1 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/intent-verify.d.ts +30 -0
- package/dist/intent-verify.d.ts.map +1 -0
- package/dist/intent-verify.js +55 -0
- package/dist/intent-verify.js.map +1 -0
- package/dist/peer-grant-verify.d.ts +16 -0
- package/dist/peer-grant-verify.d.ts.map +1 -0
- package/dist/peer-grant-verify.js +39 -0
- package/dist/peer-grant-verify.js.map +1 -0
- package/dist/peer-manager.d.ts +63 -0
- package/dist/peer-manager.d.ts.map +1 -0
- package/dist/peer-manager.js +448 -0
- package/dist/peer-manager.js.map +1 -0
- package/package.json +42 -0
- package/src/base-agent.ts +1332 -0
- package/src/config.ts +13 -0
- package/src/index.ts +8 -0
- package/src/intent-verify.ts +72 -0
- package/src/peer-grant-verify.ts +58 -0
- package/src/peer-manager.ts +696 -0
|
@@ -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
|