@coffeexdev/openclaw-sentinel 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -17
- package/dist/callbackEnvelope.js +7 -3
- package/dist/configSchema.js +80 -18
- package/dist/evaluator.js +1 -3
- package/dist/index.js +270 -54
- package/dist/strategies/httpLongPoll.js +29 -3
- package/dist/strategies/httpPoll.js +36 -5
- package/dist/strategies/sse.js +45 -13
- package/dist/strategies/websocket.js +40 -5
- package/dist/template.js +1 -3
- package/dist/toolSchema.js +16 -3
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +9 -0
- package/dist/validator.js +2 -1
- package/dist/watcherManager.d.ts +7 -0
- package/dist/watcherManager.js +57 -32
- package/openclaw.plugin.json +9 -9
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,13 +8,14 @@ const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
|
|
|
8
8
|
const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
|
|
9
9
|
const DEFAULT_HOOK_RESPONSE_TIMEOUT_MS = 30_000;
|
|
10
10
|
const DEFAULT_HOOK_RESPONSE_FALLBACK_MODE = "concise";
|
|
11
|
+
const HOOK_RESPONSE_RELAY_CLEANUP_INTERVAL_MS = 60_000;
|
|
11
12
|
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
12
13
|
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
13
14
|
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
14
15
|
const SENTINEL_CALLBACK_WAKE_REASON = "cron:sentinel-callback";
|
|
15
16
|
const SENTINEL_CALLBACK_CONTEXT_KEY = "cron:sentinel-callback";
|
|
16
|
-
const
|
|
17
|
-
const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel.
|
|
17
|
+
const RESERVED_CONTROL_TOKEN_PATTERN = /\b(?:NO[\s_-]*REPLY|HEARTBEAT[\s_-]*OK)\b/gi;
|
|
18
|
+
const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Use watcher + payload context to decide safe follow-up actions and produce a user-facing response.";
|
|
18
19
|
const SUPPORTED_DELIVERY_CHANNELS = new Set([
|
|
19
20
|
"telegram",
|
|
20
21
|
"discord",
|
|
@@ -43,20 +44,48 @@ function asIsoString(value) {
|
|
|
43
44
|
function isRecord(value) {
|
|
44
45
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
45
46
|
}
|
|
47
|
+
function sniffGatewayDispatchToken(configRoot) {
|
|
48
|
+
if (!configRoot)
|
|
49
|
+
return undefined;
|
|
50
|
+
const auth = isRecord(configRoot.auth) ? configRoot.auth : undefined;
|
|
51
|
+
const gateway = isRecord(configRoot.gateway) ? configRoot.gateway : undefined;
|
|
52
|
+
const gatewayAuth = gateway && isRecord(gateway.auth) ? gateway.auth : undefined;
|
|
53
|
+
const server = isRecord(configRoot.server) ? configRoot.server : undefined;
|
|
54
|
+
const serverAuth = server && isRecord(server.auth) ? server.auth : undefined;
|
|
55
|
+
const candidates = [
|
|
56
|
+
auth?.token,
|
|
57
|
+
gateway?.authToken,
|
|
58
|
+
gatewayAuth?.token,
|
|
59
|
+
serverAuth?.token,
|
|
60
|
+
configRoot.gatewayAuthToken,
|
|
61
|
+
configRoot.authToken,
|
|
62
|
+
];
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
const token = asString(candidate);
|
|
65
|
+
if (token)
|
|
66
|
+
return token;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
46
70
|
function resolveSentinelPluginConfig(api) {
|
|
47
71
|
const pluginConfig = isRecord(api.pluginConfig)
|
|
48
|
-
? api.pluginConfig
|
|
72
|
+
? { ...api.pluginConfig }
|
|
49
73
|
: {};
|
|
50
74
|
const configRoot = isRecord(api.config) ? api.config : undefined;
|
|
51
75
|
const legacyRootConfig = configRoot?.sentinel;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
76
|
+
let resolved = pluginConfig;
|
|
77
|
+
if (legacyRootConfig !== undefined) {
|
|
78
|
+
api.logger?.warn?.('[openclaw-sentinel] Detected deprecated root-level config key "sentinel". Move settings to plugins.entries.openclaw-sentinel.config. Root-level "sentinel" may fail with: Unrecognized key: "sentinel".');
|
|
79
|
+
if (isRecord(legacyRootConfig) && Object.keys(pluginConfig).length === 0) {
|
|
80
|
+
resolved = { ...legacyRootConfig };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!asString(resolved.dispatchAuthToken)) {
|
|
84
|
+
const sniffedToken = sniffGatewayDispatchToken(configRoot);
|
|
85
|
+
if (sniffedToken)
|
|
86
|
+
resolved.dispatchAuthToken = sniffedToken;
|
|
87
|
+
}
|
|
88
|
+
return resolved;
|
|
60
89
|
}
|
|
61
90
|
function isDeliveryTarget(value) {
|
|
62
91
|
return (isRecord(value) &&
|
|
@@ -136,25 +165,26 @@ function extractDeliveryContext(payload) {
|
|
|
136
165
|
context.deliveryTargets = deliveryTargets;
|
|
137
166
|
return Object.keys(context).length > 0 ? context : undefined;
|
|
138
167
|
}
|
|
168
|
+
function asBoolean(value) {
|
|
169
|
+
return typeof value === "boolean" ? value : undefined;
|
|
170
|
+
}
|
|
139
171
|
function buildSentinelEventEnvelope(payload) {
|
|
172
|
+
const watcherRecord = isRecord(payload.watcher) ? payload.watcher : undefined;
|
|
173
|
+
const triggerRecord = isRecord(payload.trigger) ? payload.trigger : undefined;
|
|
140
174
|
const watcherId = asString(payload.watcherId) ??
|
|
141
|
-
|
|
175
|
+
asString(watcherRecord?.id) ??
|
|
142
176
|
getNestedString(payload, ["context", "watcherId"]);
|
|
143
177
|
const eventName = asString(payload.eventName) ??
|
|
144
|
-
|
|
178
|
+
asString(watcherRecord?.eventName) ??
|
|
145
179
|
getNestedString(payload, ["event", "name"]);
|
|
146
180
|
const skillId = asString(payload.skillId) ??
|
|
147
|
-
|
|
181
|
+
asString(watcherRecord?.skillId) ??
|
|
148
182
|
getNestedString(payload, ["context", "skillId"]) ??
|
|
149
183
|
undefined;
|
|
150
184
|
const matchedAt = asIsoString(payload.matchedAt) ??
|
|
151
185
|
asIsoString(payload.timestamp) ??
|
|
152
|
-
asIsoString(
|
|
186
|
+
asIsoString(triggerRecord?.matchedAt) ??
|
|
153
187
|
new Date().toISOString();
|
|
154
|
-
const rawPayload = payload.payload ??
|
|
155
|
-
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
156
|
-
payload;
|
|
157
|
-
const boundedPayload = clipPayloadForPrompt(rawPayload);
|
|
158
188
|
const dedupeSeed = JSON.stringify({
|
|
159
189
|
watcherId: watcherId ?? null,
|
|
160
190
|
eventName: eventName ?? null,
|
|
@@ -164,8 +194,15 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
164
194
|
const dedupeKey = asString(payload.dedupeKey) ??
|
|
165
195
|
asString(payload.correlationId) ??
|
|
166
196
|
asString(payload.correlationID) ??
|
|
167
|
-
|
|
197
|
+
asString(triggerRecord?.dedupeKey) ??
|
|
168
198
|
generatedDedupe;
|
|
199
|
+
const rawPayload = payload.payload ??
|
|
200
|
+
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
201
|
+
payload;
|
|
202
|
+
const rawContext = payload.context ??
|
|
203
|
+
(isRecord(rawPayload) ? rawPayload.context : undefined) ??
|
|
204
|
+
(isRecord(payload.event) ? payload.event.context : undefined) ??
|
|
205
|
+
null;
|
|
169
206
|
const deliveryTargets = Array.isArray(payload.deliveryTargets)
|
|
170
207
|
? payload.deliveryTargets.filter(isDeliveryTarget)
|
|
171
208
|
: undefined;
|
|
@@ -173,13 +210,41 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
173
210
|
const sourcePlugin = getNestedString(payload, ["source", "plugin"]) ?? "openclaw-sentinel";
|
|
174
211
|
const hookSessionGroup = asString(payload.hookSessionGroup) ??
|
|
175
212
|
asString(payload.sessionGroup) ??
|
|
176
|
-
|
|
213
|
+
asString(watcherRecord?.sessionGroup);
|
|
177
214
|
const deliveryContext = extractDeliveryContext(payload);
|
|
215
|
+
const watcherIntent = asString(payload.intent) ?? asString(watcherRecord?.intent) ?? null;
|
|
216
|
+
const watcherStrategy = asString(watcherRecord?.strategy) ?? asString(payload.strategy) ?? null;
|
|
217
|
+
const watcherEndpoint = asString(watcherRecord?.endpoint) ?? asString(payload.endpoint) ?? null;
|
|
218
|
+
const watcherMatch = asString(watcherRecord?.match) ?? asString(payload.match) ?? null;
|
|
219
|
+
const watcherConditions = Array.isArray(watcherRecord?.conditions)
|
|
220
|
+
? watcherRecord.conditions
|
|
221
|
+
: Array.isArray(payload.conditions)
|
|
222
|
+
? payload.conditions
|
|
223
|
+
: [];
|
|
224
|
+
const watcherFireOnce = asBoolean(watcherRecord?.fireOnce ?? payload.fireOnce) ?? null;
|
|
225
|
+
const triggerPriority = asString(payload.priority) ?? asString(triggerRecord?.priority) ?? null;
|
|
178
226
|
const envelope = {
|
|
179
227
|
watcherId: watcherId ?? null,
|
|
180
228
|
eventName: eventName ?? null,
|
|
181
229
|
matchedAt,
|
|
182
|
-
|
|
230
|
+
watcher: {
|
|
231
|
+
id: watcherId ?? null,
|
|
232
|
+
skillId: skillId ?? null,
|
|
233
|
+
eventName: eventName ?? null,
|
|
234
|
+
intent: watcherIntent,
|
|
235
|
+
strategy: watcherStrategy,
|
|
236
|
+
endpoint: watcherEndpoint,
|
|
237
|
+
match: watcherMatch,
|
|
238
|
+
conditions: watcherConditions,
|
|
239
|
+
fireOnce: watcherFireOnce,
|
|
240
|
+
},
|
|
241
|
+
trigger: {
|
|
242
|
+
matchedAt,
|
|
243
|
+
dedupeKey,
|
|
244
|
+
priority: triggerPriority,
|
|
245
|
+
},
|
|
246
|
+
context: clipPayloadForPrompt(rawContext),
|
|
247
|
+
payload: clipPayloadForPrompt(rawPayload),
|
|
183
248
|
dedupeKey,
|
|
184
249
|
correlationId: dedupeKey,
|
|
185
250
|
source: {
|
|
@@ -198,8 +263,26 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
198
263
|
return envelope;
|
|
199
264
|
}
|
|
200
265
|
function buildSentinelSystemEvent(envelope) {
|
|
201
|
-
const
|
|
202
|
-
|
|
266
|
+
const callbackContext = {
|
|
267
|
+
watcher: envelope.watcher,
|
|
268
|
+
trigger: envelope.trigger,
|
|
269
|
+
source: envelope.source,
|
|
270
|
+
deliveryTargets: envelope.deliveryTargets ?? [],
|
|
271
|
+
deliveryContext: envelope.deliveryContext ?? null,
|
|
272
|
+
context: envelope.context,
|
|
273
|
+
payload: envelope.payload,
|
|
274
|
+
};
|
|
275
|
+
const text = [
|
|
276
|
+
SENTINEL_EVENT_INSTRUCTION_PREFIX,
|
|
277
|
+
"Callback handling requirements:",
|
|
278
|
+
"- Base actions on watcher intent/event/skill plus the callback context and payload.",
|
|
279
|
+
"- Return a concise user-facing response that reflects what triggered and what to do next.",
|
|
280
|
+
"- Never emit control tokens such as NO_REPLY or HEARTBEAT_OK.",
|
|
281
|
+
"SENTINEL_CALLBACK_CONTEXT_JSON:",
|
|
282
|
+
JSON.stringify(callbackContext, null, 2),
|
|
283
|
+
"SENTINEL_ENVELOPE_JSON:",
|
|
284
|
+
JSON.stringify(envelope, null, 2),
|
|
285
|
+
].join("\n");
|
|
203
286
|
return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
204
287
|
}
|
|
205
288
|
function normalizeDeliveryTargets(targets) {
|
|
@@ -300,9 +383,11 @@ function summarizeContext(value) {
|
|
|
300
383
|
function buildRelayMessage(envelope) {
|
|
301
384
|
const title = envelope.eventName ? `Sentinel alert: ${envelope.eventName}` : "Sentinel alert";
|
|
302
385
|
const watcher = envelope.watcherId ? `watcher ${envelope.watcherId}` : "watcher unknown";
|
|
303
|
-
const
|
|
304
|
-
const contextSummary = summarizeContext(
|
|
386
|
+
const intent = envelope.watcher.intent ? `intent ${envelope.watcher.intent}` : undefined;
|
|
387
|
+
const contextSummary = summarizeContext(envelope.context) ?? summarizeContext(envelope.payload);
|
|
305
388
|
const lines = [title, `${watcher} · ${envelope.matchedAt}`];
|
|
389
|
+
if (intent)
|
|
390
|
+
lines.push(intent);
|
|
306
391
|
if (contextSummary)
|
|
307
392
|
lines.push(contextSummary);
|
|
308
393
|
const text = lines.join("\n").trim();
|
|
@@ -310,12 +395,25 @@ function buildRelayMessage(envelope) {
|
|
|
310
395
|
? text
|
|
311
396
|
: "Sentinel callback received, but no assistant detail was generated.";
|
|
312
397
|
}
|
|
398
|
+
function normalizeControlTokenCandidate(value) {
|
|
399
|
+
return value.replace(/[^a-zA-Z]/g, "").toUpperCase();
|
|
400
|
+
}
|
|
401
|
+
function sanitizeAssistantRelaySegment(value) {
|
|
402
|
+
if (typeof value !== "string")
|
|
403
|
+
return "";
|
|
404
|
+
const tokenCandidate = normalizeControlTokenCandidate(value.trim());
|
|
405
|
+
if (tokenCandidate === "NOREPLY" || tokenCandidate === "HEARTBEATOK")
|
|
406
|
+
return "";
|
|
407
|
+
const withoutTokens = value.replace(RESERVED_CONTROL_TOKEN_PATTERN, " ").trim();
|
|
408
|
+
if (!withoutTokens)
|
|
409
|
+
return "";
|
|
410
|
+
const collapsed = withoutTokens.replace(/\s+/g, " ").trim();
|
|
411
|
+
return /[a-zA-Z0-9]/.test(collapsed) ? collapsed : "";
|
|
412
|
+
}
|
|
313
413
|
function normalizeAssistantRelayText(assistantTexts) {
|
|
314
414
|
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
315
415
|
return undefined;
|
|
316
|
-
const parts = assistantTexts
|
|
317
|
-
.map((value) => value.replace(HEARTBEAT_ACK_TOKEN_PATTERN, "").trim())
|
|
318
|
-
.filter(Boolean);
|
|
416
|
+
const parts = assistantTexts.map(sanitizeAssistantRelaySegment).filter(Boolean);
|
|
319
417
|
if (parts.length === 0)
|
|
320
418
|
return undefined;
|
|
321
419
|
return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
@@ -334,9 +432,12 @@ function resolveHookResponseFallbackMode(config) {
|
|
|
334
432
|
return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
|
|
335
433
|
}
|
|
336
434
|
function buildIsolatedHookSessionKey(envelope, config) {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
435
|
+
const configuredPrefix = asString(config.hookSessionPrefix);
|
|
436
|
+
const legacyPrefix = asString(config.hookSessionKey);
|
|
437
|
+
const hasCustomPrefix = typeof configuredPrefix === "string" && configuredPrefix !== DEFAULT_HOOK_SESSION_PREFIX;
|
|
438
|
+
const rawPrefix = hasCustomPrefix
|
|
439
|
+
? configuredPrefix
|
|
440
|
+
: (legacyPrefix ?? configuredPrefix ?? DEFAULT_HOOK_SESSION_PREFIX);
|
|
340
441
|
const prefix = rawPrefix.replace(/:+$/g, "");
|
|
341
442
|
const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
|
|
342
443
|
if (group) {
|
|
@@ -350,10 +451,28 @@ function buildIsolatedHookSessionKey(envelope, config) {
|
|
|
350
451
|
}
|
|
351
452
|
return `${prefix}:event:unknown`;
|
|
352
453
|
}
|
|
454
|
+
function assertJsonContentType(req) {
|
|
455
|
+
const raw = req.headers["content-type"];
|
|
456
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
457
|
+
if (!header)
|
|
458
|
+
return;
|
|
459
|
+
const normalized = header.toLowerCase();
|
|
460
|
+
const isJson = normalized.includes("application/json") ||
|
|
461
|
+
normalized.includes("application/cloudevents+json") ||
|
|
462
|
+
normalized.includes("+json");
|
|
463
|
+
if (!isJson) {
|
|
464
|
+
throw new Error(`Unsupported Content-Type: ${header}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
353
467
|
async function readSentinelWebhookPayload(req) {
|
|
468
|
+
assertJsonContentType(req);
|
|
354
469
|
const preParsed = req.body;
|
|
355
|
-
if (
|
|
470
|
+
if (preParsed !== undefined) {
|
|
471
|
+
if (!isRecord(preParsed)) {
|
|
472
|
+
throw new Error("Payload must be a JSON object");
|
|
473
|
+
}
|
|
356
474
|
return preParsed;
|
|
475
|
+
}
|
|
357
476
|
const chunks = [];
|
|
358
477
|
let total = 0;
|
|
359
478
|
for await (const chunk of req) {
|
|
@@ -446,21 +565,16 @@ class HookResponseRelayManager {
|
|
|
446
565
|
recentByDedupe = new Map();
|
|
447
566
|
pendingByDedupe = new Map();
|
|
448
567
|
pendingQueueBySession = new Map();
|
|
568
|
+
cleanupTimer;
|
|
569
|
+
disposed = false;
|
|
449
570
|
constructor(config, api) {
|
|
450
571
|
this.config = config;
|
|
451
572
|
this.api = api;
|
|
452
573
|
}
|
|
453
574
|
register(args) {
|
|
575
|
+
this.cleanup();
|
|
454
576
|
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
455
577
|
const now = Date.now();
|
|
456
|
-
if (dedupeWindowMs > 0) {
|
|
457
|
-
for (const [key, ts] of this.recentByDedupe.entries()) {
|
|
458
|
-
if (now - ts > dedupeWindowMs) {
|
|
459
|
-
this.recentByDedupe.delete(key);
|
|
460
|
-
this.pendingByDedupe.delete(key);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
578
|
const existingTs = this.recentByDedupe.get(args.dedupeKey);
|
|
465
579
|
if (dedupeWindowMs > 0 &&
|
|
466
580
|
typeof existingTs === "number" &&
|
|
@@ -477,6 +591,7 @@ class HookResponseRelayManager {
|
|
|
477
591
|
};
|
|
478
592
|
}
|
|
479
593
|
this.recentByDedupe.set(args.dedupeKey, now);
|
|
594
|
+
this.scheduleCleanup();
|
|
480
595
|
const timeoutMs = resolveHookResponseTimeoutMs(this.config);
|
|
481
596
|
const fallbackMode = resolveHookResponseFallbackMode(this.config);
|
|
482
597
|
if (args.relayTargets.length === 0) {
|
|
@@ -527,8 +642,7 @@ class HookResponseRelayManager {
|
|
|
527
642
|
async handleLlmOutput(sessionKey, assistantTexts) {
|
|
528
643
|
if (!sessionKey)
|
|
529
644
|
return;
|
|
530
|
-
|
|
531
|
-
if (!assistantMessage)
|
|
645
|
+
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
532
646
|
return;
|
|
533
647
|
const dedupeKey = this.popNextPendingDedupe(sessionKey);
|
|
534
648
|
if (!dedupeKey)
|
|
@@ -536,7 +650,70 @@ class HookResponseRelayManager {
|
|
|
536
650
|
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
537
651
|
if (!pending || pending.state !== "pending")
|
|
538
652
|
return;
|
|
539
|
-
|
|
653
|
+
const assistantMessage = normalizeAssistantRelayText(assistantTexts);
|
|
654
|
+
if (assistantMessage) {
|
|
655
|
+
await this.completeWithMessage(pending, assistantMessage, "assistant");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
await this.completeWithMessage(pending, pending.fallbackMessage, "guardrail");
|
|
659
|
+
}
|
|
660
|
+
dispose() {
|
|
661
|
+
if (this.disposed)
|
|
662
|
+
return;
|
|
663
|
+
this.disposed = true;
|
|
664
|
+
if (this.cleanupTimer) {
|
|
665
|
+
clearTimeout(this.cleanupTimer);
|
|
666
|
+
this.cleanupTimer = undefined;
|
|
667
|
+
}
|
|
668
|
+
for (const pending of this.pendingByDedupe.values()) {
|
|
669
|
+
if (pending.timer) {
|
|
670
|
+
clearTimeout(pending.timer);
|
|
671
|
+
pending.timer = undefined;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
this.pendingByDedupe.clear();
|
|
675
|
+
this.pendingQueueBySession.clear();
|
|
676
|
+
this.recentByDedupe.clear();
|
|
677
|
+
}
|
|
678
|
+
scheduleCleanup() {
|
|
679
|
+
if (this.disposed || this.cleanupTimer)
|
|
680
|
+
return;
|
|
681
|
+
this.cleanupTimer = setTimeout(() => {
|
|
682
|
+
this.cleanupTimer = undefined;
|
|
683
|
+
this.cleanup();
|
|
684
|
+
}, HOOK_RESPONSE_RELAY_CLEANUP_INTERVAL_MS);
|
|
685
|
+
this.cleanupTimer.unref?.();
|
|
686
|
+
}
|
|
687
|
+
cleanup(now = Date.now()) {
|
|
688
|
+
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
689
|
+
if (dedupeWindowMs > 0) {
|
|
690
|
+
for (const [key, ts] of this.recentByDedupe.entries()) {
|
|
691
|
+
if (now - ts > dedupeWindowMs) {
|
|
692
|
+
this.recentByDedupe.delete(key);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
for (const [key, pending] of this.pendingByDedupe.entries()) {
|
|
697
|
+
const gcAfterMs = Math.max(pending.timeoutMs, dedupeWindowMs, 1_000);
|
|
698
|
+
if (pending.state !== "pending" && now - pending.createdAt > gcAfterMs) {
|
|
699
|
+
this.pendingByDedupe.delete(key);
|
|
700
|
+
this.removeFromSessionQueue(pending.sessionKey, key);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (this.pendingByDedupe.size > 0 || this.recentByDedupe.size > 0) {
|
|
704
|
+
this.scheduleCleanup();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
removeFromSessionQueue(sessionKey, dedupeKey) {
|
|
708
|
+
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
709
|
+
if (!queue || queue.length === 0)
|
|
710
|
+
return;
|
|
711
|
+
const filtered = queue.filter((key) => key !== dedupeKey);
|
|
712
|
+
if (filtered.length === 0) {
|
|
713
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
this.pendingQueueBySession.set(sessionKey, filtered);
|
|
540
717
|
}
|
|
541
718
|
popNextPendingDedupe(sessionKey) {
|
|
542
719
|
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
@@ -571,7 +748,12 @@ class HookResponseRelayManager {
|
|
|
571
748
|
async completeWithMessage(pending, message, source) {
|
|
572
749
|
const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
|
|
573
750
|
this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
|
|
574
|
-
|
|
751
|
+
const action = source === "assistant"
|
|
752
|
+
? "Relayed assistant response"
|
|
753
|
+
: source === "guardrail"
|
|
754
|
+
? "Sent guardrail fallback"
|
|
755
|
+
: "Sent timeout fallback";
|
|
756
|
+
this.api.logger?.info?.(`[openclaw-sentinel] ${action} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
|
|
575
757
|
}
|
|
576
758
|
markClosed(pending, state) {
|
|
577
759
|
pending.state = state;
|
|
@@ -586,7 +768,7 @@ export function createSentinelPlugin(overrides) {
|
|
|
586
768
|
const config = {
|
|
587
769
|
allowedHosts: [],
|
|
588
770
|
localDispatchBase: "http://127.0.0.1:18789",
|
|
589
|
-
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
771
|
+
dispatchAuthToken: asString(process.env.SENTINEL_DISPATCH_TOKEN),
|
|
590
772
|
hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
|
|
591
773
|
hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
592
774
|
hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
|
|
@@ -606,11 +788,24 @@ export function createSentinelPlugin(overrides) {
|
|
|
606
788
|
const headers = { "content-type": "application/json" };
|
|
607
789
|
if (config.dispatchAuthToken)
|
|
608
790
|
headers.authorization = `Bearer ${config.dispatchAuthToken}`;
|
|
609
|
-
await fetch(`${config.localDispatchBase}${path}`, {
|
|
791
|
+
const response = await fetch(`${config.localDispatchBase}${path}`, {
|
|
610
792
|
method: "POST",
|
|
611
793
|
headers,
|
|
612
794
|
body: JSON.stringify(body),
|
|
613
795
|
});
|
|
796
|
+
if (!response.ok) {
|
|
797
|
+
let responseBody = "";
|
|
798
|
+
try {
|
|
799
|
+
responseBody = await response.text();
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
responseBody = "";
|
|
803
|
+
}
|
|
804
|
+
const details = responseBody ? ` body=${trimText(responseBody, 256)}` : "";
|
|
805
|
+
const error = new Error(`dispatch failed with status ${response.status}${details}`);
|
|
806
|
+
error.status = response.status;
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
614
809
|
},
|
|
615
810
|
});
|
|
616
811
|
return {
|
|
@@ -622,11 +817,18 @@ export function createSentinelPlugin(overrides) {
|
|
|
622
817
|
const runtimeConfig = resolveSentinelPluginConfig(api);
|
|
623
818
|
if (Object.keys(runtimeConfig).length > 0)
|
|
624
819
|
Object.assign(config, runtimeConfig);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
820
|
+
config.dispatchAuthToken = asString(config.dispatchAuthToken);
|
|
821
|
+
manager.setLogger(api.logger);
|
|
822
|
+
if (Array.isArray(config.allowedHosts) && config.allowedHosts.length === 0) {
|
|
823
|
+
api.logger?.warn?.("[openclaw-sentinel] allowedHosts is empty. Watcher creation will fail until at least one host is configured.");
|
|
824
|
+
}
|
|
825
|
+
const hasLegacyHookSessionKey = !!asString(config.hookSessionKey);
|
|
826
|
+
const hasCustomHookSessionPrefix = !!asString(config.hookSessionPrefix) &&
|
|
827
|
+
asString(config.hookSessionPrefix) !== DEFAULT_HOOK_SESSION_PREFIX;
|
|
828
|
+
if (hasLegacyHookSessionKey) {
|
|
829
|
+
api.logger?.warn?.(hasCustomHookSessionPrefix
|
|
830
|
+
? "[openclaw-sentinel] hookSessionKey is deprecated and ignored when hookSessionPrefix is set. Remove hookSessionKey from config."
|
|
831
|
+
: "[openclaw-sentinel] hookSessionKey is deprecated. Rename it to hookSessionPrefix.");
|
|
630
832
|
}
|
|
631
833
|
manager.setNotifier({
|
|
632
834
|
async notify(target, message) {
|
|
@@ -648,6 +850,12 @@ export function createSentinelPlugin(overrides) {
|
|
|
648
850
|
manager.setWebhookRegistrationStatus("ok", "Route already registered (idempotent)", path);
|
|
649
851
|
return;
|
|
650
852
|
}
|
|
853
|
+
const hookResponseRelayManager = new HookResponseRelayManager(config, api);
|
|
854
|
+
if (typeof api.on === "function") {
|
|
855
|
+
api.on("llm_output", (event, ctx) => {
|
|
856
|
+
void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
|
|
857
|
+
});
|
|
858
|
+
}
|
|
651
859
|
try {
|
|
652
860
|
api.registerHttpRoute({
|
|
653
861
|
path,
|
|
@@ -693,7 +901,14 @@ export function createSentinelPlugin(overrides) {
|
|
|
693
901
|
const message = String(err?.message ?? err);
|
|
694
902
|
const badRequest = message.includes("Invalid JSON payload") ||
|
|
695
903
|
message.includes("Payload must be a JSON object");
|
|
696
|
-
const
|
|
904
|
+
const unsupportedMediaType = message.includes("Unsupported Content-Type");
|
|
905
|
+
const status = message.includes("too large")
|
|
906
|
+
? 413
|
|
907
|
+
: unsupportedMediaType
|
|
908
|
+
? 415
|
|
909
|
+
: badRequest
|
|
910
|
+
? 400
|
|
911
|
+
: 500;
|
|
697
912
|
res.writeHead(status, { "content-type": "application/json" });
|
|
698
913
|
res.end(JSON.stringify({ error: message }));
|
|
699
914
|
}
|
|
@@ -704,6 +919,7 @@ export function createSentinelPlugin(overrides) {
|
|
|
704
919
|
api.logger?.info?.(`[openclaw-sentinel] Registered default webhook route ${path}`);
|
|
705
920
|
}
|
|
706
921
|
catch (err) {
|
|
922
|
+
hookResponseRelayManager.dispose();
|
|
707
923
|
const msg = `Failed to register default webhook route ${path}: ${String(err?.message ?? err)}`;
|
|
708
924
|
manager.setWebhookRegistrationStatus("error", msg, path);
|
|
709
925
|
api.logger?.error?.(`[openclaw-sentinel] ${msg}`);
|
|
@@ -719,8 +935,8 @@ const sentinelPlugin = {
|
|
|
719
935
|
configSchema: sentinelConfigSchema,
|
|
720
936
|
register(api) {
|
|
721
937
|
const plugin = createSentinelPlugin(api.pluginConfig);
|
|
722
|
-
void plugin.init();
|
|
723
938
|
plugin.register(api);
|
|
939
|
+
void plugin.init();
|
|
724
940
|
},
|
|
725
941
|
};
|
|
726
942
|
export const register = sentinelPlugin.register.bind(sentinelPlugin);
|
|
@@ -1,29 +1,55 @@
|
|
|
1
|
+
function isAbortError(err) {
|
|
2
|
+
if (!(err instanceof Error))
|
|
3
|
+
return false;
|
|
4
|
+
return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
|
|
5
|
+
}
|
|
1
6
|
export const httpLongPollStrategy = async (watcher, onPayload, onError) => {
|
|
2
7
|
let active = true;
|
|
8
|
+
let inFlightAbort;
|
|
3
9
|
const loop = async () => {
|
|
4
10
|
while (active) {
|
|
5
11
|
try {
|
|
12
|
+
inFlightAbort = new AbortController();
|
|
6
13
|
const response = await fetch(watcher.endpoint, {
|
|
7
14
|
method: watcher.method ?? "GET",
|
|
8
15
|
headers: watcher.headers,
|
|
9
16
|
body: watcher.body,
|
|
10
|
-
signal: AbortSignal.
|
|
17
|
+
signal: AbortSignal.any([
|
|
18
|
+
inFlightAbort.signal,
|
|
19
|
+
AbortSignal.timeout(watcher.timeoutMs ?? 60000),
|
|
20
|
+
]),
|
|
21
|
+
redirect: "error",
|
|
11
22
|
});
|
|
12
23
|
if (!response.ok)
|
|
13
24
|
throw new Error(`http-long-poll non-2xx: ${response.status}`);
|
|
14
25
|
const contentType = response.headers.get("content-type") ?? "";
|
|
15
|
-
if (!contentType.toLowerCase().includes("json"))
|
|
26
|
+
if (!contentType.toLowerCase().includes("json")) {
|
|
16
27
|
throw new Error(`http-long-poll expected JSON, got: ${contentType || "unknown"}`);
|
|
17
|
-
|
|
28
|
+
}
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = await response.json();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new Error(`http-long-poll invalid JSON response: ${String(err?.message ?? err)}`);
|
|
35
|
+
}
|
|
36
|
+
await onPayload(payload);
|
|
18
37
|
}
|
|
19
38
|
catch (err) {
|
|
39
|
+
if (!active && isAbortError(err))
|
|
40
|
+
return;
|
|
20
41
|
await onError(err);
|
|
21
42
|
return;
|
|
22
43
|
}
|
|
44
|
+
finally {
|
|
45
|
+
inFlightAbort = undefined;
|
|
46
|
+
}
|
|
23
47
|
}
|
|
24
48
|
};
|
|
25
49
|
void loop();
|
|
26
50
|
return async () => {
|
|
27
51
|
active = false;
|
|
52
|
+
inFlightAbort?.abort();
|
|
53
|
+
inFlightAbort = undefined;
|
|
28
54
|
};
|
|
29
55
|
};
|
|
@@ -1,35 +1,66 @@
|
|
|
1
|
+
function isAbortError(err) {
|
|
2
|
+
if (!(err instanceof Error))
|
|
3
|
+
return false;
|
|
4
|
+
return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
|
|
5
|
+
}
|
|
1
6
|
export const httpPollStrategy = async (watcher, onPayload, onError) => {
|
|
2
7
|
const interval = watcher.intervalMs ?? 30000;
|
|
3
8
|
let active = true;
|
|
9
|
+
let timer;
|
|
10
|
+
let inFlightAbort;
|
|
4
11
|
const tick = async () => {
|
|
5
12
|
if (!active)
|
|
6
13
|
return;
|
|
7
14
|
try {
|
|
15
|
+
inFlightAbort = new AbortController();
|
|
8
16
|
const response = await fetch(watcher.endpoint, {
|
|
9
17
|
method: watcher.method ?? "GET",
|
|
10
18
|
headers: watcher.headers,
|
|
11
19
|
body: watcher.body,
|
|
12
|
-
signal: AbortSignal.
|
|
20
|
+
signal: AbortSignal.any([
|
|
21
|
+
inFlightAbort.signal,
|
|
22
|
+
AbortSignal.timeout(watcher.timeoutMs ?? 15000),
|
|
23
|
+
]),
|
|
24
|
+
redirect: "error",
|
|
13
25
|
});
|
|
14
26
|
if (!response.ok)
|
|
15
27
|
throw new Error(`http-poll non-2xx: ${response.status}`);
|
|
16
28
|
const contentType = response.headers.get("content-type") ?? "";
|
|
17
|
-
if (!contentType.toLowerCase().includes("json"))
|
|
29
|
+
if (!contentType.toLowerCase().includes("json")) {
|
|
18
30
|
throw new Error(`http-poll expected JSON, got: ${contentType || "unknown"}`);
|
|
19
|
-
|
|
31
|
+
}
|
|
32
|
+
let payload;
|
|
33
|
+
try {
|
|
34
|
+
payload = await response.json();
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new Error(`http-poll invalid JSON response: ${String(err?.message ?? err)}`);
|
|
38
|
+
}
|
|
20
39
|
await onPayload(payload);
|
|
21
40
|
}
|
|
22
41
|
catch (err) {
|
|
42
|
+
if (!active && isAbortError(err))
|
|
43
|
+
return;
|
|
23
44
|
await onError(err);
|
|
24
45
|
return;
|
|
25
46
|
}
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
finally {
|
|
48
|
+
inFlightAbort = undefined;
|
|
49
|
+
}
|
|
50
|
+
if (active) {
|
|
51
|
+
timer = setTimeout(() => {
|
|
28
52
|
void tick();
|
|
29
53
|
}, interval);
|
|
54
|
+
}
|
|
30
55
|
};
|
|
31
56
|
void tick();
|
|
32
57
|
return async () => {
|
|
33
58
|
active = false;
|
|
59
|
+
if (timer) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = undefined;
|
|
62
|
+
}
|
|
63
|
+
inFlightAbort?.abort();
|
|
64
|
+
inFlightAbort = undefined;
|
|
34
65
|
};
|
|
35
66
|
};
|