podwatch 1.0.2
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 +28 -0
- package/README.md +92 -0
- package/bin/podwatch.js +10 -0
- package/dist/classifier.d.ts +22 -0
- package/dist/classifier.d.ts.map +1 -0
- package/dist/classifier.js +157 -0
- package/dist/classifier.js.map +1 -0
- package/dist/hooks/cost.d.ts +26 -0
- package/dist/hooks/cost.d.ts.map +1 -0
- package/dist/hooks/cost.js +107 -0
- package/dist/hooks/cost.js.map +1 -0
- package/dist/hooks/lifecycle.d.ts +16 -0
- package/dist/hooks/lifecycle.d.ts.map +1 -0
- package/dist/hooks/lifecycle.js +273 -0
- package/dist/hooks/lifecycle.js.map +1 -0
- package/dist/hooks/security.d.ts +19 -0
- package/dist/hooks/security.d.ts.map +1 -0
- package/dist/hooks/security.js +128 -0
- package/dist/hooks/security.js.map +1 -0
- package/dist/hooks/sessions.d.ts +10 -0
- package/dist/hooks/sessions.d.ts.map +1 -0
- package/dist/hooks/sessions.js +53 -0
- package/dist/hooks/sessions.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/redact.d.ts +35 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +372 -0
- package/dist/redact.js.map +1 -0
- package/dist/scanner.d.ts +27 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +117 -0
- package/dist/scanner.js.map +1 -0
- package/dist/transmitter.d.ts +58 -0
- package/dist/transmitter.d.ts.map +1 -0
- package/dist/transmitter.js +654 -0
- package/dist/transmitter.js.map +1 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/updater.d.ts +168 -0
- package/dist/updater.d.ts.map +1 -0
- package/dist/updater.js +579 -0
- package/dist/updater.js.map +1 -0
- package/lib/installer.js +599 -0
- package/openclaw.plugin.json +59 -0
- package/package.json +56 -0
- package/skills/podwatch/SKILL.md +112 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Batched HTTP transmitter — queues events and flushes to Podwatch cloud.
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Configurable batch size and flush interval
|
|
7
|
+
* - Exponential backoff on failure
|
|
8
|
+
* - Buffer overflow protection (drops SAFE events first)
|
|
9
|
+
* - Graceful shutdown (flush on gateway_stop)
|
|
10
|
+
* - Credential access tracking (for exfiltration detection)
|
|
11
|
+
* - Known tool tracking (for first-time tool alerts)
|
|
12
|
+
* - Cached budget state (synced every 60s from dashboard)
|
|
13
|
+
* - Local audit log for dropped events
|
|
14
|
+
* - 402 trial-expired handling
|
|
15
|
+
*/
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.transmitter = void 0;
|
|
51
|
+
const fs = __importStar(require("node:fs"));
|
|
52
|
+
const path = __importStar(require("node:path"));
|
|
53
|
+
const os = __importStar(require("node:os"));
|
|
54
|
+
// Read version from package.json at build time (resolveJsonModule enabled)
|
|
55
|
+
const PLUGIN_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// State
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
let config = null;
|
|
60
|
+
let buffer = [];
|
|
61
|
+
let flushTimer = null;
|
|
62
|
+
let flushInProgress = false;
|
|
63
|
+
let retryBackoffMs = 1_000;
|
|
64
|
+
let retryCount = 0;
|
|
65
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
66
|
+
const MAX_RETRIES = 10;
|
|
67
|
+
const MAX_BUFFER_SIZE = 1_000;
|
|
68
|
+
const BUFFER_TARGET_SIZE = 900;
|
|
69
|
+
// --- Trial expired flag ---
|
|
70
|
+
let trialExpired = false;
|
|
71
|
+
const recentCredentialAccesses = [];
|
|
72
|
+
const CREDENTIAL_ACCESS_WINDOW_MS = 60_000; // 60 seconds
|
|
73
|
+
// --- Known tools tracking ---
|
|
74
|
+
const knownTools = new Set();
|
|
75
|
+
const KNOWN_TOOLS_MAX_SIZE = 10_000;
|
|
76
|
+
let activateTs = 0; // when the plugin started
|
|
77
|
+
let cachedBudget = null;
|
|
78
|
+
let budgetSyncTimer = null;
|
|
79
|
+
const BUDGET_SYNC_INTERVAL_MS = 300_000; // 5 minutes — acceptable with 95% tolerance buffer
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Audit log for dropped events
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
function getAuditLogPath() {
|
|
84
|
+
return path.join(os.homedir(), ".openclaw", "extensions", "podwatch", "audit.log");
|
|
85
|
+
}
|
|
86
|
+
const AUDIT_LOG_MAX_BYTES = 1_048_576; // 1 MB
|
|
87
|
+
function writeAuditLog(reason, eventCount, events) {
|
|
88
|
+
try {
|
|
89
|
+
const logPath = getAuditLogPath();
|
|
90
|
+
const logDir = path.dirname(logPath);
|
|
91
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
92
|
+
// Enforce size cap — rotate if at or over 1 MB
|
|
93
|
+
try {
|
|
94
|
+
const stats = fs.statSync(logPath);
|
|
95
|
+
if (stats.size >= AUDIT_LOG_MAX_BYTES) {
|
|
96
|
+
// Truncate: keep the last ~half of the file
|
|
97
|
+
const content = fs.readFileSync(logPath, "utf-8");
|
|
98
|
+
const half = Math.floor(content.length / 2);
|
|
99
|
+
// Find the first newline after the midpoint to avoid partial lines
|
|
100
|
+
const cutIdx = content.indexOf("\n", half);
|
|
101
|
+
const trimmed = cutIdx >= 0 ? content.slice(cutIdx + 1) : "";
|
|
102
|
+
fs.writeFileSync(logPath, trimmed, { mode: 0o600 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// File may not exist yet — that's fine, appendFileSync will create it
|
|
107
|
+
}
|
|
108
|
+
const entry = JSON.stringify({
|
|
109
|
+
ts: new Date().toISOString(),
|
|
110
|
+
reason,
|
|
111
|
+
eventCount,
|
|
112
|
+
eventTypes: events.map((e) => e.type),
|
|
113
|
+
});
|
|
114
|
+
fs.appendFileSync(logPath, entry + "\n", { mode: 0o600 });
|
|
115
|
+
// Ensure file permissions are 0o600 (owner read/write only)
|
|
116
|
+
fs.chmodSync(logPath, 0o600);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Best-effort — don't crash the plugin over audit logging
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// HTTP flush
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* Map internal event type to a resultStatus string that the dashboard API expects.
|
|
127
|
+
*/
|
|
128
|
+
function mapResultStatus(event) {
|
|
129
|
+
switch (event.type) {
|
|
130
|
+
case "tool_call":
|
|
131
|
+
return "invoked";
|
|
132
|
+
case "tool_result":
|
|
133
|
+
return event.success === false ? "error" : "success";
|
|
134
|
+
case "cost":
|
|
135
|
+
return "usage";
|
|
136
|
+
case "security":
|
|
137
|
+
return "alert";
|
|
138
|
+
case "budget_blocked":
|
|
139
|
+
return "blocked";
|
|
140
|
+
case "session_start":
|
|
141
|
+
return "session_start";
|
|
142
|
+
case "session_end":
|
|
143
|
+
return "session_end";
|
|
144
|
+
case "heartbeat":
|
|
145
|
+
return "heartbeat";
|
|
146
|
+
case "compaction":
|
|
147
|
+
return "compaction";
|
|
148
|
+
case "scan":
|
|
149
|
+
return "scan";
|
|
150
|
+
case "setup_warning":
|
|
151
|
+
return "warning";
|
|
152
|
+
case "alert":
|
|
153
|
+
return "alert";
|
|
154
|
+
default:
|
|
155
|
+
return event.type || "unknown";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Build a human-readable description from the internal event fields.
|
|
160
|
+
*/
|
|
161
|
+
function buildDescription(event) {
|
|
162
|
+
const parts = [];
|
|
163
|
+
switch (event.type) {
|
|
164
|
+
case "tool_call": {
|
|
165
|
+
const name = event.toolName ? String(event.toolName) : "unknown_tool";
|
|
166
|
+
parts.push(`Called: ${name}`);
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "tool_result": {
|
|
170
|
+
const name = event.toolName ? String(event.toolName) : "unknown_tool";
|
|
171
|
+
const status = event.success === false ? "error" : "success";
|
|
172
|
+
parts.push(`${name}: ${status}`);
|
|
173
|
+
if (event.error)
|
|
174
|
+
parts.push(String(event.error).slice(0, 200));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "cost": {
|
|
178
|
+
const model = event.model ? String(event.model) : "unknown_model";
|
|
179
|
+
const cost = typeof event.costUsd === "number" ? `$${event.costUsd.toFixed(4)}` : "$?";
|
|
180
|
+
const tokens = typeof event.totalTokens === "number" ? `${event.totalTokens} tokens` : "? tokens";
|
|
181
|
+
parts.push(`${model}: ${cost} (${tokens})`);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "security": {
|
|
185
|
+
if (event.pattern)
|
|
186
|
+
parts.push(String(event.pattern));
|
|
187
|
+
if (event.severity)
|
|
188
|
+
parts.push(`severity=${event.severity}`);
|
|
189
|
+
if (event.reason)
|
|
190
|
+
parts.push(String(event.reason));
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "session_start": {
|
|
194
|
+
parts.push("Session started");
|
|
195
|
+
if (event.sessionId)
|
|
196
|
+
parts.push(`id=${event.sessionId}`);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "session_end": {
|
|
200
|
+
parts.push("Session ended");
|
|
201
|
+
if (event.sessionId)
|
|
202
|
+
parts.push(`id=${event.sessionId}`);
|
|
203
|
+
if (typeof event.durationMs === "number")
|
|
204
|
+
parts.push(`duration=${Math.round(Number(event.durationMs) / 1000)}s`);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
default: {
|
|
208
|
+
if (event.type)
|
|
209
|
+
parts.push(String(event.type));
|
|
210
|
+
if (event.toolName)
|
|
211
|
+
parts.push(String(event.toolName));
|
|
212
|
+
if (event.pattern)
|
|
213
|
+
parts.push(String(event.pattern));
|
|
214
|
+
if (event.severity)
|
|
215
|
+
parts.push(`severity=${event.severity}`);
|
|
216
|
+
if (event.reason)
|
|
217
|
+
parts.push(String(event.reason));
|
|
218
|
+
if (event.message)
|
|
219
|
+
parts.push(String(event.message));
|
|
220
|
+
if (event.error)
|
|
221
|
+
parts.push(`error: ${String(event.error).slice(0, 200)}`);
|
|
222
|
+
if (event.loopDetected)
|
|
223
|
+
parts.push(`loop_detected (${event.messagesPerMinute} msg/min)`);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const desc = parts.join(": ");
|
|
228
|
+
return desc.slice(0, 2000);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Determine sessionType from the internal event.
|
|
232
|
+
* If the event already carries an explicit sessionType (e.g. set by cost handler
|
|
233
|
+
* for heartbeat-triggered LLM calls), use it directly.
|
|
234
|
+
*/
|
|
235
|
+
function mapSessionType(event) {
|
|
236
|
+
// Allow upstream hooks to pre-set sessionType (e.g. heartbeat cost events)
|
|
237
|
+
if (typeof event.sessionType === "string" && event.sessionType) {
|
|
238
|
+
return event.sessionType;
|
|
239
|
+
}
|
|
240
|
+
if (event.type === "heartbeat")
|
|
241
|
+
return "heartbeat";
|
|
242
|
+
if (event.type === "scan")
|
|
243
|
+
return "cron";
|
|
244
|
+
// Tool calls, tool results, cost, security, session events = interactive
|
|
245
|
+
if (event.type === "tool_call" || event.type === "tool_result" || event.type === "cost" ||
|
|
246
|
+
event.type === "security" || event.type === "budget_blocked" ||
|
|
247
|
+
event.type === "session_start" || event.type === "session_end" ||
|
|
248
|
+
event.type === "compaction" || event.type === "alert")
|
|
249
|
+
return "interactive";
|
|
250
|
+
return "unknown";
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Extract agentId from a sessionKey string.
|
|
254
|
+
* SessionKey format: "agent:<agentId>:<sessionType>:..."
|
|
255
|
+
*/
|
|
256
|
+
function extractAgentIdFromSessionKey(sessionKey) {
|
|
257
|
+
if (!sessionKey || typeof sessionKey !== "string")
|
|
258
|
+
return undefined;
|
|
259
|
+
const parts = sessionKey.split(":");
|
|
260
|
+
// Format: agent:<agentId>:<rest...>
|
|
261
|
+
if (parts.length >= 2 && parts[0] === "agent") {
|
|
262
|
+
return parts[1] || undefined;
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Resolve agentId with fallback chain:
|
|
268
|
+
* 1. event.agentId (if present)
|
|
269
|
+
* 2. Extract from event.sessionKey
|
|
270
|
+
* 3. Default to "main"
|
|
271
|
+
*/
|
|
272
|
+
function resolveAgentId(event) {
|
|
273
|
+
if (event.agentId != null && String(event.agentId).length > 0) {
|
|
274
|
+
return String(event.agentId);
|
|
275
|
+
}
|
|
276
|
+
const sessionKey = (event.sessionKey ?? event.sessionId);
|
|
277
|
+
if (sessionKey) {
|
|
278
|
+
const extracted = extractAgentIdFromSessionKey(sessionKey);
|
|
279
|
+
if (extracted)
|
|
280
|
+
return extracted;
|
|
281
|
+
}
|
|
282
|
+
return "main";
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Resolve sessionId with fallback chain:
|
|
286
|
+
* 1. event.sessionKey (if present)
|
|
287
|
+
* 2. event.sessionId
|
|
288
|
+
* 3. Default to "default"
|
|
289
|
+
*/
|
|
290
|
+
function resolveSessionId(event) {
|
|
291
|
+
if (event.sessionKey != null && String(event.sessionKey).length > 0) {
|
|
292
|
+
return String(event.sessionKey);
|
|
293
|
+
}
|
|
294
|
+
if (event.sessionId != null && String(event.sessionId).length > 0) {
|
|
295
|
+
return String(event.sessionId);
|
|
296
|
+
}
|
|
297
|
+
return "default";
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Resolve toolName — for cost events, use the model name instead of generic "cost".
|
|
301
|
+
*/
|
|
302
|
+
function resolveToolName(event) {
|
|
303
|
+
if (event.type === "cost" && typeof event.model === "string" && event.model) {
|
|
304
|
+
return event.model;
|
|
305
|
+
}
|
|
306
|
+
return (typeof event.toolName === "string" && event.toolName) || event.type || "system";
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Transform internal PodwatchEvent[] into the API-expected EventPayload[].
|
|
310
|
+
*/
|
|
311
|
+
function transformEvents(events) {
|
|
312
|
+
return events.map((event) => {
|
|
313
|
+
const transformed = {
|
|
314
|
+
eventId: typeof event.eventId === "string" ? event.eventId : crypto.randomUUID(),
|
|
315
|
+
timestamp: new Date(event.ts).toISOString(),
|
|
316
|
+
toolName: resolveToolName(event),
|
|
317
|
+
resultStatus: mapResultStatus(event),
|
|
318
|
+
description: buildDescription(event),
|
|
319
|
+
sessionType: mapSessionType(event),
|
|
320
|
+
agentId: resolveAgentId(event),
|
|
321
|
+
sessionId: resolveSessionId(event),
|
|
322
|
+
};
|
|
323
|
+
// Optional fields — only include if present
|
|
324
|
+
if (event.model != null)
|
|
325
|
+
transformed.model = String(event.model);
|
|
326
|
+
if (typeof event.inputTokens === "number")
|
|
327
|
+
transformed.inputTokens = event.inputTokens;
|
|
328
|
+
if (typeof event.outputTokens === "number")
|
|
329
|
+
transformed.outputTokens = event.outputTokens;
|
|
330
|
+
if (typeof event.cacheReadTokens === "number")
|
|
331
|
+
transformed.cacheReadTokens = event.cacheReadTokens;
|
|
332
|
+
if (typeof event.cacheWriteTokens === "number")
|
|
333
|
+
transformed.cacheWriteTokens = event.cacheWriteTokens;
|
|
334
|
+
if (typeof event.durationMs === "number")
|
|
335
|
+
transformed.durationMs = event.durationMs;
|
|
336
|
+
if (event.params != null)
|
|
337
|
+
transformed.toolArgs = event.params;
|
|
338
|
+
if (typeof event.redactedCount === "number")
|
|
339
|
+
transformed.redactedCount = event.redactedCount;
|
|
340
|
+
return transformed;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async function sendBatch(events) {
|
|
344
|
+
if (!config)
|
|
345
|
+
return false;
|
|
346
|
+
const payload = {
|
|
347
|
+
events: transformEvents(events),
|
|
348
|
+
skillVersion: PLUGIN_VERSION,
|
|
349
|
+
};
|
|
350
|
+
try {
|
|
351
|
+
const response = await fetch(`${config.endpoint}/events`, {
|
|
352
|
+
method: "POST",
|
|
353
|
+
headers: {
|
|
354
|
+
"Content-Type": "application/json",
|
|
355
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
356
|
+
},
|
|
357
|
+
body: JSON.stringify(payload),
|
|
358
|
+
signal: AbortSignal.timeout(30_000),
|
|
359
|
+
});
|
|
360
|
+
if (response.ok) {
|
|
361
|
+
retryBackoffMs = 1_000;
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
// 402 — trial expired, disable all future transmissions
|
|
365
|
+
if (response.status === 402) {
|
|
366
|
+
trialExpired = true;
|
|
367
|
+
console.warn("[podwatch] Trial expired (402) — event transmission disabled. Visit podwatch.app to upgrade.");
|
|
368
|
+
return true; // consume the batch (don't retry)
|
|
369
|
+
}
|
|
370
|
+
// Other 4xx — client error, drop events + audit log
|
|
371
|
+
if (response.status >= 400 && response.status < 500) {
|
|
372
|
+
console.error(`[podwatch] API ${response.status}: dropping ${events.length} events`);
|
|
373
|
+
writeAuditLog(`http_${response.status}`, events.length, events);
|
|
374
|
+
retryBackoffMs = 1_000;
|
|
375
|
+
return true; // Don't retry client errors
|
|
376
|
+
}
|
|
377
|
+
console.error(`[podwatch] API ${response.status}: will retry ${events.length} events`);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.error("[podwatch] Network error during flush:", err);
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Core flush
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
async function doFlush() {
|
|
389
|
+
if (flushInProgress || buffer.length === 0 || !config)
|
|
390
|
+
return;
|
|
391
|
+
// Trial expired — silently discard all buffered events
|
|
392
|
+
if (trialExpired) {
|
|
393
|
+
buffer.length = 0;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
flushInProgress = true;
|
|
397
|
+
try {
|
|
398
|
+
const batchSize = Math.min(buffer.length, config.batchSize);
|
|
399
|
+
const batch = buffer.slice(0, batchSize);
|
|
400
|
+
const success = await sendBatch(batch);
|
|
401
|
+
if (success) {
|
|
402
|
+
buffer.splice(0, batchSize);
|
|
403
|
+
retryBackoffMs = 1_000;
|
|
404
|
+
retryCount = 0;
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
retryCount++;
|
|
408
|
+
// Drop batch only after exhausting all retries
|
|
409
|
+
if (retryCount >= MAX_RETRIES) {
|
|
410
|
+
writeAuditLog("max_retries_exceeded", batch.length, batch);
|
|
411
|
+
buffer.splice(0, batchSize);
|
|
412
|
+
retryBackoffMs = 1_000;
|
|
413
|
+
retryCount = 0;
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
// Keep events in buffer (don't splice) — retry on next flush
|
|
417
|
+
retryBackoffMs = Math.min(retryBackoffMs * 2, MAX_BACKOFF_MS);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
console.error("[podwatch] Unexpected flush error:", err);
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
flushInProgress = false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Budget sync
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
/**
|
|
432
|
+
* Sync budget from dashboard API. Called on activate + every 300s.
|
|
433
|
+
* Stale by up to 5 min — acceptable with 95% tolerance buffer.
|
|
434
|
+
* Gracefully handles 404 (endpoint not deployed yet) without spamming logs.
|
|
435
|
+
*/
|
|
436
|
+
let budgetSyncFailures = 0;
|
|
437
|
+
const BUDGET_SYNC_MAX_SILENT_FAILURES = 3; // Only warn after N consecutive failures
|
|
438
|
+
async function syncBudget() {
|
|
439
|
+
if (!config)
|
|
440
|
+
return;
|
|
441
|
+
try {
|
|
442
|
+
const response = await fetch(`${config.endpoint}/budget`, {
|
|
443
|
+
method: "GET",
|
|
444
|
+
headers: {
|
|
445
|
+
"Content-Type": "application/json",
|
|
446
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
447
|
+
},
|
|
448
|
+
signal: AbortSignal.timeout(10_000),
|
|
449
|
+
});
|
|
450
|
+
if (response.ok) {
|
|
451
|
+
const data = (await response.json());
|
|
452
|
+
cachedBudget = {
|
|
453
|
+
limit: typeof data.limit === "number" ? data.limit : 0,
|
|
454
|
+
currentSpend: typeof data.currentSpend === "number" ? data.currentSpend : 0,
|
|
455
|
+
lastSyncTs: Date.now(),
|
|
456
|
+
};
|
|
457
|
+
budgetSyncFailures = 0;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
// 404 — endpoint not deployed yet, silently ignore
|
|
461
|
+
if (response.status === 404) {
|
|
462
|
+
budgetSyncFailures++;
|
|
463
|
+
if (budgetSyncFailures === BUDGET_SYNC_MAX_SILENT_FAILURES) {
|
|
464
|
+
console.warn("[podwatch] Budget endpoint not available (404). Budget sync disabled until endpoint is deployed.");
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
budgetSyncFailures++;
|
|
469
|
+
if (budgetSyncFailures <= BUDGET_SYNC_MAX_SILENT_FAILURES) {
|
|
470
|
+
console.warn(`[podwatch] Budget sync failed: HTTP ${response.status}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
budgetSyncFailures++;
|
|
475
|
+
if (budgetSyncFailures <= BUDGET_SYNC_MAX_SILENT_FAILURES) {
|
|
476
|
+
console.warn("[podwatch] Budget sync network error:", err);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Public API
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
exports.transmitter = {
|
|
484
|
+
start(cfg) {
|
|
485
|
+
config = cfg;
|
|
486
|
+
buffer = [];
|
|
487
|
+
retryBackoffMs = 1_000;
|
|
488
|
+
retryCount = 0;
|
|
489
|
+
flushInProgress = false;
|
|
490
|
+
trialExpired = false;
|
|
491
|
+
activateTs = Date.now();
|
|
492
|
+
knownTools.clear();
|
|
493
|
+
recentCredentialAccesses.length = 0;
|
|
494
|
+
cachedBudget = null;
|
|
495
|
+
budgetSyncFailures = 0;
|
|
496
|
+
if (flushTimer)
|
|
497
|
+
clearInterval(flushTimer);
|
|
498
|
+
flushTimer = setInterval(() => void doFlush(), config.flushIntervalMs);
|
|
499
|
+
if (flushTimer && typeof flushTimer === "object" && "unref" in flushTimer) {
|
|
500
|
+
flushTimer.unref();
|
|
501
|
+
}
|
|
502
|
+
// Start budget sync
|
|
503
|
+
void syncBudget();
|
|
504
|
+
if (budgetSyncTimer)
|
|
505
|
+
clearInterval(budgetSyncTimer);
|
|
506
|
+
budgetSyncTimer = setInterval(() => void syncBudget(), BUDGET_SYNC_INTERVAL_MS);
|
|
507
|
+
if (budgetSyncTimer && typeof budgetSyncTimer === "object" && "unref" in budgetSyncTimer) {
|
|
508
|
+
budgetSyncTimer.unref();
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
enqueue(event) {
|
|
512
|
+
buffer.push(event);
|
|
513
|
+
// Overflow protection — drop non-critical events down to BUFFER_TARGET_SIZE
|
|
514
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
515
|
+
const criticalTypes = new Set(["setup_warning", "security", "budget_blocked"]);
|
|
516
|
+
const dropCount = buffer.length - BUFFER_TARGET_SIZE;
|
|
517
|
+
// Collect indices of non-critical events (oldest first)
|
|
518
|
+
const nonCriticalIndices = [];
|
|
519
|
+
for (let i = 0; i < buffer.length && nonCriticalIndices.length < dropCount; i++) {
|
|
520
|
+
if (!criticalTypes.has(buffer[i].type)) {
|
|
521
|
+
nonCriticalIndices.push(i);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// If not enough non-critical events, also drop critical (oldest first)
|
|
525
|
+
if (nonCriticalIndices.length < dropCount) {
|
|
526
|
+
for (let i = 0; i < buffer.length && nonCriticalIndices.length < dropCount; i++) {
|
|
527
|
+
if (criticalTypes.has(buffer[i].type)) {
|
|
528
|
+
nonCriticalIndices.push(i);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Remove in reverse order to preserve indices
|
|
533
|
+
const dropped = [];
|
|
534
|
+
const indicesToDrop = nonCriticalIndices.sort((a, b) => b - a);
|
|
535
|
+
for (const idx of indicesToDrop) {
|
|
536
|
+
dropped.push(buffer[idx]);
|
|
537
|
+
buffer.splice(idx, 1);
|
|
538
|
+
}
|
|
539
|
+
if (dropped.length > 0) {
|
|
540
|
+
writeAuditLog("buffer_overflow", dropped.length, dropped);
|
|
541
|
+
console.warn(`[podwatch] Buffer overflow: dropped ${dropped.length} events (target: ${BUFFER_TARGET_SIZE})`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Flush if batch size reached
|
|
545
|
+
if (config && buffer.length >= config.batchSize) {
|
|
546
|
+
void doFlush();
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
async flush() {
|
|
550
|
+
await doFlush();
|
|
551
|
+
},
|
|
552
|
+
stop() {
|
|
553
|
+
if (flushTimer) {
|
|
554
|
+
clearInterval(flushTimer);
|
|
555
|
+
flushTimer = null;
|
|
556
|
+
}
|
|
557
|
+
if (budgetSyncTimer) {
|
|
558
|
+
clearInterval(budgetSyncTimer);
|
|
559
|
+
budgetSyncTimer = null;
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
/** Flush remaining events and stop. For graceful shutdown. */
|
|
563
|
+
async shutdown() {
|
|
564
|
+
while (buffer.length > 0 && config) {
|
|
565
|
+
await doFlush();
|
|
566
|
+
}
|
|
567
|
+
this.stop();
|
|
568
|
+
},
|
|
569
|
+
// -----------------------------------------------------------------------
|
|
570
|
+
// Credential access tracking (exfiltration detection)
|
|
571
|
+
// -----------------------------------------------------------------------
|
|
572
|
+
/** Mark that a tool accessed credentials. */
|
|
573
|
+
markCredentialAccess(toolName, params) {
|
|
574
|
+
const path = (typeof params.path === "string" && params.path) ||
|
|
575
|
+
(typeof params.file_path === "string" && params.file_path) ||
|
|
576
|
+
(typeof params.command === "string" && params.command) ||
|
|
577
|
+
"unknown";
|
|
578
|
+
recentCredentialAccesses.push({
|
|
579
|
+
toolName,
|
|
580
|
+
path,
|
|
581
|
+
ts: Date.now(),
|
|
582
|
+
});
|
|
583
|
+
// Prune old entries
|
|
584
|
+
this._pruneCredentialAccesses();
|
|
585
|
+
},
|
|
586
|
+
/** Check if there was a credential access within the last `windowSec` seconds. */
|
|
587
|
+
hasRecentCredentialAccess(windowSec) {
|
|
588
|
+
this._pruneCredentialAccesses();
|
|
589
|
+
const cutoff = Date.now() - windowSec * 1_000;
|
|
590
|
+
return recentCredentialAccesses.some((a) => a.ts >= cutoff);
|
|
591
|
+
},
|
|
592
|
+
/** Get the most recent credential access details (for exfiltration alerts). */
|
|
593
|
+
getRecentCredentialAccess(windowSec) {
|
|
594
|
+
this._pruneCredentialAccesses();
|
|
595
|
+
const cutoff = Date.now() - windowSec * 1_000;
|
|
596
|
+
const recent = recentCredentialAccesses.filter((a) => a.ts >= cutoff);
|
|
597
|
+
return recent.length > 0 ? recent[recent.length - 1] : null;
|
|
598
|
+
},
|
|
599
|
+
_pruneCredentialAccesses() {
|
|
600
|
+
const cutoff = Date.now() - CREDENTIAL_ACCESS_WINDOW_MS;
|
|
601
|
+
while (recentCredentialAccesses.length > 0 && recentCredentialAccesses[0].ts < cutoff) {
|
|
602
|
+
recentCredentialAccesses.shift();
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
// -----------------------------------------------------------------------
|
|
606
|
+
// Known tool tracking (first-time tool detection)
|
|
607
|
+
// -----------------------------------------------------------------------
|
|
608
|
+
/** Check if a tool has been seen before. */
|
|
609
|
+
isKnownTool(toolName) {
|
|
610
|
+
return knownTools.has(toolName);
|
|
611
|
+
},
|
|
612
|
+
/** Record a tool as seen. Clears the set when it exceeds KNOWN_TOOLS_MAX_SIZE. */
|
|
613
|
+
recordToolSeen(toolName) {
|
|
614
|
+
knownTools.add(toolName);
|
|
615
|
+
if (knownTools.size > KNOWN_TOOLS_MAX_SIZE) {
|
|
616
|
+
console.warn(`[podwatch] knownTools set exceeded ${KNOWN_TOOLS_MAX_SIZE} entries — resetting first-time-tool baseline`);
|
|
617
|
+
knownTools.clear();
|
|
618
|
+
knownTools.add(toolName);
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
/** Get agent uptime in hours since plugin activated. */
|
|
622
|
+
getAgentUptimeHours() {
|
|
623
|
+
if (activateTs === 0)
|
|
624
|
+
return 0;
|
|
625
|
+
return (Date.now() - activateTs) / (1_000 * 60 * 60);
|
|
626
|
+
},
|
|
627
|
+
// -----------------------------------------------------------------------
|
|
628
|
+
// Budget cache
|
|
629
|
+
// -----------------------------------------------------------------------
|
|
630
|
+
/** Get cached budget state (synced from dashboard every 60s). */
|
|
631
|
+
getCachedBudget() {
|
|
632
|
+
return cachedBudget;
|
|
633
|
+
},
|
|
634
|
+
/** Force a budget sync now. */
|
|
635
|
+
async forceBudgetSync() {
|
|
636
|
+
await syncBudget();
|
|
637
|
+
},
|
|
638
|
+
// -----------------------------------------------------------------------
|
|
639
|
+
// Diagnostics
|
|
640
|
+
// -----------------------------------------------------------------------
|
|
641
|
+
/** Current buffer size (for testing/diagnostics). */
|
|
642
|
+
get bufferedCount() {
|
|
643
|
+
return buffer.length;
|
|
644
|
+
},
|
|
645
|
+
/** Number of known tools (for testing/diagnostics). */
|
|
646
|
+
get knownToolCount() {
|
|
647
|
+
return knownTools.size;
|
|
648
|
+
},
|
|
649
|
+
/** Whether trial has expired (for testing). */
|
|
650
|
+
get isTrialExpired() {
|
|
651
|
+
return trialExpired;
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
//# sourceMappingURL=transmitter.js.map
|