@coffeexdev/openclaw-sentinel 0.4.4 → 0.4.5
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 +140 -17
- package/dist/callbackEnvelope.js +1 -0
- package/dist/configSchema.js +64 -5
- package/dist/index.js +218 -12
- package/dist/toolSchema.d.ts +4 -0
- package/dist/toolSchema.js +11 -0
- package/dist/types.d.ts +9 -0
- package/dist/validator.d.ts +2 -0
- package/dist/validator.js +7 -0
- package/dist/watcherManager.js +33 -2
- package/openclaw.plugin.json +44 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,19 +18,42 @@ Add/update `~/.openclaw/openclaw.json`:
|
|
|
18
18
|
|
|
19
19
|
```json5
|
|
20
20
|
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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: payload style for non-/hooks/sentinel deliveryTargets notifications.
|
|
43
|
+
// "none" suppresses delivery-target message fan-out (callback still fires).
|
|
44
|
+
// "concise" (default) sends human-friendly relay text only.
|
|
45
|
+
// "debug" appends a structured sentinel envelope block for diagnostics.
|
|
46
|
+
// notificationPayloadMode: "concise",
|
|
47
|
+
|
|
48
|
+
// Optional legacy alias for hookSessionPrefix (still supported).
|
|
49
|
+
// hookSessionKey: "agent:main:hooks:sentinel",
|
|
50
|
+
|
|
51
|
+
// Optional: bearer token used for dispatch calls back to gateway.
|
|
52
|
+
// Set this to your gateway auth token when gateway auth is enabled.
|
|
53
|
+
// dispatchAuthToken: "<gateway-token>"
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
34
57
|
},
|
|
35
58
|
}
|
|
36
59
|
```
|
|
@@ -41,6 +64,20 @@ Add/update `~/.openclaw/openclaw.json`:
|
|
|
41
64
|
openclaw gateway restart
|
|
42
65
|
```
|
|
43
66
|
|
|
67
|
+
### Troubleshooting: `Unrecognized key: "sentinel"`
|
|
68
|
+
|
|
69
|
+
If gateway startup/validation reports:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
Unrecognized key: "sentinel"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
your config is using the old root-level shape. Move Sentinel config under:
|
|
76
|
+
|
|
77
|
+
- `plugins.entries.openclaw-sentinel.config`
|
|
78
|
+
|
|
79
|
+
Sentinel also logs a runtime warning when that legacy root key is still observable, but it never writes a root-level `sentinel` key.
|
|
80
|
+
|
|
44
81
|
### 4) Create your first watcher (`sentinel_control`)
|
|
45
82
|
|
|
46
83
|
```json
|
|
@@ -65,6 +102,7 @@ openclaw gateway restart
|
|
|
65
102
|
"workflow": "alerts"
|
|
66
103
|
},
|
|
67
104
|
"priority": "high",
|
|
105
|
+
"sessionGroup": "portfolio-risk",
|
|
68
106
|
"deadlineTemplate": "${timestamp}",
|
|
69
107
|
"payloadTemplate": {
|
|
70
108
|
"event": "${event.name}",
|
|
@@ -97,11 +135,11 @@ Use `sentinel_control`:
|
|
|
97
135
|
1. Sentinel evaluates conditions.
|
|
98
136
|
2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
|
|
99
137
|
3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `source`) so downstream agent behavior is workflow-agnostic.
|
|
100
|
-
4.
|
|
101
|
-
5.
|
|
102
|
-
6. OpenClaw
|
|
138
|
+
4. For `/hooks/sentinel`, delivery to user chat is relayed from the hook route using `deliveryTargets` (or inferred chat context when available) with a concise alert message.
|
|
139
|
+
5. The `/hooks/sentinel` route enqueues an instruction-prefixed system event plus structured JSON envelope and requests heartbeat wake.
|
|
140
|
+
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.
|
|
103
141
|
|
|
104
|
-
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
|
|
142
|
+
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Relay notifications are dedupe-aware by callback dedupe key.
|
|
105
143
|
|
|
106
144
|
Sample emitted envelope:
|
|
107
145
|
|
|
@@ -185,6 +223,91 @@ It **does not** execute user-authored code from watcher definitions.
|
|
|
185
223
|
|
|
186
224
|
`deliveryTargets` is optional. If omitted on `create`, Sentinel infers a default target from the current tool/session context (channel + current peer).
|
|
187
225
|
|
|
226
|
+
## Notification payload delivery modes
|
|
227
|
+
|
|
228
|
+
Sentinel always dispatches the callback envelope to `localDispatchBase + webhookPath` on match.
|
|
229
|
+
`notificationPayloadMode` only controls **additional fan-out messages** to `deliveryTargets`.
|
|
230
|
+
|
|
231
|
+
Global mode options:
|
|
232
|
+
|
|
233
|
+
- `none`: suppress delivery-target notification messages (callback dispatch still occurs)
|
|
234
|
+
- `concise` (default): send short relay text only
|
|
235
|
+
- `debug`: send relay text plus `SENTINEL_DEBUG_ENVELOPE_JSON` block
|
|
236
|
+
|
|
237
|
+
### 1) Global notifications disabled (`none`)
|
|
238
|
+
|
|
239
|
+
```json5
|
|
240
|
+
{
|
|
241
|
+
sentinel: {
|
|
242
|
+
allowedHosts: ["api.github.com"],
|
|
243
|
+
notificationPayloadMode: "none",
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### 2) Global concise relay (default)
|
|
249
|
+
|
|
250
|
+
```json5
|
|
251
|
+
{
|
|
252
|
+
sentinel: {
|
|
253
|
+
allowedHosts: ["api.github.com"],
|
|
254
|
+
notificationPayloadMode: "concise",
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### 3) Global debug diagnostics
|
|
260
|
+
|
|
261
|
+
```json5
|
|
262
|
+
{
|
|
263
|
+
sentinel: {
|
|
264
|
+
allowedHosts: ["api.github.com"],
|
|
265
|
+
notificationPayloadMode: "debug",
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
In debug mode, delivery notifications include the same concise relay line plus a `SENTINEL_DEBUG_ENVELOPE_JSON` block for diagnostics.
|
|
271
|
+
|
|
272
|
+
### 4) Per-watcher override (`watcher.fire.notificationPayloadMode`)
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
{
|
|
276
|
+
"action": "create",
|
|
277
|
+
"watcher": {
|
|
278
|
+
"id": "status-watch",
|
|
279
|
+
"skillId": "skills.ops",
|
|
280
|
+
"enabled": true,
|
|
281
|
+
"strategy": "http-poll",
|
|
282
|
+
"endpoint": "https://status.example.com/api/health",
|
|
283
|
+
"intervalMs": 10000,
|
|
284
|
+
"match": "all",
|
|
285
|
+
"conditions": [{ "path": "status", "op": "eq", "value": "degraded" }],
|
|
286
|
+
"fire": {
|
|
287
|
+
"webhookPath": "/hooks/agent",
|
|
288
|
+
"eventName": "service_degraded",
|
|
289
|
+
"notificationPayloadMode": "none",
|
|
290
|
+
"payloadTemplate": { "event": "${event.name}", "status": "${payload.status}" }
|
|
291
|
+
},
|
|
292
|
+
"retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Allowed values:
|
|
298
|
+
|
|
299
|
+
- `inherit` (or omitted): follow global `notificationPayloadMode`
|
|
300
|
+
- `none`: suppress delivery-target notification messages for this watcher
|
|
301
|
+
- `concise`: force concise notification text for this watcher
|
|
302
|
+
- `debug`: force debug envelope output for this watcher
|
|
303
|
+
|
|
304
|
+
Precedence: **watcher override > global setting**.
|
|
305
|
+
|
|
306
|
+
### Migration notes
|
|
307
|
+
|
|
308
|
+
- Existing installs keep default behavior (`concise`) unless you set `notificationPayloadMode` explicitly.
|
|
309
|
+
- 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`.
|
|
310
|
+
|
|
188
311
|
## Runtime controls
|
|
189
312
|
|
|
190
313
|
```json
|
package/dist/callbackEnvelope.js
CHANGED
|
@@ -89,6 +89,7 @@ export function createCallbackEnvelope(args) {
|
|
|
89
89
|
priority,
|
|
90
90
|
...(deadline ? { deadline } : {}),
|
|
91
91
|
},
|
|
92
|
+
...(watcher.fire.sessionGroup ? { hookSessionGroup: watcher.fire.sessionGroup } : {}),
|
|
92
93
|
context: renderedContext ?? summarizePayload(payload),
|
|
93
94
|
payload: truncatePayload(payload),
|
|
94
95
|
deliveryTargets: watcher.deliveryTargets ?? [],
|
package/dist/configSchema.js
CHANGED
|
@@ -6,12 +6,21 @@ 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
|
+
]);
|
|
9
14
|
const ConfigSchema = Type.Object({
|
|
10
15
|
allowedHosts: Type.Array(Type.String()),
|
|
11
16
|
localDispatchBase: Type.String({ minLength: 1 }),
|
|
12
17
|
dispatchAuthToken: Type.Optional(Type.String()),
|
|
13
18
|
hookSessionKey: Type.Optional(Type.String({ minLength: 1 })),
|
|
19
|
+
hookSessionPrefix: Type.Optional(Type.String({ minLength: 1 })),
|
|
20
|
+
hookSessionGroup: Type.Optional(Type.String({ minLength: 1 })),
|
|
21
|
+
hookRelayDedupeWindowMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
14
22
|
stateFilePath: Type.Optional(Type.String()),
|
|
23
|
+
notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
|
|
15
24
|
limits: Type.Optional(LimitsSchema),
|
|
16
25
|
}, { additionalProperties: false });
|
|
17
26
|
function withDefaults(value) {
|
|
@@ -22,8 +31,18 @@ function withDefaults(value) {
|
|
|
22
31
|
? value.localDispatchBase
|
|
23
32
|
: "http://127.0.0.1:18789",
|
|
24
33
|
dispatchAuthToken: typeof value.dispatchAuthToken === "string" ? value.dispatchAuthToken : undefined,
|
|
25
|
-
hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey :
|
|
34
|
+
hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : undefined,
|
|
35
|
+
hookSessionPrefix: typeof value.hookSessionPrefix === "string"
|
|
36
|
+
? value.hookSessionPrefix
|
|
37
|
+
: "agent:main:hooks:sentinel",
|
|
38
|
+
hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
|
|
39
|
+
hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" ? value.hookRelayDedupeWindowMs : 120000,
|
|
26
40
|
stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
|
|
41
|
+
notificationPayloadMode: value.notificationPayloadMode === "none"
|
|
42
|
+
? "none"
|
|
43
|
+
: value.notificationPayloadMode === "debug"
|
|
44
|
+
? "debug"
|
|
45
|
+
: "concise",
|
|
27
46
|
limits: {
|
|
28
47
|
maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" ? limitsIn.maxWatchersTotal : 200,
|
|
29
48
|
maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" ? limitsIn.maxWatchersPerSkill : 20,
|
|
@@ -96,13 +115,33 @@ export const sentinelConfigSchema = {
|
|
|
96
115
|
},
|
|
97
116
|
hookSessionKey: {
|
|
98
117
|
type: "string",
|
|
99
|
-
description: "
|
|
100
|
-
|
|
118
|
+
description: "Deprecated alias for hookSessionPrefix. Sentinel always appends watcher/group segments to prevent a shared global callback session.",
|
|
119
|
+
},
|
|
120
|
+
hookSessionPrefix: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "Base session key prefix used for isolated /hooks/sentinel callback sessions (default: agent:main:hooks:sentinel)",
|
|
123
|
+
default: "agent:main:hooks:sentinel",
|
|
124
|
+
},
|
|
125
|
+
hookSessionGroup: {
|
|
126
|
+
type: "string",
|
|
127
|
+
description: "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session.",
|
|
128
|
+
},
|
|
129
|
+
hookRelayDedupeWindowMs: {
|
|
130
|
+
type: "number",
|
|
131
|
+
minimum: 0,
|
|
132
|
+
description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
|
|
133
|
+
default: 120000,
|
|
101
134
|
},
|
|
102
135
|
stateFilePath: {
|
|
103
136
|
type: "string",
|
|
104
137
|
description: "Custom path for the sentinel state persistence file",
|
|
105
138
|
},
|
|
139
|
+
notificationPayloadMode: {
|
|
140
|
+
type: "string",
|
|
141
|
+
enum: ["none", "concise", "debug"],
|
|
142
|
+
description: "Controls delivery-target notifications: none (suppress message fan-out), concise relay text (default), or relay text with debug envelope payload",
|
|
143
|
+
default: "concise",
|
|
144
|
+
},
|
|
106
145
|
limits: {
|
|
107
146
|
type: "object",
|
|
108
147
|
additionalProperties: false,
|
|
@@ -148,8 +187,23 @@ export const sentinelConfigSchema = {
|
|
|
148
187
|
placeholder: "sk-...",
|
|
149
188
|
},
|
|
150
189
|
hookSessionKey: {
|
|
151
|
-
label: "
|
|
152
|
-
help: "
|
|
190
|
+
label: "Hook Session Key (Deprecated)",
|
|
191
|
+
help: "Deprecated alias for hookSessionPrefix. Sentinel appends watcher/group segments automatically.",
|
|
192
|
+
advanced: true,
|
|
193
|
+
},
|
|
194
|
+
hookSessionPrefix: {
|
|
195
|
+
label: "Hook Session Prefix",
|
|
196
|
+
help: "Base prefix for isolated callback sessions (default: agent:main:hooks:sentinel)",
|
|
197
|
+
advanced: true,
|
|
198
|
+
},
|
|
199
|
+
hookSessionGroup: {
|
|
200
|
+
label: "Default Hook Session Group",
|
|
201
|
+
help: "Optional default group key for callback sessions. Watchers with the same group share one isolated session.",
|
|
202
|
+
advanced: true,
|
|
203
|
+
},
|
|
204
|
+
hookRelayDedupeWindowMs: {
|
|
205
|
+
label: "Hook Relay Dedupe Window (ms)",
|
|
206
|
+
help: "Suppress duplicate relay messages with the same dedupe key for this many milliseconds",
|
|
153
207
|
advanced: true,
|
|
154
208
|
},
|
|
155
209
|
stateFilePath: {
|
|
@@ -157,6 +211,11 @@ export const sentinelConfigSchema = {
|
|
|
157
211
|
help: "Custom path for sentinel state persistence file",
|
|
158
212
|
advanced: true,
|
|
159
213
|
},
|
|
214
|
+
notificationPayloadMode: {
|
|
215
|
+
label: "Notification Payload Mode",
|
|
216
|
+
help: "Choose none (suppress delivery-target messages), concise relay text (default), or include debug envelope payload",
|
|
217
|
+
advanced: true,
|
|
218
|
+
},
|
|
160
219
|
"limits.maxWatchersTotal": {
|
|
161
220
|
label: "Max Watchers",
|
|
162
221
|
help: "Maximum total watchers across all skills",
|
package/dist/index.js
CHANGED
|
@@ -4,11 +4,21 @@ import { registerSentinelControl } from "./tool.js";
|
|
|
4
4
|
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
5
5
|
import { WatcherManager } from "./watcherManager.js";
|
|
6
6
|
const registeredWebhookPathsByRegistrar = new WeakMap();
|
|
7
|
-
const
|
|
7
|
+
const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
|
|
8
|
+
const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
|
|
8
9
|
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
9
10
|
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
10
11
|
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
11
12
|
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.";
|
|
13
|
+
const SUPPORTED_DELIVERY_CHANNELS = new Set([
|
|
14
|
+
"telegram",
|
|
15
|
+
"discord",
|
|
16
|
+
"slack",
|
|
17
|
+
"signal",
|
|
18
|
+
"imessage",
|
|
19
|
+
"whatsapp",
|
|
20
|
+
"line",
|
|
21
|
+
]);
|
|
12
22
|
function trimText(value, max) {
|
|
13
23
|
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
14
24
|
}
|
|
@@ -28,6 +38,21 @@ function asIsoString(value) {
|
|
|
28
38
|
function isRecord(value) {
|
|
29
39
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
30
40
|
}
|
|
41
|
+
function resolveSentinelPluginConfig(api) {
|
|
42
|
+
const pluginConfig = isRecord(api.pluginConfig)
|
|
43
|
+
? api.pluginConfig
|
|
44
|
+
: {};
|
|
45
|
+
const configRoot = isRecord(api.config) ? api.config : undefined;
|
|
46
|
+
const legacyRootConfig = configRoot?.sentinel;
|
|
47
|
+
if (legacyRootConfig === undefined)
|
|
48
|
+
return pluginConfig;
|
|
49
|
+
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".');
|
|
50
|
+
if (!isRecord(legacyRootConfig))
|
|
51
|
+
return pluginConfig;
|
|
52
|
+
if (Object.keys(pluginConfig).length > 0)
|
|
53
|
+
return pluginConfig;
|
|
54
|
+
return legacyRootConfig;
|
|
55
|
+
}
|
|
31
56
|
function isDeliveryTarget(value) {
|
|
32
57
|
return (isRecord(value) &&
|
|
33
58
|
typeof value.channel === "string" &&
|
|
@@ -41,6 +66,14 @@ function normalizePath(path) {
|
|
|
41
66
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
42
67
|
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
43
68
|
}
|
|
69
|
+
function sanitizeSessionSegment(value) {
|
|
70
|
+
const sanitized = value
|
|
71
|
+
.trim()
|
|
72
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
|
73
|
+
.replace(/-+/g, "-")
|
|
74
|
+
.replace(/^-|-$/g, "");
|
|
75
|
+
return sanitized.length > 0 ? sanitized.slice(0, 64) : "unknown";
|
|
76
|
+
}
|
|
44
77
|
function clipPayloadForPrompt(value) {
|
|
45
78
|
const serialized = JSON.stringify(value);
|
|
46
79
|
if (!serialized)
|
|
@@ -56,15 +89,30 @@ function clipPayloadForPrompt(value) {
|
|
|
56
89
|
preview: `${clipped}…`,
|
|
57
90
|
};
|
|
58
91
|
}
|
|
92
|
+
function getNestedString(value, path) {
|
|
93
|
+
let cursor = value;
|
|
94
|
+
for (const segment of path) {
|
|
95
|
+
if (!isRecord(cursor))
|
|
96
|
+
return undefined;
|
|
97
|
+
cursor = cursor[segment];
|
|
98
|
+
}
|
|
99
|
+
return asString(cursor);
|
|
100
|
+
}
|
|
59
101
|
function buildSentinelEventEnvelope(payload) {
|
|
60
102
|
const watcherId = asString(payload.watcherId) ??
|
|
61
|
-
(
|
|
103
|
+
getNestedString(payload, ["watcher", "id"]) ??
|
|
104
|
+
getNestedString(payload, ["context", "watcherId"]);
|
|
62
105
|
const eventName = asString(payload.eventName) ??
|
|
63
|
-
(
|
|
106
|
+
getNestedString(payload, ["watcher", "eventName"]) ??
|
|
107
|
+
getNestedString(payload, ["event", "name"]);
|
|
64
108
|
const skillId = asString(payload.skillId) ??
|
|
65
|
-
(
|
|
109
|
+
getNestedString(payload, ["watcher", "skillId"]) ??
|
|
110
|
+
getNestedString(payload, ["context", "skillId"]) ??
|
|
66
111
|
undefined;
|
|
67
|
-
const matchedAt = asIsoString(payload.matchedAt) ??
|
|
112
|
+
const matchedAt = asIsoString(payload.matchedAt) ??
|
|
113
|
+
asIsoString(payload.timestamp) ??
|
|
114
|
+
asIsoString(getNestedString(payload, ["trigger", "matchedAt"])) ??
|
|
115
|
+
new Date().toISOString();
|
|
68
116
|
const rawPayload = payload.payload ??
|
|
69
117
|
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
70
118
|
payload;
|
|
@@ -78,10 +126,16 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
78
126
|
const dedupeKey = asString(payload.dedupeKey) ??
|
|
79
127
|
asString(payload.correlationId) ??
|
|
80
128
|
asString(payload.correlationID) ??
|
|
129
|
+
getNestedString(payload, ["trigger", "dedupeKey"]) ??
|
|
81
130
|
generatedDedupe;
|
|
82
131
|
const deliveryTargets = Array.isArray(payload.deliveryTargets)
|
|
83
132
|
? payload.deliveryTargets.filter(isDeliveryTarget)
|
|
84
133
|
: undefined;
|
|
134
|
+
const sourceRoute = getNestedString(payload, ["source", "route"]) ?? DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
135
|
+
const sourcePlugin = getNestedString(payload, ["source", "plugin"]) ?? "openclaw-sentinel";
|
|
136
|
+
const hookSessionGroup = asString(payload.hookSessionGroup) ??
|
|
137
|
+
asString(payload.sessionGroup) ??
|
|
138
|
+
getNestedString(payload, ["watcher", "sessionGroup"]);
|
|
85
139
|
const envelope = {
|
|
86
140
|
watcherId: watcherId ?? null,
|
|
87
141
|
eventName: eventName ?? null,
|
|
@@ -90,22 +144,126 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
90
144
|
dedupeKey,
|
|
91
145
|
correlationId: dedupeKey,
|
|
92
146
|
source: {
|
|
93
|
-
route:
|
|
94
|
-
plugin:
|
|
147
|
+
route: sourceRoute,
|
|
148
|
+
plugin: sourcePlugin,
|
|
95
149
|
},
|
|
96
150
|
};
|
|
97
151
|
if (skillId)
|
|
98
152
|
envelope.skillId = skillId;
|
|
153
|
+
if (hookSessionGroup)
|
|
154
|
+
envelope.hookSessionGroup = hookSessionGroup;
|
|
99
155
|
if (deliveryTargets && deliveryTargets.length > 0)
|
|
100
156
|
envelope.deliveryTargets = deliveryTargets;
|
|
101
157
|
return envelope;
|
|
102
158
|
}
|
|
103
|
-
function buildSentinelSystemEvent(
|
|
104
|
-
const envelope = buildSentinelEventEnvelope(payload);
|
|
159
|
+
function buildSentinelSystemEvent(envelope) {
|
|
105
160
|
const jsonEnvelope = JSON.stringify(envelope, null, 2);
|
|
106
161
|
const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
|
|
107
162
|
return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
108
163
|
}
|
|
164
|
+
function normalizeDeliveryTargets(targets) {
|
|
165
|
+
const deduped = new Map();
|
|
166
|
+
for (const target of targets) {
|
|
167
|
+
const channel = asString(target.channel);
|
|
168
|
+
const to = asString(target.to);
|
|
169
|
+
if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
|
|
170
|
+
continue;
|
|
171
|
+
const accountId = asString(target.accountId);
|
|
172
|
+
const key = `${channel}:${to}:${accountId ?? ""}`;
|
|
173
|
+
deduped.set(key, { channel, to, ...(accountId ? { accountId } : {}) });
|
|
174
|
+
}
|
|
175
|
+
return [...deduped.values()];
|
|
176
|
+
}
|
|
177
|
+
function inferTargetFromSessionKey(sessionKey, accountId) {
|
|
178
|
+
const segments = sessionKey
|
|
179
|
+
.split(":")
|
|
180
|
+
.map((part) => part.trim())
|
|
181
|
+
.filter(Boolean);
|
|
182
|
+
if (segments.length < 5)
|
|
183
|
+
return undefined;
|
|
184
|
+
const channel = segments[2];
|
|
185
|
+
const to = segments.at(-1);
|
|
186
|
+
if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
|
|
187
|
+
return undefined;
|
|
188
|
+
return {
|
|
189
|
+
channel,
|
|
190
|
+
to,
|
|
191
|
+
...(accountId ? { accountId } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function inferRelayTargets(payload, envelope) {
|
|
195
|
+
if (envelope.deliveryTargets?.length) {
|
|
196
|
+
return normalizeDeliveryTargets(envelope.deliveryTargets);
|
|
197
|
+
}
|
|
198
|
+
const inferred = [];
|
|
199
|
+
if (isDeliveryTarget(payload.currentChat))
|
|
200
|
+
inferred.push(payload.currentChat);
|
|
201
|
+
const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
|
|
202
|
+
if (isDeliveryTarget(sourceCurrentChat))
|
|
203
|
+
inferred.push(sourceCurrentChat);
|
|
204
|
+
const messageChannel = asString(payload.messageChannel);
|
|
205
|
+
const requesterSenderId = asString(payload.requesterSenderId);
|
|
206
|
+
if (messageChannel && requesterSenderId && SUPPORTED_DELIVERY_CHANNELS.has(messageChannel)) {
|
|
207
|
+
inferred.push({ channel: messageChannel, to: requesterSenderId });
|
|
208
|
+
}
|
|
209
|
+
const fromSessionKey = asString(payload.sessionKey);
|
|
210
|
+
if (fromSessionKey) {
|
|
211
|
+
const target = inferTargetFromSessionKey(fromSessionKey, asString(payload.agentAccountId));
|
|
212
|
+
if (target)
|
|
213
|
+
inferred.push(target);
|
|
214
|
+
}
|
|
215
|
+
const sourceSessionKey = getNestedString(payload, ["source", "sessionKey"]);
|
|
216
|
+
if (sourceSessionKey) {
|
|
217
|
+
const sourceAccountId = getNestedString(payload, ["source", "accountId"]);
|
|
218
|
+
const target = inferTargetFromSessionKey(sourceSessionKey, sourceAccountId);
|
|
219
|
+
if (target)
|
|
220
|
+
inferred.push(target);
|
|
221
|
+
}
|
|
222
|
+
return normalizeDeliveryTargets(inferred);
|
|
223
|
+
}
|
|
224
|
+
function summarizeContext(value) {
|
|
225
|
+
if (!isRecord(value))
|
|
226
|
+
return undefined;
|
|
227
|
+
const entries = Object.entries(value).slice(0, 3);
|
|
228
|
+
if (entries.length === 0)
|
|
229
|
+
return undefined;
|
|
230
|
+
const chunks = entries.map(([key, val]) => {
|
|
231
|
+
if (typeof val === "string")
|
|
232
|
+
return `${key}=${trimText(val, 64)}`;
|
|
233
|
+
if (typeof val === "number" || typeof val === "boolean")
|
|
234
|
+
return `${key}=${String(val)}`;
|
|
235
|
+
return `${key}=${trimText(JSON.stringify(val), 64)}`;
|
|
236
|
+
});
|
|
237
|
+
return chunks.join(" · ");
|
|
238
|
+
}
|
|
239
|
+
function buildRelayMessage(envelope) {
|
|
240
|
+
const title = envelope.eventName ? `Sentinel alert: ${envelope.eventName}` : "Sentinel alert";
|
|
241
|
+
const watcher = envelope.watcherId ? `watcher ${envelope.watcherId}` : "watcher unknown";
|
|
242
|
+
const payloadRecord = isRecord(envelope.payload) ? envelope.payload : undefined;
|
|
243
|
+
const contextSummary = summarizeContext(payloadRecord && isRecord(payloadRecord.context) ? payloadRecord.context : payloadRecord);
|
|
244
|
+
const lines = [title, `${watcher} · ${envelope.matchedAt}`];
|
|
245
|
+
if (contextSummary)
|
|
246
|
+
lines.push(contextSummary);
|
|
247
|
+
const text = lines.join("\n").trim();
|
|
248
|
+
return text.length > 0 ? text : "Sentinel callback received.";
|
|
249
|
+
}
|
|
250
|
+
function buildIsolatedHookSessionKey(envelope, config) {
|
|
251
|
+
const rawPrefix = asString(config.hookSessionKey) ??
|
|
252
|
+
asString(config.hookSessionPrefix) ??
|
|
253
|
+
DEFAULT_HOOK_SESSION_PREFIX;
|
|
254
|
+
const prefix = rawPrefix.replace(/:+$/g, "");
|
|
255
|
+
const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
|
|
256
|
+
if (group) {
|
|
257
|
+
return `${prefix}:group:${sanitizeSessionSegment(group)}`;
|
|
258
|
+
}
|
|
259
|
+
if (envelope.watcherId) {
|
|
260
|
+
return `${prefix}:watcher:${sanitizeSessionSegment(envelope.watcherId)}`;
|
|
261
|
+
}
|
|
262
|
+
if (envelope.dedupeKey) {
|
|
263
|
+
return `${prefix}:event:${sanitizeSessionSegment(envelope.dedupeKey.slice(0, 24))}`;
|
|
264
|
+
}
|
|
265
|
+
return `${prefix}:event:unknown`;
|
|
266
|
+
}
|
|
109
267
|
async function readSentinelWebhookPayload(req) {
|
|
110
268
|
const preParsed = req.body;
|
|
111
269
|
if (isRecord(preParsed))
|
|
@@ -183,7 +341,9 @@ export function createSentinelPlugin(overrides) {
|
|
|
183
341
|
allowedHosts: [],
|
|
184
342
|
localDispatchBase: "http://127.0.0.1:18789",
|
|
185
343
|
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
186
|
-
|
|
344
|
+
hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
|
|
345
|
+
hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
346
|
+
notificationPayloadMode: "concise",
|
|
187
347
|
limits: {
|
|
188
348
|
maxWatchersTotal: 200,
|
|
189
349
|
maxWatchersPerSkill: 20,
|
|
@@ -192,6 +352,22 @@ export function createSentinelPlugin(overrides) {
|
|
|
192
352
|
},
|
|
193
353
|
...overrides,
|
|
194
354
|
};
|
|
355
|
+
const recentRelayByDedupe = new Map();
|
|
356
|
+
const shouldRelayForDedupe = (dedupeKey) => {
|
|
357
|
+
const windowMs = Math.max(0, config.hookRelayDedupeWindowMs ?? DEFAULT_RELAY_DEDUPE_WINDOW_MS);
|
|
358
|
+
if (windowMs === 0)
|
|
359
|
+
return true;
|
|
360
|
+
const now = Date.now();
|
|
361
|
+
for (const [key, ts] of recentRelayByDedupe.entries()) {
|
|
362
|
+
if (now - ts > windowMs)
|
|
363
|
+
recentRelayByDedupe.delete(key);
|
|
364
|
+
}
|
|
365
|
+
const prev = recentRelayByDedupe.get(dedupeKey);
|
|
366
|
+
if (typeof prev === "number" && now - prev <= windowMs)
|
|
367
|
+
return false;
|
|
368
|
+
recentRelayByDedupe.set(dedupeKey, now);
|
|
369
|
+
return true;
|
|
370
|
+
};
|
|
195
371
|
const manager = new WatcherManager(config, {
|
|
196
372
|
async dispatch(path, body) {
|
|
197
373
|
const headers = { "content-type": "application/json" };
|
|
@@ -210,6 +386,9 @@ export function createSentinelPlugin(overrides) {
|
|
|
210
386
|
await manager.init();
|
|
211
387
|
},
|
|
212
388
|
register(api) {
|
|
389
|
+
const runtimeConfig = resolveSentinelPluginConfig(api);
|
|
390
|
+
if (Object.keys(runtimeConfig).length > 0)
|
|
391
|
+
Object.assign(config, runtimeConfig);
|
|
213
392
|
manager.setNotifier({
|
|
214
393
|
async notify(target, message) {
|
|
215
394
|
await notifyDeliveryTarget(api, target, message);
|
|
@@ -244,19 +423,46 @@ export function createSentinelPlugin(overrides) {
|
|
|
244
423
|
}
|
|
245
424
|
try {
|
|
246
425
|
const payload = await readSentinelWebhookPayload(req);
|
|
247
|
-
const
|
|
248
|
-
const
|
|
426
|
+
const envelope = buildSentinelEventEnvelope(payload);
|
|
427
|
+
const sessionKey = buildIsolatedHookSessionKey(envelope, config);
|
|
428
|
+
const text = buildSentinelSystemEvent(envelope);
|
|
249
429
|
const enqueued = api.runtime.system.enqueueSystemEvent(text, { sessionKey });
|
|
250
430
|
api.runtime.system.requestHeartbeatNow({
|
|
251
431
|
reason: "hook:sentinel",
|
|
252
432
|
sessionKey,
|
|
253
433
|
});
|
|
434
|
+
const relayTargets = inferRelayTargets(payload, envelope);
|
|
435
|
+
const relayMessage = buildRelayMessage(envelope);
|
|
436
|
+
const relay = {
|
|
437
|
+
dedupeKey: envelope.dedupeKey,
|
|
438
|
+
attempted: relayTargets.length,
|
|
439
|
+
delivered: 0,
|
|
440
|
+
failed: 0,
|
|
441
|
+
deduped: false,
|
|
442
|
+
};
|
|
443
|
+
if (relayTargets.length > 0) {
|
|
444
|
+
if (!shouldRelayForDedupe(envelope.dedupeKey)) {
|
|
445
|
+
relay.deduped = true;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
await Promise.all(relayTargets.map(async (target) => {
|
|
449
|
+
try {
|
|
450
|
+
await notifyDeliveryTarget(api, target, relayMessage);
|
|
451
|
+
relay.delivered += 1;
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
relay.failed += 1;
|
|
455
|
+
}
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
254
459
|
res.writeHead(200, { "content-type": "application/json" });
|
|
255
460
|
res.end(JSON.stringify({
|
|
256
461
|
ok: true,
|
|
257
462
|
route: path,
|
|
258
463
|
sessionKey,
|
|
259
464
|
enqueued,
|
|
465
|
+
relay,
|
|
260
466
|
}));
|
|
261
467
|
}
|
|
262
468
|
catch (err) {
|
package/dist/toolSchema.d.ts
CHANGED
|
@@ -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;
|
package/dist/toolSchema.js
CHANGED
|
@@ -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,8 @@ 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;
|
|
10
12
|
export interface FireConfig {
|
|
11
13
|
webhookPath?: string;
|
|
12
14
|
eventName: string;
|
|
@@ -16,6 +18,8 @@ export interface FireConfig {
|
|
|
16
18
|
priority?: PriorityLevel;
|
|
17
19
|
deadlineTemplate?: string;
|
|
18
20
|
dedupeKeyTemplate?: string;
|
|
21
|
+
notificationPayloadMode?: NotificationPayloadModeOverride;
|
|
22
|
+
sessionGroup?: string;
|
|
19
23
|
}
|
|
20
24
|
export interface RetryPolicy {
|
|
21
25
|
maxRetries: number;
|
|
@@ -83,8 +87,13 @@ export interface SentinelConfig {
|
|
|
83
87
|
allowedHosts: string[];
|
|
84
88
|
localDispatchBase: string;
|
|
85
89
|
dispatchAuthToken?: string;
|
|
90
|
+
/** @deprecated Backward-compatible alias for hookSessionPrefix. */
|
|
86
91
|
hookSessionKey?: string;
|
|
92
|
+
hookSessionPrefix?: string;
|
|
93
|
+
hookSessionGroup?: string;
|
|
94
|
+
hookRelayDedupeWindowMs?: number;
|
|
87
95
|
stateFilePath?: string;
|
|
96
|
+
notificationPayloadMode?: NotificationPayloadMode;
|
|
88
97
|
limits: SentinelLimits;
|
|
89
98
|
}
|
|
90
99
|
export interface GatewayWebhookDispatcher {
|
package/dist/validator.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare const WatcherSchema: import("@sinclair/typebox").TObject<{
|
|
|
25
25
|
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">]>>;
|
|
26
26
|
deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
27
27
|
dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
28
|
+
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">]>>;
|
|
29
|
+
sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
28
30
|
}>;
|
|
29
31
|
retry: import("@sinclair/typebox").TObject<{
|
|
30
32
|
maxRetries: import("@sinclair/typebox").TInteger;
|
package/dist/validator.js
CHANGED
|
@@ -54,6 +54,13 @@ export const WatcherSchema = Type.Object({
|
|
|
54
54
|
])),
|
|
55
55
|
deadlineTemplate: Type.Optional(Type.String({ minLength: 1 })),
|
|
56
56
|
dedupeKeyTemplate: Type.Optional(Type.String({ minLength: 1 })),
|
|
57
|
+
notificationPayloadMode: Type.Optional(Type.Union([
|
|
58
|
+
Type.Literal("inherit"),
|
|
59
|
+
Type.Literal("none"),
|
|
60
|
+
Type.Literal("concise"),
|
|
61
|
+
Type.Literal("debug"),
|
|
62
|
+
])),
|
|
63
|
+
sessionGroup: Type.Optional(Type.String({ minLength: 1 })),
|
|
57
64
|
}, { additionalProperties: false }),
|
|
58
65
|
retry: Type.Object({
|
|
59
66
|
maxRetries: Type.Integer({ minimum: 0, maximum: 20 }),
|
package/dist/watcherManager.js
CHANGED
|
@@ -10,6 +10,32 @@ import { sseStrategy } from "./strategies/sse.js";
|
|
|
10
10
|
import { websocketStrategy } from "./strategies/websocket.js";
|
|
11
11
|
import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
|
|
12
12
|
export const RESET_BACKOFF_AFTER_MS = 60_000;
|
|
13
|
+
const MAX_DEBUG_NOTIFICATION_CHARS = 7000;
|
|
14
|
+
function trimForChat(text) {
|
|
15
|
+
if (text.length <= MAX_DEBUG_NOTIFICATION_CHARS)
|
|
16
|
+
return text;
|
|
17
|
+
return `${text.slice(0, MAX_DEBUG_NOTIFICATION_CHARS)}…`;
|
|
18
|
+
}
|
|
19
|
+
function resolveNotificationPayloadMode(config, watcher) {
|
|
20
|
+
const override = watcher.fire.notificationPayloadMode;
|
|
21
|
+
if (override === "none" || override === "concise" || override === "debug")
|
|
22
|
+
return override;
|
|
23
|
+
if (config.notificationPayloadMode === "none")
|
|
24
|
+
return "none";
|
|
25
|
+
return config.notificationPayloadMode === "debug" ? "debug" : "concise";
|
|
26
|
+
}
|
|
27
|
+
function buildDeliveryNotificationMessage(watcher, body, mode) {
|
|
28
|
+
const matchedAt = typeof body.trigger === "object" &&
|
|
29
|
+
body.trigger !== null &&
|
|
30
|
+
typeof body.trigger.matchedAt === "string"
|
|
31
|
+
? body.trigger.matchedAt
|
|
32
|
+
: new Date().toISOString();
|
|
33
|
+
const concise = `Sentinel watcher "${watcher.id}" fired event "${watcher.fire.eventName}" at ${matchedAt}.`;
|
|
34
|
+
if (mode !== "debug")
|
|
35
|
+
return concise;
|
|
36
|
+
const envelopeJson = JSON.stringify(body, null, 2) ?? "{}";
|
|
37
|
+
return trimForChat(`${concise}\n\nSENTINEL_DEBUG_ENVELOPE_JSON:\n${envelopeJson}`);
|
|
38
|
+
}
|
|
13
39
|
export const backoff = (base, max, failures) => {
|
|
14
40
|
const raw = Math.min(max, base * 2 ** failures);
|
|
15
41
|
const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
|
|
@@ -188,9 +214,14 @@ export class WatcherManager {
|
|
|
188
214
|
webhookPath,
|
|
189
215
|
});
|
|
190
216
|
await this.dispatcher.dispatch(webhookPath, body);
|
|
191
|
-
|
|
217
|
+
const deliveryMode = resolveNotificationPayloadMode(this.config, watcher);
|
|
218
|
+
const isSentinelWebhook = webhookPath === DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
219
|
+
if (deliveryMode !== "none" &&
|
|
220
|
+
watcher.deliveryTargets?.length &&
|
|
221
|
+
this.notifier &&
|
|
222
|
+
!isSentinelWebhook) {
|
|
192
223
|
const attemptedAt = new Date().toISOString();
|
|
193
|
-
const message =
|
|
224
|
+
const message = buildDeliveryNotificationMessage(watcher, body, deliveryMode);
|
|
194
225
|
const failures = [];
|
|
195
226
|
let successCount = 0;
|
|
196
227
|
await Promise.all(watcher.deliveryTargets.map(async (target) => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -21,13 +21,33 @@
|
|
|
21
21
|
},
|
|
22
22
|
"hookSessionKey": {
|
|
23
23
|
"type": "string",
|
|
24
|
-
"description": "
|
|
25
|
-
|
|
24
|
+
"description": "Deprecated alias for hookSessionPrefix. Sentinel always appends watcher/group segments to prevent a shared global callback session."
|
|
25
|
+
},
|
|
26
|
+
"hookSessionPrefix": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "Base session key prefix used for isolated /hooks/sentinel callback sessions (default: agent:main:hooks:sentinel)",
|
|
29
|
+
"default": "agent:main:hooks:sentinel"
|
|
30
|
+
},
|
|
31
|
+
"hookSessionGroup": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session."
|
|
34
|
+
},
|
|
35
|
+
"hookRelayDedupeWindowMs": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"minimum": 0,
|
|
38
|
+
"description": "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
|
|
39
|
+
"default": 120000
|
|
26
40
|
},
|
|
27
41
|
"stateFilePath": {
|
|
28
42
|
"type": "string",
|
|
29
43
|
"description": "Custom path for the sentinel state persistence file"
|
|
30
44
|
},
|
|
45
|
+
"notificationPayloadMode": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"enum": ["none", "concise", "debug"],
|
|
48
|
+
"description": "Controls delivery-target notifications: none (suppress message fan-out), concise relay text (default), or relay text with debug envelope payload",
|
|
49
|
+
"default": "concise"
|
|
50
|
+
},
|
|
31
51
|
"limits": {
|
|
32
52
|
"type": "object",
|
|
33
53
|
"additionalProperties": false,
|
|
@@ -73,8 +93,23 @@
|
|
|
73
93
|
"placeholder": "sk-..."
|
|
74
94
|
},
|
|
75
95
|
"hookSessionKey": {
|
|
76
|
-
"label": "
|
|
77
|
-
"help": "
|
|
96
|
+
"label": "Hook Session Key (Deprecated)",
|
|
97
|
+
"help": "Deprecated alias for hookSessionPrefix. Sentinel appends watcher/group segments automatically.",
|
|
98
|
+
"advanced": true
|
|
99
|
+
},
|
|
100
|
+
"hookSessionPrefix": {
|
|
101
|
+
"label": "Hook Session Prefix",
|
|
102
|
+
"help": "Base prefix for isolated callback sessions (default: agent:main:hooks:sentinel)",
|
|
103
|
+
"advanced": true
|
|
104
|
+
},
|
|
105
|
+
"hookSessionGroup": {
|
|
106
|
+
"label": "Default Hook Session Group",
|
|
107
|
+
"help": "Optional default group key for callback sessions. Watchers with the same group share one isolated session.",
|
|
108
|
+
"advanced": true
|
|
109
|
+
},
|
|
110
|
+
"hookRelayDedupeWindowMs": {
|
|
111
|
+
"label": "Hook Relay Dedupe Window (ms)",
|
|
112
|
+
"help": "Suppress duplicate relay messages with the same dedupe key for this many milliseconds",
|
|
78
113
|
"advanced": true
|
|
79
114
|
},
|
|
80
115
|
"stateFilePath": {
|
|
@@ -82,6 +117,11 @@
|
|
|
82
117
|
"help": "Custom path for sentinel state persistence file",
|
|
83
118
|
"advanced": true
|
|
84
119
|
},
|
|
120
|
+
"notificationPayloadMode": {
|
|
121
|
+
"label": "Notification Payload Mode",
|
|
122
|
+
"help": "Choose none (suppress delivery-target messages), concise relay text (default), or include debug envelope payload",
|
|
123
|
+
"advanced": true
|
|
124
|
+
},
|
|
85
125
|
"limits.maxWatchersTotal": {
|
|
86
126
|
"label": "Max Watchers",
|
|
87
127
|
"help": "Maximum total watchers across all skills",
|