@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/README.md CHANGED
@@ -18,19 +18,53 @@ Add/update `~/.openclaw/openclaw.json`:
18
18
 
19
19
  ```json5
20
20
  {
21
- sentinel: {
22
- // Required: watchers can only call endpoints on these hosts.
23
- allowedHosts: ["api.github.com", "api.coingecko.com"],
24
-
25
- // Default dispatch base for internal webhook callbacks.
26
- localDispatchBase: "http://127.0.0.1:18789",
27
-
28
- // Optional: where /hooks/sentinel events are queued in the LLM loop.
29
- hookSessionKey: "agent:main:main",
30
-
31
- // Optional: bearer token used for dispatch calls back to gateway.
32
- // Set this to your gateway auth token when gateway auth is enabled.
33
- // dispatchAuthToken: "<gateway-token>"
21
+ plugins: {
22
+ entries: {
23
+ "openclaw-sentinel": {
24
+ enabled: true,
25
+ config: {
26
+ // Required: watchers can only call endpoints on these hosts.
27
+ allowedHosts: ["api.github.com", "api.coingecko.com"],
28
+
29
+ // Default dispatch base for internal webhook callbacks.
30
+ localDispatchBase: "http://127.0.0.1:18789",
31
+
32
+ // Optional: base prefix for isolated /hooks/sentinel callback sessions.
33
+ // Sentinel appends :watcher:<id> by default (or :group:<key> when grouped).
34
+ hookSessionPrefix: "agent:main:hooks:sentinel",
35
+
36
+ // Optional: default group key for callbacks without explicit hookSessionGroup.
37
+ // hookSessionGroup: "ops-alerts",
38
+
39
+ // Optional: suppress duplicate relays by dedupe key within this time window.
40
+ hookRelayDedupeWindowMs: 120000,
41
+
42
+ // Optional: guarantee hook-response delivery contract for /hooks/sentinel callbacks.
43
+ // Wait this long for assistant-authored output before fallback behavior applies.
44
+ hookResponseTimeoutMs: 30000,
45
+
46
+ // Optional: timeout fallback relay mode for /hooks/sentinel response contracts.
47
+ // "none" = no fallback message, "concise" = send a fail-safe relay line.
48
+ hookResponseFallbackMode: "concise",
49
+
50
+ // Optional: dedupe repeated callback response contracts by dedupe key.
51
+ hookResponseDedupeWindowMs: 120000,
52
+
53
+ // Optional: payload style for non-/hooks/sentinel deliveryTargets notifications.
54
+ // "none" suppresses delivery-target message fan-out (callback still fires).
55
+ // "concise" (default) sends human-friendly relay text only.
56
+ // "debug" appends a structured sentinel envelope block for diagnostics.
57
+ // notificationPayloadMode: "concise",
58
+
59
+ // Optional legacy alias for hookSessionPrefix (still supported).
60
+ // hookSessionKey: "agent:main:hooks:sentinel",
61
+
62
+ // Optional: bearer token used for dispatch calls back to gateway.
63
+ // Set this to your gateway auth token when gateway auth is enabled.
64
+ // dispatchAuthToken: "<gateway-token>"
65
+ },
66
+ },
67
+ },
34
68
  },
35
69
  }
36
70
  ```
@@ -41,6 +75,20 @@ Add/update `~/.openclaw/openclaw.json`:
41
75
  openclaw gateway restart
42
76
  ```
43
77
 
78
+ ### Troubleshooting: `Unrecognized key: "sentinel"`
79
+
80
+ If gateway startup/validation reports:
81
+
82
+ ```text
83
+ Unrecognized key: "sentinel"
84
+ ```
85
+
86
+ your config is using the old root-level shape. Move Sentinel config under:
87
+
88
+ - `plugins.entries.openclaw-sentinel.config`
89
+
90
+ Sentinel also logs a runtime warning when that legacy root key is still observable, but it never writes a root-level `sentinel` key.
91
+
44
92
  ### 4) Create your first watcher (`sentinel_control`)
45
93
 
46
94
  ```json
@@ -65,6 +113,7 @@ openclaw gateway restart
65
113
  "workflow": "alerts"
66
114
  },
67
115
  "priority": "high",
116
+ "sessionGroup": "portfolio-risk",
68
117
  "deadlineTemplate": "${timestamp}",
69
118
  "payloadTemplate": {
70
119
  "event": "${event.name}",
@@ -96,12 +145,13 @@ Use `sentinel_control`:
96
145
 
97
146
  1. Sentinel evaluates conditions.
98
147
  2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
99
- 3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `source`) so downstream agent behavior is workflow-agnostic.
100
- 4. It also sends a notification message to each configured `deliveryTargets` destination (defaults to the current chat context when watcher is created from a channel session).
101
- 5. For `/hooks/sentinel`, the plugin route enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
102
- 6. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
148
+ 3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `deliveryContext`, `source`) so downstream agent behavior is workflow-agnostic.
149
+ 4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
150
+ 5. The hook route creates a **response-delivery contract** keyed by callback dedupe key, preserving original chat/session context (`deliveryContext`) and intended relay targets.
151
+ 6. OpenClaw processes each callback in an isolated hook session: per-watcher by default, or grouped when `hookSessionGroup` / `fire.sessionGroup` is set. Shared global hook-session mode is intentionally not supported.
152
+ 7. When hook-session LLM output arrives, Sentinel relays assistant-authored text to the original chat context. If no assistant output arrives before `hookResponseTimeoutMs`, optional fallback relay behavior is applied (`hookResponseFallbackMode`).
103
153
 
104
- The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
154
+ The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Response contracts are dedupe-aware by callback dedupe key (`hookResponseDedupeWindowMs`).
105
155
 
106
156
  Sample emitted envelope:
107
157
 
@@ -120,6 +170,12 @@ Sample emitted envelope:
120
170
  "context": { "asset": "ETH", "priceUsd": 5001, "workflow": "alerts" },
121
171
  "payload": { "ethereum": { "usd": 5001 } },
122
172
  "deliveryTargets": [{ "channel": "telegram", "to": "5613673222" }],
173
+ "deliveryContext": {
174
+ "sessionKey": "agent:main:telegram:direct:5613673222",
175
+ "messageChannel": "telegram",
176
+ "requesterSenderId": "5613673222",
177
+ "currentChat": { "channel": "telegram", "to": "5613673222" }
178
+ },
123
179
  "source": { "plugin": "openclaw-sentinel", "route": "/hooks/sentinel" }
124
180
  }
125
181
  ```
@@ -185,6 +241,124 @@ It **does not** execute user-authored code from watcher definitions.
185
241
 
186
242
  `deliveryTargets` is optional. If omitted on `create`, Sentinel infers a default target from the current tool/session context (channel + current peer).
187
243
 
244
+ ## Notification payload delivery modes
245
+
246
+ Sentinel always dispatches the callback envelope to `localDispatchBase + webhookPath` on match.
247
+ `notificationPayloadMode` only controls **additional fan-out messages** to `deliveryTargets` for watcher dispatches (for example `/hooks/agent`).
248
+ It does **not** control `/hooks/sentinel` hook-response contracts or assistant-output relay behavior.
249
+
250
+ Global mode options:
251
+
252
+ - `none`: suppress delivery-target notification messages (callback dispatch still occurs)
253
+ - `concise` (default): send short relay text only
254
+ - `debug`: send relay text plus `SENTINEL_DEBUG_ENVELOPE_JSON` block
255
+
256
+ ### 1) Global notifications disabled (`none`)
257
+
258
+ ```json5
259
+ {
260
+ sentinel: {
261
+ allowedHosts: ["api.github.com"],
262
+ notificationPayloadMode: "none",
263
+ },
264
+ }
265
+ ```
266
+
267
+ ### 2) Global concise relay (default)
268
+
269
+ ```json5
270
+ {
271
+ sentinel: {
272
+ allowedHosts: ["api.github.com"],
273
+ notificationPayloadMode: "concise",
274
+ },
275
+ }
276
+ ```
277
+
278
+ ### 3) Global debug diagnostics
279
+
280
+ ```json5
281
+ {
282
+ sentinel: {
283
+ allowedHosts: ["api.github.com"],
284
+ notificationPayloadMode: "debug",
285
+ },
286
+ }
287
+ ```
288
+
289
+ In debug mode, delivery notifications include the same concise relay line plus a `SENTINEL_DEBUG_ENVELOPE_JSON` block for diagnostics.
290
+
291
+ ### 4) Per-watcher override (`watcher.fire.notificationPayloadMode`)
292
+
293
+ ```json
294
+ {
295
+ "action": "create",
296
+ "watcher": {
297
+ "id": "status-watch",
298
+ "skillId": "skills.ops",
299
+ "enabled": true,
300
+ "strategy": "http-poll",
301
+ "endpoint": "https://status.example.com/api/health",
302
+ "intervalMs": 10000,
303
+ "match": "all",
304
+ "conditions": [{ "path": "status", "op": "eq", "value": "degraded" }],
305
+ "fire": {
306
+ "webhookPath": "/hooks/agent",
307
+ "eventName": "service_degraded",
308
+ "notificationPayloadMode": "none",
309
+ "payloadTemplate": { "event": "${event.name}", "status": "${payload.status}" }
310
+ },
311
+ "retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 }
312
+ }
313
+ }
314
+ ```
315
+
316
+ Allowed values:
317
+
318
+ - `inherit` (or omitted): follow global `notificationPayloadMode`
319
+ - `none`: suppress delivery-target notification messages for this watcher
320
+ - `concise`: force concise notification text for this watcher
321
+ - `debug`: force debug envelope output for this watcher
322
+
323
+ Precedence: **watcher override > global setting**.
324
+
325
+ ### Migration notes
326
+
327
+ - Existing installs keep default behavior (`concise`) unless you set `notificationPayloadMode` explicitly.
328
+ - If you want callback-only operation (wake LLM loop via `/hooks/sentinel` but no delivery-target chat message), set global or per-watcher mode to `none`.
329
+
330
+ ## Hook-response delivery contract (`/hooks/sentinel`)
331
+
332
+ `/hooks/sentinel` now enforces a dedicated trigger → LLM → user-visible relay contract:
333
+
334
+ 1. Callback is enqueued to isolated hook session.
335
+ 2. Contract captures original delivery context (`deliveryContext` + resolved `deliveryTargets`).
336
+ 3. First assistant-authored `llm_output` for that pending callback is relayed to target chat.
337
+ 4. If no assistant output arrives in time (`hookResponseTimeoutMs`), fallback is configurable:
338
+ - `hookResponseFallbackMode: "concise"` (default) sends a short fail-safe relay.
339
+ - `hookResponseFallbackMode: "none"` suppresses fallback.
340
+ 5. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
341
+
342
+ Example config:
343
+
344
+ ```json5
345
+ {
346
+ plugins: {
347
+ entries: {
348
+ "openclaw-sentinel": {
349
+ enabled: true,
350
+ config: {
351
+ allowedHosts: ["api.github.com"],
352
+ hookResponseTimeoutMs: 30000,
353
+ hookResponseFallbackMode: "concise",
354
+ hookResponseDedupeWindowMs: 120000,
355
+ },
356
+ },
357
+ },
358
+ },
359
+ }
360
+ ```
361
+
188
362
  ## Runtime controls
189
363
 
190
364
  ```json
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
2
3
  import { renderTemplate } from "./template.js";
3
4
  const MAX_PAYLOAD_JSON_CHARS = 4000;
4
5
  function toIntent(eventName) {
@@ -30,6 +31,32 @@ function truncatePayload(payload) {
30
31
  preview: serialized.slice(0, MAX_PAYLOAD_JSON_CHARS),
31
32
  };
32
33
  }
34
+ function buildDeliveryContextFromMetadata(watcher) {
35
+ const metadata = watcher.metadata;
36
+ if (!metadata)
37
+ return undefined;
38
+ const sessionKey = metadata[SENTINEL_ORIGIN_SESSION_KEY_METADATA]?.trim();
39
+ const channel = metadata[SENTINEL_ORIGIN_CHANNEL_METADATA]?.trim();
40
+ const to = metadata[SENTINEL_ORIGIN_TARGET_METADATA]?.trim();
41
+ const accountId = metadata[SENTINEL_ORIGIN_ACCOUNT_METADATA]?.trim();
42
+ const context = {};
43
+ if (sessionKey)
44
+ context.sessionKey = sessionKey;
45
+ if (channel)
46
+ context.messageChannel = channel;
47
+ if (to)
48
+ context.requesterSenderId = to;
49
+ if (accountId)
50
+ context.agentAccountId = accountId;
51
+ if (channel && to) {
52
+ context.currentChat = {
53
+ channel,
54
+ to,
55
+ ...(accountId ? { accountId } : {}),
56
+ };
57
+ }
58
+ return Object.keys(context).length > 0 ? context : undefined;
59
+ }
33
60
  function getPath(obj, path) {
34
61
  return path.split(".").reduce((acc, part) => acc?.[part], obj);
35
62
  }
@@ -73,6 +100,7 @@ export function createCallbackEnvelope(args) {
73
100
  const dedupeSeed = getTemplateString(watcher.fire.dedupeKeyTemplate, context) ??
74
101
  `${watcher.id}|${watcher.fire.eventName}|${matchedAt}`;
75
102
  const dedupeKey = createHash("sha256").update(dedupeSeed).digest("hex");
103
+ const deliveryContext = buildDeliveryContextFromMetadata(watcher);
76
104
  return {
77
105
  type: "sentinel.callback",
78
106
  version: "1",
@@ -89,6 +117,8 @@ export function createCallbackEnvelope(args) {
89
117
  priority,
90
118
  ...(deadline ? { deadline } : {}),
91
119
  },
120
+ ...(watcher.fire.sessionGroup ? { hookSessionGroup: watcher.fire.sessionGroup } : {}),
121
+ ...(deliveryContext ? { deliveryContext } : {}),
92
122
  context: renderedContext ?? summarizePayload(payload),
93
123
  payload: truncatePayload(payload),
94
124
  deliveryTargets: watcher.deliveryTargets ?? [],
@@ -6,12 +6,25 @@ const LimitsSchema = Type.Object({
6
6
  maxConditionsPerWatcher: Type.Integer({ minimum: 1 }),
7
7
  maxIntervalMsFloor: Type.Integer({ minimum: 1 }),
8
8
  }, { additionalProperties: false });
9
+ const NotificationPayloadModeSchema = Type.Union([
10
+ Type.Literal("none"),
11
+ Type.Literal("concise"),
12
+ Type.Literal("debug"),
13
+ ]);
14
+ const HookResponseFallbackModeSchema = Type.Union([Type.Literal("none"), Type.Literal("concise")]);
9
15
  const ConfigSchema = Type.Object({
10
16
  allowedHosts: Type.Array(Type.String()),
11
17
  localDispatchBase: Type.String({ minLength: 1 }),
12
18
  dispatchAuthToken: Type.Optional(Type.String()),
13
19
  hookSessionKey: Type.Optional(Type.String({ minLength: 1 })),
20
+ hookSessionPrefix: Type.Optional(Type.String({ minLength: 1 })),
21
+ hookSessionGroup: Type.Optional(Type.String({ minLength: 1 })),
22
+ hookRelayDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
23
+ hookResponseTimeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
24
+ hookResponseFallbackMode: Type.Optional(HookResponseFallbackModeSchema),
25
+ hookResponseDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
14
26
  stateFilePath: Type.Optional(Type.String()),
27
+ notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
15
28
  limits: Type.Optional(LimitsSchema),
16
29
  }, { additionalProperties: false });
17
30
  function withDefaults(value) {
@@ -22,8 +35,23 @@ function withDefaults(value) {
22
35
  ? value.localDispatchBase
23
36
  : "http://127.0.0.1:18789",
24
37
  dispatchAuthToken: typeof value.dispatchAuthToken === "string" ? value.dispatchAuthToken : undefined,
25
- hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : "agent:main:main",
38
+ hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : undefined,
39
+ hookSessionPrefix: typeof value.hookSessionPrefix === "string"
40
+ ? value.hookSessionPrefix
41
+ : "agent:main:hooks:sentinel",
42
+ hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
43
+ hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" ? value.hookRelayDedupeWindowMs : 120000,
44
+ hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" ? value.hookResponseTimeoutMs : 30000,
45
+ hookResponseFallbackMode: value.hookResponseFallbackMode === "none" ? "none" : "concise",
46
+ hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number"
47
+ ? value.hookResponseDedupeWindowMs
48
+ : 120000,
26
49
  stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
50
+ notificationPayloadMode: value.notificationPayloadMode === "none"
51
+ ? "none"
52
+ : value.notificationPayloadMode === "debug"
53
+ ? "debug"
54
+ : "concise",
27
55
  limits: {
28
56
  maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" ? limitsIn.maxWatchersTotal : 200,
29
57
  maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" ? limitsIn.maxWatchersPerSkill : 20,
@@ -96,13 +124,51 @@ export const sentinelConfigSchema = {
96
124
  },
97
125
  hookSessionKey: {
98
126
  type: "string",
99
- description: "Session key used when /hooks/sentinel enqueues system events into the LLM loop",
100
- default: "agent:main:main",
127
+ description: "Deprecated alias for hookSessionPrefix. Sentinel always appends watcher/group segments to prevent a shared global callback session.",
128
+ },
129
+ hookSessionPrefix: {
130
+ type: "string",
131
+ description: "Base session key prefix used for isolated /hooks/sentinel callback sessions (default: agent:main:hooks:sentinel)",
132
+ default: "agent:main:hooks:sentinel",
133
+ },
134
+ hookSessionGroup: {
135
+ type: "string",
136
+ description: "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session.",
137
+ },
138
+ hookRelayDedupeWindowMs: {
139
+ type: "number",
140
+ minimum: 0,
141
+ description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
142
+ default: 120000,
143
+ },
144
+ hookResponseTimeoutMs: {
145
+ type: "number",
146
+ minimum: 0,
147
+ description: "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
148
+ default: 30000,
149
+ },
150
+ hookResponseFallbackMode: {
151
+ type: "string",
152
+ enum: ["none", "concise"],
153
+ description: "Fallback behavior when no assistant response arrives before hookResponseTimeoutMs: none (silent timeout) or concise fail-safe relay",
154
+ default: "concise",
155
+ },
156
+ hookResponseDedupeWindowMs: {
157
+ type: "number",
158
+ minimum: 0,
159
+ description: "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
160
+ default: 120000,
101
161
  },
102
162
  stateFilePath: {
103
163
  type: "string",
104
164
  description: "Custom path for the sentinel state persistence file",
105
165
  },
166
+ notificationPayloadMode: {
167
+ type: "string",
168
+ enum: ["none", "concise", "debug"],
169
+ description: "Controls delivery-target notifications: none (suppress message fan-out), concise relay text (default), or relay text with debug envelope payload",
170
+ default: "concise",
171
+ },
106
172
  limits: {
107
173
  type: "object",
108
174
  additionalProperties: false,
@@ -148,8 +214,38 @@ export const sentinelConfigSchema = {
148
214
  placeholder: "sk-...",
149
215
  },
150
216
  hookSessionKey: {
151
- label: "Sentinel Hook Session Key",
152
- help: "Session key that receives /hooks/sentinel callback events (default: agent:main:main)",
217
+ label: "Hook Session Key (Deprecated)",
218
+ help: "Deprecated alias for hookSessionPrefix. Sentinel appends watcher/group segments automatically.",
219
+ advanced: true,
220
+ },
221
+ hookSessionPrefix: {
222
+ label: "Hook Session Prefix",
223
+ help: "Base prefix for isolated callback sessions (default: agent:main:hooks:sentinel)",
224
+ advanced: true,
225
+ },
226
+ hookSessionGroup: {
227
+ label: "Default Hook Session Group",
228
+ help: "Optional default group key for callback sessions. Watchers with the same group share one isolated session.",
229
+ advanced: true,
230
+ },
231
+ hookRelayDedupeWindowMs: {
232
+ label: "Hook Relay Dedupe Window (ms)",
233
+ help: "Suppress duplicate relay messages with the same dedupe key for this many milliseconds",
234
+ advanced: true,
235
+ },
236
+ hookResponseTimeoutMs: {
237
+ label: "Hook Response Timeout (ms)",
238
+ help: "How long to wait for assistant-authored hook output before optional fallback relay",
239
+ advanced: true,
240
+ },
241
+ hookResponseFallbackMode: {
242
+ label: "Hook Response Fallback Mode",
243
+ help: "If timeout occurs, choose none (silent) or concise fail-safe relay",
244
+ advanced: true,
245
+ },
246
+ hookResponseDedupeWindowMs: {
247
+ label: "Hook Response Dedupe Window (ms)",
248
+ help: "Deduplicate hook-response delivery contracts by dedupe key within this window",
153
249
  advanced: true,
154
250
  },
155
251
  stateFilePath: {
@@ -157,6 +253,11 @@ export const sentinelConfigSchema = {
157
253
  help: "Custom path for sentinel state persistence file",
158
254
  advanced: true,
159
255
  },
256
+ notificationPayloadMode: {
257
+ label: "Notification Payload Mode",
258
+ help: "Choose none (suppress delivery-target messages), concise relay text (default), or include debug envelope payload",
259
+ advanced: true,
260
+ },
160
261
  "limits.maxWatchersTotal": {
161
262
  label: "Max Watchers",
162
263
  help: "Maximum total watchers across all skills",