@coffeexdev/openclaw-sentinel 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -17
- package/dist/callbackEnvelope.js +7 -3
- package/dist/configSchema.js +80 -18
- package/dist/evaluator.js +1 -3
- package/dist/index.js +270 -54
- package/dist/strategies/httpLongPoll.js +29 -3
- package/dist/strategies/httpPoll.js +36 -5
- package/dist/strategies/sse.js +45 -13
- package/dist/strategies/websocket.js +40 -5
- package/dist/template.js +1 -3
- package/dist/toolSchema.js +16 -3
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +9 -0
- package/dist/validator.js +2 -1
- package/dist/watcherManager.d.ts +7 -0
- package/dist/watcherManager.js +57 -32
- package/openclaw.plugin.json +9 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,8 +59,9 @@ Add/update `~/.openclaw/openclaw.json`:
|
|
|
59
59
|
// Optional legacy alias for hookSessionPrefix (still supported).
|
|
60
60
|
// hookSessionKey: "agent:main:hooks:sentinel",
|
|
61
61
|
|
|
62
|
-
// Optional: bearer token
|
|
63
|
-
//
|
|
62
|
+
// Optional: explicit bearer token override for dispatch calls back to gateway.
|
|
63
|
+
// Sentinel auto-detects gateway auth token from runtime config when available,
|
|
64
|
+
// so manual token copy is usually no longer required.
|
|
64
65
|
// dispatchAuthToken: "<gateway-token>"
|
|
65
66
|
},
|
|
66
67
|
},
|
|
@@ -89,6 +90,13 @@ your config is using the old root-level shape. Move Sentinel config under:
|
|
|
89
90
|
|
|
90
91
|
Sentinel also logs a runtime warning when that legacy root key is still observable, but it never writes a root-level `sentinel` key.
|
|
91
92
|
|
|
93
|
+
### Hardening notes (0.6 minor)
|
|
94
|
+
|
|
95
|
+
- `hookSessionKey` remains supported but is deprecated. If both are present, `hookSessionPrefix` now wins.
|
|
96
|
+
- HTTP watcher strategies now set `redirect: "error"` to prevent host-allowlist bypass via redirects.
|
|
97
|
+
- Watcher IDs are now constrained to `^[A-Za-z0-9_-]{1,128}$`.
|
|
98
|
+
- `/hooks/sentinel` validates JSON `Content-Type` when provided and returns `415` for unsupported media types.
|
|
99
|
+
|
|
92
100
|
### 4) Create your first watcher (`sentinel_control`)
|
|
93
101
|
|
|
94
102
|
```json
|
|
@@ -146,10 +154,10 @@ Use `sentinel_control`:
|
|
|
146
154
|
1. Sentinel evaluates conditions.
|
|
147
155
|
2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
|
|
148
156
|
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
|
|
157
|
+
4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event with a **structured callback prompt context** (`watcher`, `trigger`, `source`, `deliveryTargets`, `deliveryContext`, `context`, `payload`) plus the full envelope, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
|
|
150
158
|
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
159
|
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.
|
|
160
|
+
7. Relay guardrails suppress control-token outputs (`NO_REPLY`, `HEARTBEAT_OK`, empty variants). If model output is unusable, Sentinel emits a concise contextual fallback message. Timeout fallback behavior still follows `hookResponseFallbackMode`.
|
|
153
161
|
|
|
154
162
|
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent). Response contracts are dedupe-aware by callback dedupe key (`hookResponseDedupeWindowMs`).
|
|
155
163
|
|
|
@@ -161,7 +169,17 @@ Sample emitted envelope:
|
|
|
161
169
|
"version": "1",
|
|
162
170
|
"intent": "price_threshold_review",
|
|
163
171
|
"actionable": true,
|
|
164
|
-
"watcher": {
|
|
172
|
+
"watcher": {
|
|
173
|
+
"id": "eth-price-watch",
|
|
174
|
+
"skillId": "skills.alerts",
|
|
175
|
+
"eventName": "eth_target_hit",
|
|
176
|
+
"intent": "price_threshold_review",
|
|
177
|
+
"strategy": "http-poll",
|
|
178
|
+
"endpoint": "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd",
|
|
179
|
+
"match": "all",
|
|
180
|
+
"conditions": [{ "path": "ethereum.usd", "op": "gte", "value": 5000 }],
|
|
181
|
+
"fireOnce": false
|
|
182
|
+
},
|
|
165
183
|
"trigger": {
|
|
166
184
|
"matchedAt": "2026-03-04T15:00:00.000Z",
|
|
167
185
|
"dedupeKey": "<sha256>",
|
|
@@ -257,9 +275,16 @@ Global mode options:
|
|
|
257
275
|
|
|
258
276
|
```json5
|
|
259
277
|
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
plugins: {
|
|
279
|
+
entries: {
|
|
280
|
+
"openclaw-sentinel": {
|
|
281
|
+
enabled: true,
|
|
282
|
+
config: {
|
|
283
|
+
allowedHosts: ["api.github.com"],
|
|
284
|
+
notificationPayloadMode: "none",
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
263
288
|
},
|
|
264
289
|
}
|
|
265
290
|
```
|
|
@@ -268,9 +293,16 @@ Global mode options:
|
|
|
268
293
|
|
|
269
294
|
```json5
|
|
270
295
|
{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
296
|
+
plugins: {
|
|
297
|
+
entries: {
|
|
298
|
+
"openclaw-sentinel": {
|
|
299
|
+
enabled: true,
|
|
300
|
+
config: {
|
|
301
|
+
allowedHosts: ["api.github.com"],
|
|
302
|
+
notificationPayloadMode: "concise",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
274
306
|
},
|
|
275
307
|
}
|
|
276
308
|
```
|
|
@@ -279,9 +311,16 @@ Global mode options:
|
|
|
279
311
|
|
|
280
312
|
```json5
|
|
281
313
|
{
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
314
|
+
plugins: {
|
|
315
|
+
entries: {
|
|
316
|
+
"openclaw-sentinel": {
|
|
317
|
+
enabled: true,
|
|
318
|
+
config: {
|
|
319
|
+
allowedHosts: ["api.github.com"],
|
|
320
|
+
notificationPayloadMode: "debug",
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
285
324
|
},
|
|
286
325
|
}
|
|
287
326
|
```
|
|
@@ -334,10 +373,11 @@ Precedence: **watcher override > global setting**.
|
|
|
334
373
|
1. Callback is enqueued to isolated hook session.
|
|
335
374
|
2. Contract captures original delivery context (`deliveryContext` + resolved `deliveryTargets`).
|
|
336
375
|
3. First assistant-authored `llm_output` for that pending callback is relayed to target chat.
|
|
337
|
-
4.
|
|
376
|
+
4. Reserved control outputs are never relayed (`NO_REPLY`, `HEARTBEAT_OK`, empty variants). If output is unusable, Sentinel sends a concise contextual guardrail fallback.
|
|
377
|
+
5. If no assistant output arrives in time (`hookResponseTimeoutMs`), timeout fallback is configurable:
|
|
338
378
|
- `hookResponseFallbackMode: "concise"` (default) sends a short fail-safe relay.
|
|
339
|
-
- `hookResponseFallbackMode: "none"` suppresses fallback.
|
|
340
|
-
|
|
379
|
+
- `hookResponseFallbackMode: "none"` suppresses timeout fallback.
|
|
380
|
+
6. Repeated callbacks with same dedupe key are idempotent within `hookResponseDedupeWindowMs`.
|
|
341
381
|
|
|
342
382
|
Example config:
|
|
343
383
|
|
package/dist/callbackEnvelope.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
|
|
3
3
|
import { renderTemplate } from "./template.js";
|
|
4
|
+
import { getPath } from "./utils.js";
|
|
4
5
|
const MAX_PAYLOAD_JSON_CHARS = 4000;
|
|
5
6
|
function toIntent(eventName) {
|
|
6
7
|
return (eventName
|
|
@@ -57,9 +58,6 @@ function buildDeliveryContextFromMetadata(watcher) {
|
|
|
57
58
|
}
|
|
58
59
|
return Object.keys(context).length > 0 ? context : undefined;
|
|
59
60
|
}
|
|
60
|
-
function getPath(obj, path) {
|
|
61
|
-
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
62
|
-
}
|
|
63
61
|
function getTemplateString(value, context) {
|
|
64
62
|
if (!value)
|
|
65
63
|
return undefined;
|
|
@@ -110,6 +108,12 @@ export function createCallbackEnvelope(args) {
|
|
|
110
108
|
id: watcher.id,
|
|
111
109
|
skillId: watcher.skillId,
|
|
112
110
|
eventName: watcher.fire.eventName,
|
|
111
|
+
intent,
|
|
112
|
+
strategy: watcher.strategy,
|
|
113
|
+
endpoint: watcher.endpoint,
|
|
114
|
+
match: watcher.match,
|
|
115
|
+
conditions: watcher.conditions,
|
|
116
|
+
fireOnce: watcher.fireOnce ?? false,
|
|
113
117
|
},
|
|
114
118
|
trigger: {
|
|
115
119
|
matchedAt,
|
package/dist/configSchema.js
CHANGED
|
@@ -27,6 +27,42 @@ const ConfigSchema = Type.Object({
|
|
|
27
27
|
notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
|
|
28
28
|
limits: Type.Optional(LimitsSchema),
|
|
29
29
|
}, { additionalProperties: false });
|
|
30
|
+
function trimToUndefined(value) {
|
|
31
|
+
if (typeof value !== "string")
|
|
32
|
+
return undefined;
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
35
|
+
}
|
|
36
|
+
function findInvalidNumericPath(input) {
|
|
37
|
+
const numericPaths = [
|
|
38
|
+
"hookRelayDedupeWindowMs",
|
|
39
|
+
"hookResponseTimeoutMs",
|
|
40
|
+
"hookResponseDedupeWindowMs",
|
|
41
|
+
];
|
|
42
|
+
for (const key of numericPaths) {
|
|
43
|
+
const value = input[key];
|
|
44
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
45
|
+
return `/${key}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const limits = input.limits;
|
|
49
|
+
if (limits && typeof limits === "object" && !Array.isArray(limits)) {
|
|
50
|
+
const limitsRecord = limits;
|
|
51
|
+
const limitKeys = [
|
|
52
|
+
"maxWatchersTotal",
|
|
53
|
+
"maxWatchersPerSkill",
|
|
54
|
+
"maxConditionsPerWatcher",
|
|
55
|
+
"maxIntervalMsFloor",
|
|
56
|
+
];
|
|
57
|
+
for (const key of limitKeys) {
|
|
58
|
+
const value = limitsRecord[key];
|
|
59
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
60
|
+
return `/limits/${key}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
30
66
|
function withDefaults(value) {
|
|
31
67
|
const limitsIn = value.limits ?? {};
|
|
32
68
|
return {
|
|
@@ -34,16 +70,23 @@ function withDefaults(value) {
|
|
|
34
70
|
localDispatchBase: typeof value.localDispatchBase === "string" && value.localDispatchBase.length > 0
|
|
35
71
|
? value.localDispatchBase
|
|
36
72
|
: "http://127.0.0.1:18789",
|
|
37
|
-
dispatchAuthToken:
|
|
73
|
+
dispatchAuthToken: trimToUndefined(value.dispatchAuthToken),
|
|
38
74
|
hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : undefined,
|
|
39
75
|
hookSessionPrefix: typeof value.hookSessionPrefix === "string"
|
|
40
76
|
? value.hookSessionPrefix
|
|
41
77
|
: "agent:main:hooks:sentinel",
|
|
42
78
|
hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
|
|
43
|
-
hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number"
|
|
44
|
-
|
|
79
|
+
hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" &&
|
|
80
|
+
Number.isFinite(value.hookRelayDedupeWindowMs)
|
|
81
|
+
? value.hookRelayDedupeWindowMs
|
|
82
|
+
: 120000,
|
|
83
|
+
hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" &&
|
|
84
|
+
Number.isFinite(value.hookResponseTimeoutMs)
|
|
85
|
+
? value.hookResponseTimeoutMs
|
|
86
|
+
: 30000,
|
|
45
87
|
hookResponseFallbackMode: value.hookResponseFallbackMode === "none" ? "none" : "concise",
|
|
46
|
-
hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number"
|
|
88
|
+
hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number" &&
|
|
89
|
+
Number.isFinite(value.hookResponseDedupeWindowMs)
|
|
47
90
|
? value.hookResponseDedupeWindowMs
|
|
48
91
|
: 120000,
|
|
49
92
|
stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
|
|
@@ -53,12 +96,21 @@ function withDefaults(value) {
|
|
|
53
96
|
? "debug"
|
|
54
97
|
: "concise",
|
|
55
98
|
limits: {
|
|
56
|
-
maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number"
|
|
57
|
-
|
|
58
|
-
|
|
99
|
+
maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" && Number.isFinite(limitsIn.maxWatchersTotal)
|
|
100
|
+
? limitsIn.maxWatchersTotal
|
|
101
|
+
: 200,
|
|
102
|
+
maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" &&
|
|
103
|
+
Number.isFinite(limitsIn.maxWatchersPerSkill)
|
|
104
|
+
? limitsIn.maxWatchersPerSkill
|
|
105
|
+
: 20,
|
|
106
|
+
maxConditionsPerWatcher: typeof limitsIn.maxConditionsPerWatcher === "number" &&
|
|
107
|
+
Number.isFinite(limitsIn.maxConditionsPerWatcher)
|
|
59
108
|
? limitsIn.maxConditionsPerWatcher
|
|
60
109
|
: 25,
|
|
61
|
-
maxIntervalMsFloor: typeof limitsIn.maxIntervalMsFloor === "number"
|
|
110
|
+
maxIntervalMsFloor: typeof limitsIn.maxIntervalMsFloor === "number" &&
|
|
111
|
+
Number.isFinite(limitsIn.maxIntervalMsFloor)
|
|
112
|
+
? limitsIn.maxIntervalMsFloor
|
|
113
|
+
: 1000,
|
|
62
114
|
},
|
|
63
115
|
};
|
|
64
116
|
}
|
|
@@ -79,7 +131,17 @@ export const sentinelConfigSchema = {
|
|
|
79
131
|
error: { issues: [issue("/", "Config must be an object")] },
|
|
80
132
|
};
|
|
81
133
|
}
|
|
82
|
-
const
|
|
134
|
+
const source = value;
|
|
135
|
+
const invalidNumericPath = findInvalidNumericPath(source);
|
|
136
|
+
if (invalidNumericPath) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
error: {
|
|
140
|
+
issues: [issue(invalidNumericPath, "Expected a finite number")],
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const candidate = withDefaults(source);
|
|
83
145
|
if (!Value.Check(ConfigSchema, candidate)) {
|
|
84
146
|
const first = [...Value.Errors(ConfigSchema, candidate)][0];
|
|
85
147
|
return {
|
|
@@ -120,7 +182,7 @@ export const sentinelConfigSchema = {
|
|
|
120
182
|
},
|
|
121
183
|
dispatchAuthToken: {
|
|
122
184
|
type: "string",
|
|
123
|
-
description: "
|
|
185
|
+
description: "Optional bearer token override for webhook dispatch auth. Sentinel auto-detects gateway auth token when available.",
|
|
124
186
|
},
|
|
125
187
|
hookSessionKey: {
|
|
126
188
|
type: "string",
|
|
@@ -136,13 +198,13 @@ export const sentinelConfigSchema = {
|
|
|
136
198
|
description: "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session.",
|
|
137
199
|
},
|
|
138
200
|
hookRelayDedupeWindowMs: {
|
|
139
|
-
type: "
|
|
201
|
+
type: "integer",
|
|
140
202
|
minimum: 0,
|
|
141
203
|
description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
|
|
142
204
|
default: 120000,
|
|
143
205
|
},
|
|
144
206
|
hookResponseTimeoutMs: {
|
|
145
|
-
type: "
|
|
207
|
+
type: "integer",
|
|
146
208
|
minimum: 0,
|
|
147
209
|
description: "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
|
|
148
210
|
default: 30000,
|
|
@@ -154,7 +216,7 @@ export const sentinelConfigSchema = {
|
|
|
154
216
|
default: "concise",
|
|
155
217
|
},
|
|
156
218
|
hookResponseDedupeWindowMs: {
|
|
157
|
-
type: "
|
|
219
|
+
type: "integer",
|
|
158
220
|
minimum: 0,
|
|
159
221
|
description: "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
|
|
160
222
|
default: 120000,
|
|
@@ -175,22 +237,22 @@ export const sentinelConfigSchema = {
|
|
|
175
237
|
description: "Resource limits for watcher creation",
|
|
176
238
|
properties: {
|
|
177
239
|
maxWatchersTotal: {
|
|
178
|
-
type: "
|
|
240
|
+
type: "integer",
|
|
179
241
|
description: "Maximum total watchers across all skills",
|
|
180
242
|
default: 200,
|
|
181
243
|
},
|
|
182
244
|
maxWatchersPerSkill: {
|
|
183
|
-
type: "
|
|
245
|
+
type: "integer",
|
|
184
246
|
description: "Maximum watchers per skill",
|
|
185
247
|
default: 20,
|
|
186
248
|
},
|
|
187
249
|
maxConditionsPerWatcher: {
|
|
188
|
-
type: "
|
|
250
|
+
type: "integer",
|
|
189
251
|
description: "Maximum conditions per watcher definition",
|
|
190
252
|
default: 25,
|
|
191
253
|
},
|
|
192
254
|
maxIntervalMsFloor: {
|
|
193
|
-
type: "
|
|
255
|
+
type: "integer",
|
|
194
256
|
description: "Minimum allowed polling interval in milliseconds",
|
|
195
257
|
default: 1000,
|
|
196
258
|
},
|
|
@@ -209,7 +271,7 @@ export const sentinelConfigSchema = {
|
|
|
209
271
|
},
|
|
210
272
|
dispatchAuthToken: {
|
|
211
273
|
label: "Dispatch Auth Token",
|
|
212
|
-
help: "
|
|
274
|
+
help: "Optional override for webhook dispatch auth token. Sentinel auto-detects gateway auth token when available (or use SENTINEL_DISPATCH_TOKEN env var).",
|
|
213
275
|
sensitive: true,
|
|
214
276
|
placeholder: "sk-...",
|
|
215
277
|
},
|
package/dist/evaluator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { getPath } from "./utils.js";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
let cachedRegexCtor = null;
|
|
5
6
|
function getSafeRegexCtor() {
|
|
@@ -26,9 +27,6 @@ function getSafeRegexCtor() {
|
|
|
26
27
|
throw new Error("No safe regex engine available (re2/re2-wasm)");
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
|
-
function getPath(obj, path) {
|
|
30
|
-
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
31
|
-
}
|
|
32
30
|
function safeRegexTest(pattern, input) {
|
|
33
31
|
if (pattern.length > 256)
|
|
34
32
|
throw new Error("Regex pattern too long");
|