@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/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 HEARTBEAT_ACK_TOKEN_PATTERN = /\bHEARTBEAT_OK\b/gi;
17
- const SENTINEL_EVENT_INSTRUCTION_PREFIX = "SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Evaluate action policy, decide whether to notify configured deliveryTargets, and execute safe follow-up actions.";
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
- if (legacyRootConfig === undefined)
53
- return pluginConfig;
54
- 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".');
55
- if (!isRecord(legacyRootConfig))
56
- return pluginConfig;
57
- if (Object.keys(pluginConfig).length > 0)
58
- return pluginConfig;
59
- return legacyRootConfig;
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
- getNestedString(payload, ["watcher", "id"]) ??
175
+ asString(watcherRecord?.id) ??
142
176
  getNestedString(payload, ["context", "watcherId"]);
143
177
  const eventName = asString(payload.eventName) ??
144
- getNestedString(payload, ["watcher", "eventName"]) ??
178
+ asString(watcherRecord?.eventName) ??
145
179
  getNestedString(payload, ["event", "name"]);
146
180
  const skillId = asString(payload.skillId) ??
147
- getNestedString(payload, ["watcher", "skillId"]) ??
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(getNestedString(payload, ["trigger", "matchedAt"])) ??
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
- getNestedString(payload, ["trigger", "dedupeKey"]) ??
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
- getNestedString(payload, ["watcher", "sessionGroup"]);
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
- payload: boundedPayload,
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 jsonEnvelope = JSON.stringify(envelope, null, 2);
202
- const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
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 payloadRecord = isRecord(envelope.payload) ? envelope.payload : undefined;
304
- const contextSummary = summarizeContext(payloadRecord && isRecord(payloadRecord.context) ? payloadRecord.context : payloadRecord);
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 rawPrefix = asString(config.hookSessionKey) ??
338
- asString(config.hookSessionPrefix) ??
339
- DEFAULT_HOOK_SESSION_PREFIX;
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 (isRecord(preParsed))
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
- const assistantMessage = normalizeAssistantRelayText(assistantTexts);
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
- await this.completeWithMessage(pending, assistantMessage, "assistant");
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
- this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
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
- const hookResponseRelayManager = new HookResponseRelayManager(config, api);
626
- if (typeof api.on === "function") {
627
- api.on("llm_output", (event, ctx) => {
628
- void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
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 status = message.includes("too large") ? 413 : badRequest ? 400 : 500;
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.timeout(watcher.timeoutMs ?? 60000),
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
- await onPayload(await response.json());
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.timeout(watcher.timeoutMs ?? 15000),
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
- const payload = await response.json();
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
- if (active)
27
- setTimeout(() => {
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
  };