@coffeexdev/openclaw-sentinel 0.4.4 → 0.5.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
@@ -1,14 +1,26 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { sentinelConfigSchema } from "./configSchema.js";
3
3
  import { registerSentinelControl } from "./tool.js";
4
- import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
4
+ import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
5
5
  import { WatcherManager } from "./watcherManager.js";
6
6
  const registeredWebhookPathsByRegistrar = new WeakMap();
7
- const DEFAULT_HOOK_SESSION_KEY = "agent:main:main";
7
+ const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
8
+ const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
9
+ const DEFAULT_HOOK_RESPONSE_TIMEOUT_MS = 30_000;
10
+ const DEFAULT_HOOK_RESPONSE_FALLBACK_MODE = "concise";
8
11
  const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
9
12
  const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
10
13
  const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
11
14
  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.";
15
+ const SUPPORTED_DELIVERY_CHANNELS = new Set([
16
+ "telegram",
17
+ "discord",
18
+ "slack",
19
+ "signal",
20
+ "imessage",
21
+ "whatsapp",
22
+ "line",
23
+ ]);
12
24
  function trimText(value, max) {
13
25
  return value.length <= max ? value : `${value.slice(0, max)}…`;
14
26
  }
@@ -28,6 +40,21 @@ function asIsoString(value) {
28
40
  function isRecord(value) {
29
41
  return !!value && typeof value === "object" && !Array.isArray(value);
30
42
  }
43
+ function resolveSentinelPluginConfig(api) {
44
+ const pluginConfig = isRecord(api.pluginConfig)
45
+ ? api.pluginConfig
46
+ : {};
47
+ const configRoot = isRecord(api.config) ? api.config : undefined;
48
+ const legacyRootConfig = configRoot?.sentinel;
49
+ if (legacyRootConfig === undefined)
50
+ return pluginConfig;
51
+ 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".');
52
+ if (!isRecord(legacyRootConfig))
53
+ return pluginConfig;
54
+ if (Object.keys(pluginConfig).length > 0)
55
+ return pluginConfig;
56
+ return legacyRootConfig;
57
+ }
31
58
  function isDeliveryTarget(value) {
32
59
  return (isRecord(value) &&
33
60
  typeof value.channel === "string" &&
@@ -41,6 +68,14 @@ function normalizePath(path) {
41
68
  const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
42
69
  return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
43
70
  }
71
+ function sanitizeSessionSegment(value) {
72
+ const sanitized = value
73
+ .trim()
74
+ .replace(/[^a-zA-Z0-9_-]+/g, "-")
75
+ .replace(/-+/g, "-")
76
+ .replace(/^-|-$/g, "");
77
+ return sanitized.length > 0 ? sanitized.slice(0, 64) : "unknown";
78
+ }
44
79
  function clipPayloadForPrompt(value) {
45
80
  const serialized = JSON.stringify(value);
46
81
  if (!serialized)
@@ -56,15 +91,63 @@ function clipPayloadForPrompt(value) {
56
91
  preview: `${clipped}…`,
57
92
  };
58
93
  }
94
+ function getNestedString(value, path) {
95
+ let cursor = value;
96
+ for (const segment of path) {
97
+ if (!isRecord(cursor))
98
+ return undefined;
99
+ cursor = cursor[segment];
100
+ }
101
+ return asString(cursor);
102
+ }
103
+ function extractDeliveryContext(payload) {
104
+ const raw = isRecord(payload.deliveryContext) ? payload.deliveryContext : undefined;
105
+ if (!raw)
106
+ return undefined;
107
+ const sessionKey = asString(raw.sessionKey) ??
108
+ asString(raw.sourceSessionKey) ??
109
+ getNestedString(raw, ["source", "sessionKey"]);
110
+ const messageChannel = asString(raw.messageChannel);
111
+ const requesterSenderId = asString(raw.requesterSenderId);
112
+ const agentAccountId = asString(raw.agentAccountId);
113
+ const currentChat = isDeliveryTarget(raw.currentChat)
114
+ ? raw.currentChat
115
+ : isDeliveryTarget(raw.deliveryTarget)
116
+ ? raw.deliveryTarget
117
+ : undefined;
118
+ const deliveryTargets = Array.isArray(raw.deliveryTargets)
119
+ ? raw.deliveryTargets.filter(isDeliveryTarget)
120
+ : undefined;
121
+ const context = {};
122
+ if (sessionKey)
123
+ context.sessionKey = sessionKey;
124
+ if (messageChannel)
125
+ context.messageChannel = messageChannel;
126
+ if (requesterSenderId)
127
+ context.requesterSenderId = requesterSenderId;
128
+ if (agentAccountId)
129
+ context.agentAccountId = agentAccountId;
130
+ if (currentChat)
131
+ context.currentChat = currentChat;
132
+ if (deliveryTargets && deliveryTargets.length > 0)
133
+ context.deliveryTargets = deliveryTargets;
134
+ return Object.keys(context).length > 0 ? context : undefined;
135
+ }
59
136
  function buildSentinelEventEnvelope(payload) {
60
137
  const watcherId = asString(payload.watcherId) ??
61
- (isRecord(payload.watcher) ? asString(payload.watcher.id) : undefined);
138
+ getNestedString(payload, ["watcher", "id"]) ??
139
+ getNestedString(payload, ["context", "watcherId"]);
62
140
  const eventName = asString(payload.eventName) ??
63
- (isRecord(payload.event) ? asString(payload.event.name) : undefined);
141
+ getNestedString(payload, ["watcher", "eventName"]) ??
142
+ getNestedString(payload, ["event", "name"]);
64
143
  const skillId = asString(payload.skillId) ??
65
- (isRecord(payload.watcher) ? asString(payload.watcher.skillId) : undefined) ??
144
+ getNestedString(payload, ["watcher", "skillId"]) ??
145
+ getNestedString(payload, ["context", "skillId"]) ??
66
146
  undefined;
67
- const matchedAt = asIsoString(payload.matchedAt) ?? asIsoString(payload.timestamp) ?? new Date().toISOString();
147
+ const matchedAt = asIsoString(payload.matchedAt) ??
148
+ asIsoString(payload.timestamp) ??
149
+ asIsoString(getNestedString(payload, ["trigger", "matchedAt"])) ??
150
+ new Date().toISOString();
68
151
  const rawPayload = payload.payload ??
69
152
  (isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
70
153
  payload;
@@ -78,10 +161,17 @@ function buildSentinelEventEnvelope(payload) {
78
161
  const dedupeKey = asString(payload.dedupeKey) ??
79
162
  asString(payload.correlationId) ??
80
163
  asString(payload.correlationID) ??
164
+ getNestedString(payload, ["trigger", "dedupeKey"]) ??
81
165
  generatedDedupe;
82
166
  const deliveryTargets = Array.isArray(payload.deliveryTargets)
83
167
  ? payload.deliveryTargets.filter(isDeliveryTarget)
84
168
  : undefined;
169
+ const sourceRoute = getNestedString(payload, ["source", "route"]) ?? DEFAULT_SENTINEL_WEBHOOK_PATH;
170
+ const sourcePlugin = getNestedString(payload, ["source", "plugin"]) ?? "openclaw-sentinel";
171
+ const hookSessionGroup = asString(payload.hookSessionGroup) ??
172
+ asString(payload.sessionGroup) ??
173
+ getNestedString(payload, ["watcher", "sessionGroup"]);
174
+ const deliveryContext = extractDeliveryContext(payload);
85
175
  const envelope = {
86
176
  watcherId: watcherId ?? null,
87
177
  eventName: eventName ?? null,
@@ -90,22 +180,169 @@ function buildSentinelEventEnvelope(payload) {
90
180
  dedupeKey,
91
181
  correlationId: dedupeKey,
92
182
  source: {
93
- route: DEFAULT_SENTINEL_WEBHOOK_PATH,
94
- plugin: "openclaw-sentinel",
183
+ route: sourceRoute,
184
+ plugin: sourcePlugin,
95
185
  },
96
186
  };
97
187
  if (skillId)
98
188
  envelope.skillId = skillId;
189
+ if (hookSessionGroup)
190
+ envelope.hookSessionGroup = hookSessionGroup;
99
191
  if (deliveryTargets && deliveryTargets.length > 0)
100
192
  envelope.deliveryTargets = deliveryTargets;
193
+ if (deliveryContext)
194
+ envelope.deliveryContext = deliveryContext;
101
195
  return envelope;
102
196
  }
103
- function buildSentinelSystemEvent(payload) {
104
- const envelope = buildSentinelEventEnvelope(payload);
197
+ function buildSentinelSystemEvent(envelope) {
105
198
  const jsonEnvelope = JSON.stringify(envelope, null, 2);
106
199
  const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
107
200
  return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
108
201
  }
202
+ function normalizeDeliveryTargets(targets) {
203
+ const deduped = new Map();
204
+ for (const target of targets) {
205
+ const channel = asString(target.channel);
206
+ const to = asString(target.to);
207
+ if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
208
+ continue;
209
+ const accountId = asString(target.accountId);
210
+ const key = `${channel}:${to}:${accountId ?? ""}`;
211
+ deduped.set(key, { channel, to, ...(accountId ? { accountId } : {}) });
212
+ }
213
+ return [...deduped.values()];
214
+ }
215
+ function inferTargetFromSessionKey(sessionKey, accountId) {
216
+ const segments = sessionKey
217
+ .split(":")
218
+ .map((part) => part.trim())
219
+ .filter(Boolean);
220
+ if (segments.length < 5)
221
+ return undefined;
222
+ const channel = segments[2];
223
+ const to = segments.at(-1);
224
+ if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
225
+ return undefined;
226
+ return {
227
+ channel,
228
+ to,
229
+ ...(accountId ? { accountId } : {}),
230
+ };
231
+ }
232
+ function inferRelayTargets(payload, envelope) {
233
+ const inferred = [];
234
+ if (envelope.deliveryTargets?.length)
235
+ inferred.push(...envelope.deliveryTargets);
236
+ if (envelope.deliveryContext?.deliveryTargets?.length) {
237
+ inferred.push(...envelope.deliveryContext.deliveryTargets);
238
+ }
239
+ if (envelope.deliveryContext?.currentChat)
240
+ inferred.push(envelope.deliveryContext.currentChat);
241
+ if (envelope.deliveryContext?.messageChannel && envelope.deliveryContext?.requesterSenderId) {
242
+ if (SUPPORTED_DELIVERY_CHANNELS.has(envelope.deliveryContext.messageChannel)) {
243
+ inferred.push({
244
+ channel: envelope.deliveryContext.messageChannel,
245
+ to: envelope.deliveryContext.requesterSenderId,
246
+ ...(envelope.deliveryContext.agentAccountId
247
+ ? { accountId: envelope.deliveryContext.agentAccountId }
248
+ : {}),
249
+ });
250
+ }
251
+ }
252
+ if (envelope.deliveryContext?.sessionKey) {
253
+ const target = inferTargetFromSessionKey(envelope.deliveryContext.sessionKey, envelope.deliveryContext.agentAccountId);
254
+ if (target)
255
+ inferred.push(target);
256
+ }
257
+ if (isDeliveryTarget(payload.currentChat))
258
+ inferred.push(payload.currentChat);
259
+ const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
260
+ if (isDeliveryTarget(sourceCurrentChat))
261
+ inferred.push(sourceCurrentChat);
262
+ const messageChannel = asString(payload.messageChannel);
263
+ const requesterSenderId = asString(payload.requesterSenderId);
264
+ if (messageChannel && requesterSenderId && SUPPORTED_DELIVERY_CHANNELS.has(messageChannel)) {
265
+ inferred.push({ channel: messageChannel, to: requesterSenderId });
266
+ }
267
+ const fromSessionKey = asString(payload.sessionKey);
268
+ if (fromSessionKey) {
269
+ const target = inferTargetFromSessionKey(fromSessionKey, asString(payload.agentAccountId));
270
+ if (target)
271
+ inferred.push(target);
272
+ }
273
+ const sourceSessionKey = getNestedString(payload, ["source", "sessionKey"]);
274
+ if (sourceSessionKey) {
275
+ const sourceAccountId = getNestedString(payload, ["source", "accountId"]);
276
+ const target = inferTargetFromSessionKey(sourceSessionKey, sourceAccountId);
277
+ if (target)
278
+ inferred.push(target);
279
+ }
280
+ return normalizeDeliveryTargets(inferred);
281
+ }
282
+ function summarizeContext(value) {
283
+ if (!isRecord(value))
284
+ return undefined;
285
+ const entries = Object.entries(value).slice(0, 3);
286
+ if (entries.length === 0)
287
+ return undefined;
288
+ const chunks = entries.map(([key, val]) => {
289
+ if (typeof val === "string")
290
+ return `${key}=${trimText(val, 64)}`;
291
+ if (typeof val === "number" || typeof val === "boolean")
292
+ return `${key}=${String(val)}`;
293
+ return `${key}=${trimText(JSON.stringify(val), 64)}`;
294
+ });
295
+ return chunks.join(" · ");
296
+ }
297
+ function buildRelayMessage(envelope) {
298
+ const title = envelope.eventName ? `Sentinel alert: ${envelope.eventName}` : "Sentinel alert";
299
+ const watcher = envelope.watcherId ? `watcher ${envelope.watcherId}` : "watcher unknown";
300
+ const payloadRecord = isRecord(envelope.payload) ? envelope.payload : undefined;
301
+ const contextSummary = summarizeContext(payloadRecord && isRecord(payloadRecord.context) ? payloadRecord.context : payloadRecord);
302
+ const lines = [title, `${watcher} · ${envelope.matchedAt}`];
303
+ if (contextSummary)
304
+ lines.push(contextSummary);
305
+ const text = lines.join("\n").trim();
306
+ return text.length > 0 ? text : "Sentinel callback received.";
307
+ }
308
+ function normalizeAssistantRelayText(assistantTexts) {
309
+ if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
310
+ return undefined;
311
+ const parts = assistantTexts.map((value) => value.trim()).filter(Boolean);
312
+ if (parts.length === 0)
313
+ return undefined;
314
+ return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
315
+ }
316
+ function resolveHookResponseDedupeWindowMs(config) {
317
+ const candidate = config.hookResponseDedupeWindowMs ??
318
+ config.hookRelayDedupeWindowMs ??
319
+ DEFAULT_RELAY_DEDUPE_WINDOW_MS;
320
+ return Math.max(0, candidate);
321
+ }
322
+ function resolveHookResponseTimeoutMs(config) {
323
+ const candidate = config.hookResponseTimeoutMs ?? DEFAULT_HOOK_RESPONSE_TIMEOUT_MS;
324
+ return Math.max(0, candidate);
325
+ }
326
+ function resolveHookResponseFallbackMode(config) {
327
+ return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
328
+ }
329
+ function buildIsolatedHookSessionKey(envelope, config) {
330
+ const rawPrefix = asString(config.hookSessionKey) ??
331
+ asString(config.hookSessionPrefix) ??
332
+ DEFAULT_HOOK_SESSION_PREFIX;
333
+ const prefix = rawPrefix.replace(/:+$/g, "");
334
+ const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
335
+ if (group) {
336
+ return `${prefix}:group:${sanitizeSessionSegment(group)}`;
337
+ }
338
+ if (envelope.watcherId) {
339
+ return `${prefix}:watcher:${sanitizeSessionSegment(envelope.watcherId)}`;
340
+ }
341
+ if (envelope.dedupeKey) {
342
+ return `${prefix}:event:${sanitizeSessionSegment(envelope.dedupeKey.slice(0, 24))}`;
343
+ }
344
+ return `${prefix}:event:unknown`;
345
+ }
109
346
  async function readSentinelWebhookPayload(req) {
110
347
  const preParsed = req.body;
111
348
  if (isRecord(preParsed))
@@ -178,12 +415,177 @@ async function notifyDeliveryTarget(api, target, message) {
178
415
  throw new Error(`Unsupported delivery target channel: ${target.channel}`);
179
416
  }
180
417
  }
418
+ async function deliverMessageToTargets(api, targets, message) {
419
+ if (targets.length === 0)
420
+ return { delivered: 0, failed: 0 };
421
+ const results = await Promise.all(targets.map(async (target) => {
422
+ try {
423
+ await notifyDeliveryTarget(api, target, message);
424
+ return true;
425
+ }
426
+ catch {
427
+ return false;
428
+ }
429
+ }));
430
+ const delivered = results.filter(Boolean).length;
431
+ return {
432
+ delivered,
433
+ failed: results.length - delivered,
434
+ };
435
+ }
436
+ class HookResponseRelayManager {
437
+ config;
438
+ api;
439
+ recentByDedupe = new Map();
440
+ pendingByDedupe = new Map();
441
+ pendingQueueBySession = new Map();
442
+ constructor(config, api) {
443
+ this.config = config;
444
+ this.api = api;
445
+ }
446
+ register(args) {
447
+ const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
448
+ const now = Date.now();
449
+ if (dedupeWindowMs > 0) {
450
+ for (const [key, ts] of this.recentByDedupe.entries()) {
451
+ if (now - ts > dedupeWindowMs) {
452
+ this.recentByDedupe.delete(key);
453
+ this.pendingByDedupe.delete(key);
454
+ }
455
+ }
456
+ }
457
+ const existingTs = this.recentByDedupe.get(args.dedupeKey);
458
+ if (dedupeWindowMs > 0 &&
459
+ typeof existingTs === "number" &&
460
+ now - existingTs <= dedupeWindowMs) {
461
+ return {
462
+ dedupeKey: args.dedupeKey,
463
+ attempted: args.relayTargets.length,
464
+ delivered: 0,
465
+ failed: 0,
466
+ deduped: true,
467
+ pending: false,
468
+ timeoutMs: resolveHookResponseTimeoutMs(this.config),
469
+ fallbackMode: resolveHookResponseFallbackMode(this.config),
470
+ };
471
+ }
472
+ this.recentByDedupe.set(args.dedupeKey, now);
473
+ const timeoutMs = resolveHookResponseTimeoutMs(this.config);
474
+ const fallbackMode = resolveHookResponseFallbackMode(this.config);
475
+ if (args.relayTargets.length === 0) {
476
+ return {
477
+ dedupeKey: args.dedupeKey,
478
+ attempted: 0,
479
+ delivered: 0,
480
+ failed: 0,
481
+ deduped: false,
482
+ pending: false,
483
+ timeoutMs,
484
+ fallbackMode,
485
+ };
486
+ }
487
+ const pending = {
488
+ dedupeKey: args.dedupeKey,
489
+ sessionKey: args.sessionKey,
490
+ relayTargets: args.relayTargets,
491
+ fallbackMessage: args.fallbackMessage,
492
+ createdAt: now,
493
+ timeoutMs,
494
+ fallbackMode,
495
+ state: "pending",
496
+ };
497
+ this.pendingByDedupe.set(args.dedupeKey, pending);
498
+ const queue = this.pendingQueueBySession.get(args.sessionKey) ?? [];
499
+ queue.push(args.dedupeKey);
500
+ this.pendingQueueBySession.set(args.sessionKey, queue);
501
+ if (timeoutMs === 0) {
502
+ void this.handleTimeout(args.dedupeKey);
503
+ }
504
+ else {
505
+ pending.timer = setTimeout(() => {
506
+ void this.handleTimeout(args.dedupeKey);
507
+ }, timeoutMs);
508
+ }
509
+ return {
510
+ dedupeKey: args.dedupeKey,
511
+ attempted: args.relayTargets.length,
512
+ delivered: 0,
513
+ failed: 0,
514
+ deduped: false,
515
+ pending: true,
516
+ timeoutMs,
517
+ fallbackMode,
518
+ };
519
+ }
520
+ async handleLlmOutput(sessionKey, assistantTexts) {
521
+ if (!sessionKey)
522
+ return;
523
+ const assistantMessage = normalizeAssistantRelayText(assistantTexts);
524
+ if (!assistantMessage)
525
+ return;
526
+ const dedupeKey = this.popNextPendingDedupe(sessionKey);
527
+ if (!dedupeKey)
528
+ return;
529
+ const pending = this.pendingByDedupe.get(dedupeKey);
530
+ if (!pending || pending.state !== "pending")
531
+ return;
532
+ await this.completeWithMessage(pending, assistantMessage, "assistant");
533
+ }
534
+ popNextPendingDedupe(sessionKey) {
535
+ const queue = this.pendingQueueBySession.get(sessionKey);
536
+ if (!queue || queue.length === 0)
537
+ return undefined;
538
+ while (queue.length > 0) {
539
+ const next = queue.shift();
540
+ if (!next)
541
+ continue;
542
+ const pending = this.pendingByDedupe.get(next);
543
+ if (pending && pending.state === "pending") {
544
+ if (queue.length === 0)
545
+ this.pendingQueueBySession.delete(sessionKey);
546
+ else
547
+ this.pendingQueueBySession.set(sessionKey, queue);
548
+ return next;
549
+ }
550
+ }
551
+ this.pendingQueueBySession.delete(sessionKey);
552
+ return undefined;
553
+ }
554
+ async handleTimeout(dedupeKey) {
555
+ const pending = this.pendingByDedupe.get(dedupeKey);
556
+ if (!pending || pending.state !== "pending")
557
+ return;
558
+ if (pending.fallbackMode === "none") {
559
+ this.markClosed(pending, "timed_out");
560
+ return;
561
+ }
562
+ await this.completeWithMessage(pending, pending.fallbackMessage, "timeout");
563
+ }
564
+ async completeWithMessage(pending, message, source) {
565
+ const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
566
+ this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
567
+ this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
568
+ }
569
+ markClosed(pending, state) {
570
+ pending.state = state;
571
+ if (pending.timer) {
572
+ clearTimeout(pending.timer);
573
+ pending.timer = undefined;
574
+ }
575
+ this.pendingByDedupe.set(pending.dedupeKey, pending);
576
+ }
577
+ }
181
578
  export function createSentinelPlugin(overrides) {
182
579
  const config = {
183
580
  allowedHosts: [],
184
581
  localDispatchBase: "http://127.0.0.1:18789",
185
582
  dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
186
- hookSessionKey: DEFAULT_HOOK_SESSION_KEY,
583
+ hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
584
+ hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
585
+ hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
586
+ hookResponseFallbackMode: DEFAULT_HOOK_RESPONSE_FALLBACK_MODE,
587
+ hookResponseDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
588
+ notificationPayloadMode: "concise",
187
589
  limits: {
188
590
  maxWatchersTotal: 200,
189
591
  maxWatchersPerSkill: 20,
@@ -210,6 +612,15 @@ export function createSentinelPlugin(overrides) {
210
612
  await manager.init();
211
613
  },
212
614
  register(api) {
615
+ const runtimeConfig = resolveSentinelPluginConfig(api);
616
+ if (Object.keys(runtimeConfig).length > 0)
617
+ Object.assign(config, runtimeConfig);
618
+ const hookResponseRelayManager = new HookResponseRelayManager(config, api);
619
+ if (typeof api.on === "function") {
620
+ api.on("llm_output", (event, ctx) => {
621
+ void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
622
+ });
623
+ }
213
624
  manager.setNotifier({
214
625
  async notify(target, message) {
215
626
  await notifyDeliveryTarget(api, target, message);
@@ -244,19 +655,28 @@ export function createSentinelPlugin(overrides) {
244
655
  }
245
656
  try {
246
657
  const payload = await readSentinelWebhookPayload(req);
247
- const sessionKey = config.hookSessionKey ?? DEFAULT_HOOK_SESSION_KEY;
248
- const text = buildSentinelSystemEvent(payload);
658
+ const envelope = buildSentinelEventEnvelope(payload);
659
+ const sessionKey = buildIsolatedHookSessionKey(envelope, config);
660
+ const text = buildSentinelSystemEvent(envelope);
249
661
  const enqueued = api.runtime.system.enqueueSystemEvent(text, { sessionKey });
250
662
  api.runtime.system.requestHeartbeatNow({
251
663
  reason: "hook:sentinel",
252
664
  sessionKey,
253
665
  });
666
+ const relayTargets = inferRelayTargets(payload, envelope);
667
+ const relay = hookResponseRelayManager.register({
668
+ dedupeKey: envelope.dedupeKey,
669
+ sessionKey,
670
+ relayTargets,
671
+ fallbackMessage: buildRelayMessage(envelope),
672
+ });
254
673
  res.writeHead(200, { "content-type": "application/json" });
255
674
  res.end(JSON.stringify({
256
675
  ok: true,
257
676
  route: path,
258
677
  sessionKey,
259
678
  enqueued,
679
+ relay,
260
680
  }));
261
681
  }
262
682
  catch (err) {
package/dist/tool.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsonResult } from "openclaw/plugin-sdk";
2
2
  import { Value } from "@sinclair/typebox/value";
3
+ import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
3
4
  import { SentinelToolSchema, SentinelToolValidationSchema } from "./toolSchema.js";
4
5
  import { TemplateValueSchema } from "./templateValueSchema.js";
5
6
  function validateParams(params) {
@@ -63,6 +64,30 @@ function inferDefaultDeliveryTargets(ctx) {
63
64
  }
64
65
  return [];
65
66
  }
67
+ function maybeSetMetadata(metadata, key, value) {
68
+ const trimmed = value?.trim();
69
+ if (!trimmed)
70
+ return;
71
+ if (!metadata[key])
72
+ metadata[key] = trimmed;
73
+ }
74
+ function addOriginDeliveryMetadata(watcher, ctx) {
75
+ const metadataRaw = watcher.metadata;
76
+ const metadata = metadataRaw && typeof metadataRaw === "object" && !Array.isArray(metadataRaw)
77
+ ? { ...metadataRaw }
78
+ : {};
79
+ const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
80
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_SESSION_KEY_METADATA, ctx.sessionKey);
81
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_CHANNEL_METADATA, ctx.messageChannel);
82
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_TARGET_METADATA, ctx.requesterSenderId ?? sessionPeer);
83
+ maybeSetMetadata(metadata, SENTINEL_ORIGIN_ACCOUNT_METADATA, ctx.agentAccountId);
84
+ if (Object.keys(metadata).length === 0)
85
+ return watcher;
86
+ return {
87
+ ...watcher,
88
+ metadata,
89
+ };
90
+ }
66
91
  export function registerSentinelControl(registerTool, manager) {
67
92
  registerTool((ctx) => ({
68
93
  name: "sentinel_control",
@@ -73,10 +98,12 @@ export function registerSentinelControl(registerTool, manager) {
73
98
  const payload = validateParams(params);
74
99
  switch (payload.action) {
75
100
  case "create":
76
- case "add":
77
- return normalizeToolResultText(await manager.create(payload.watcher, {
101
+ case "add": {
102
+ const watcherWithContext = addOriginDeliveryMetadata(payload.watcher, ctx);
103
+ return normalizeToolResultText(await manager.create(watcherWithContext, {
78
104
  deliveryTargets: inferDefaultDeliveryTargets(ctx),
79
105
  }), "Watcher created");
106
+ }
80
107
  case "enable":
81
108
  await manager.enable(payload.id);
82
109
  return normalizeToolResultText(undefined, `Enabled watcher: ${payload.id}`);
@@ -26,6 +26,8 @@ export declare const SentinelToolValidationSchema: import("@sinclair/typebox").T
26
26
  priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
27
27
  deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
28
28
  dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
29
+ notificationPayloadMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"inherit">, import("@sinclair/typebox").TLiteral<"none">, import("@sinclair/typebox").TLiteral<"concise">, import("@sinclair/typebox").TLiteral<"debug">]>>;
30
+ sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
29
31
  }>;
30
32
  retry: import("@sinclair/typebox").TObject<{
31
33
  maxRetries: import("@sinclair/typebox").TNumber;
@@ -74,6 +76,8 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
74
76
  priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
75
77
  deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
76
78
  dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
79
+ notificationPayloadMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"inherit">, import("@sinclair/typebox").TLiteral<"none">, import("@sinclair/typebox").TLiteral<"concise">, import("@sinclair/typebox").TLiteral<"debug">]>>;
80
+ sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
77
81
  }>;
78
82
  retry: import("@sinclair/typebox").TObject<{
79
83
  maxRetries: import("@sinclair/typebox").TNumber;
@@ -35,6 +35,17 @@ const FireConfigSchema = Type.Object({
35
35
  priority: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("normal"), Type.Literal("high"), Type.Literal("critical")], { description: "Callback urgency hint" })),
36
36
  deadlineTemplate: Type.Optional(Type.String({ description: "Optional templated deadline string for callback consumers" })),
37
37
  dedupeKeyTemplate: Type.Optional(Type.String({ description: "Optional template to derive deterministic trigger dedupe key" })),
38
+ notificationPayloadMode: Type.Optional(Type.Union([
39
+ Type.Literal("inherit"),
40
+ Type.Literal("none"),
41
+ Type.Literal("concise"),
42
+ Type.Literal("debug"),
43
+ ], {
44
+ description: "Notification payload mode override for deliveryTargets (inherit global default, suppress messages, concise relay text, or debug envelope block)",
45
+ })),
46
+ sessionGroup: Type.Optional(Type.String({
47
+ description: "Optional hook session group key. Watchers with the same key share one isolated callback-processing session.",
48
+ })),
38
49
  });
39
50
  const RetryPolicySchema = Type.Object({
40
51
  maxRetries: Type.Number({ description: "Maximum number of retry attempts" }),
package/dist/types.d.ts CHANGED
@@ -7,6 +7,13 @@ export interface Condition {
7
7
  }
8
8
  export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
9
9
  export type PriorityLevel = "low" | "normal" | "high" | "critical";
10
+ export type NotificationPayloadMode = "none" | "concise" | "debug";
11
+ export type NotificationPayloadModeOverride = "inherit" | NotificationPayloadMode;
12
+ export type HookResponseFallbackMode = "none" | "concise";
13
+ export declare const SENTINEL_ORIGIN_SESSION_KEY_METADATA = "openclaw.sentinel.origin.sessionKey";
14
+ export declare const SENTINEL_ORIGIN_CHANNEL_METADATA = "openclaw.sentinel.origin.channel";
15
+ export declare const SENTINEL_ORIGIN_TARGET_METADATA = "openclaw.sentinel.origin.to";
16
+ export declare const SENTINEL_ORIGIN_ACCOUNT_METADATA = "openclaw.sentinel.origin.accountId";
10
17
  export interface FireConfig {
11
18
  webhookPath?: string;
12
19
  eventName: string;
@@ -16,6 +23,8 @@ export interface FireConfig {
16
23
  priority?: PriorityLevel;
17
24
  deadlineTemplate?: string;
18
25
  dedupeKeyTemplate?: string;
26
+ notificationPayloadMode?: NotificationPayloadModeOverride;
27
+ sessionGroup?: string;
19
28
  }
20
29
  export interface RetryPolicy {
21
30
  maxRetries: number;
@@ -83,8 +92,16 @@ export interface SentinelConfig {
83
92
  allowedHosts: string[];
84
93
  localDispatchBase: string;
85
94
  dispatchAuthToken?: string;
95
+ /** @deprecated Backward-compatible alias for hookSessionPrefix. */
86
96
  hookSessionKey?: string;
97
+ hookSessionPrefix?: string;
98
+ hookSessionGroup?: string;
99
+ hookRelayDedupeWindowMs?: number;
100
+ hookResponseTimeoutMs?: number;
101
+ hookResponseFallbackMode?: HookResponseFallbackMode;
102
+ hookResponseDedupeWindowMs?: number;
87
103
  stateFilePath?: string;
104
+ notificationPayloadMode?: NotificationPayloadMode;
88
105
  limits: SentinelLimits;
89
106
  }
90
107
  export interface GatewayWebhookDispatcher {