@coffeexdev/openclaw-sentinel 0.5.0 → 0.6.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
@@ -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 used for dispatch calls back to gateway.
63
- // Set this to your gateway auth token when gateway auth is enabled.
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,7 +154,7 @@ 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 plus structured JSON envelope and requests heartbeat wake.
157
+ 4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event plus structured JSON envelope with a cron-tagged callback context, 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
160
  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`).
@@ -257,9 +265,16 @@ Global mode options:
257
265
 
258
266
  ```json5
259
267
  {
260
- sentinel: {
261
- allowedHosts: ["api.github.com"],
262
- notificationPayloadMode: "none",
268
+ plugins: {
269
+ entries: {
270
+ "openclaw-sentinel": {
271
+ enabled: true,
272
+ config: {
273
+ allowedHosts: ["api.github.com"],
274
+ notificationPayloadMode: "none",
275
+ },
276
+ },
277
+ },
263
278
  },
264
279
  }
265
280
  ```
@@ -268,9 +283,16 @@ Global mode options:
268
283
 
269
284
  ```json5
270
285
  {
271
- sentinel: {
272
- allowedHosts: ["api.github.com"],
273
- notificationPayloadMode: "concise",
286
+ plugins: {
287
+ entries: {
288
+ "openclaw-sentinel": {
289
+ enabled: true,
290
+ config: {
291
+ allowedHosts: ["api.github.com"],
292
+ notificationPayloadMode: "concise",
293
+ },
294
+ },
295
+ },
274
296
  },
275
297
  }
276
298
  ```
@@ -279,9 +301,16 @@ Global mode options:
279
301
 
280
302
  ```json5
281
303
  {
282
- sentinel: {
283
- allowedHosts: ["api.github.com"],
284
- notificationPayloadMode: "debug",
304
+ plugins: {
305
+ entries: {
306
+ "openclaw-sentinel": {
307
+ enabled: true,
308
+ config: {
309
+ allowedHosts: ["api.github.com"],
310
+ notificationPayloadMode: "debug",
311
+ },
312
+ },
313
+ },
285
314
  },
286
315
  }
287
316
  ```
@@ -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;
@@ -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: typeof value.dispatchAuthToken === "string" ? value.dispatchAuthToken : undefined,
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" ? value.hookRelayDedupeWindowMs : 120000,
44
- hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" ? value.hookResponseTimeoutMs : 30000,
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" ? limitsIn.maxWatchersTotal : 200,
57
- maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" ? limitsIn.maxWatchersPerSkill : 20,
58
- maxConditionsPerWatcher: typeof limitsIn.maxConditionsPerWatcher === "number"
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" ? limitsIn.maxIntervalMsFloor : 1000,
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 candidate = withDefaults(value);
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: "Bearer token for authenticating webhook dispatch requests",
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: "number",
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: "number",
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: "number",
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: "number",
240
+ type: "integer",
179
241
  description: "Maximum total watchers across all skills",
180
242
  default: 200,
181
243
  },
182
244
  maxWatchersPerSkill: {
183
- type: "number",
245
+ type: "integer",
184
246
  description: "Maximum watchers per skill",
185
247
  default: 20,
186
248
  },
187
249
  maxConditionsPerWatcher: {
188
- type: "number",
250
+ type: "integer",
189
251
  description: "Maximum conditions per watcher definition",
190
252
  default: 25,
191
253
  },
192
254
  maxIntervalMsFloor: {
193
- type: "number",
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: "Bearer token for webhook dispatch authentication (or use SENTINEL_DISPATCH_TOKEN env var)",
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");
package/dist/index.js CHANGED
@@ -8,9 +8,13 @@ const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
8
8
  const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
9
9
  const DEFAULT_HOOK_RESPONSE_TIMEOUT_MS = 30_000;
10
10
  const DEFAULT_HOOK_RESPONSE_FALLBACK_MODE = "concise";
11
+ const HOOK_RESPONSE_RELAY_CLEANUP_INTERVAL_MS = 60_000;
11
12
  const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
12
13
  const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
13
14
  const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
15
+ const SENTINEL_CALLBACK_WAKE_REASON = "cron:sentinel-callback";
16
+ const SENTINEL_CALLBACK_CONTEXT_KEY = "cron:sentinel-callback";
17
+ const HEARTBEAT_ACK_TOKEN_PATTERN = /\bHEARTBEAT_OK\b/gi;
14
18
  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
19
  const SUPPORTED_DELIVERY_CHANNELS = new Set([
16
20
  "telegram",
@@ -40,20 +44,48 @@ function asIsoString(value) {
40
44
  function isRecord(value) {
41
45
  return !!value && typeof value === "object" && !Array.isArray(value);
42
46
  }
47
+ function sniffGatewayDispatchToken(configRoot) {
48
+ if (!configRoot)
49
+ return undefined;
50
+ const auth = isRecord(configRoot.auth) ? configRoot.auth : undefined;
51
+ const gateway = isRecord(configRoot.gateway) ? configRoot.gateway : undefined;
52
+ const gatewayAuth = gateway && isRecord(gateway.auth) ? gateway.auth : undefined;
53
+ const server = isRecord(configRoot.server) ? configRoot.server : undefined;
54
+ const serverAuth = server && isRecord(server.auth) ? server.auth : undefined;
55
+ const candidates = [
56
+ auth?.token,
57
+ gateway?.authToken,
58
+ gatewayAuth?.token,
59
+ serverAuth?.token,
60
+ configRoot.gatewayAuthToken,
61
+ configRoot.authToken,
62
+ ];
63
+ for (const candidate of candidates) {
64
+ const token = asString(candidate);
65
+ if (token)
66
+ return token;
67
+ }
68
+ return undefined;
69
+ }
43
70
  function resolveSentinelPluginConfig(api) {
44
71
  const pluginConfig = isRecord(api.pluginConfig)
45
- ? api.pluginConfig
72
+ ? { ...api.pluginConfig }
46
73
  : {};
47
74
  const configRoot = isRecord(api.config) ? api.config : undefined;
48
75
  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;
76
+ let resolved = pluginConfig;
77
+ if (legacyRootConfig !== undefined) {
78
+ api.logger?.warn?.('[openclaw-sentinel] Detected deprecated root-level config key "sentinel". Move settings to plugins.entries.openclaw-sentinel.config. Root-level "sentinel" may fail with: Unrecognized key: "sentinel".');
79
+ if (isRecord(legacyRootConfig) && Object.keys(pluginConfig).length === 0) {
80
+ resolved = { ...legacyRootConfig };
81
+ }
82
+ }
83
+ if (!asString(resolved.dispatchAuthToken)) {
84
+ const sniffedToken = sniffGatewayDispatchToken(configRoot);
85
+ if (sniffedToken)
86
+ resolved.dispatchAuthToken = sniffedToken;
87
+ }
88
+ return resolved;
57
89
  }
58
90
  function isDeliveryTarget(value) {
59
91
  return (isRecord(value) &&
@@ -303,12 +335,16 @@ function buildRelayMessage(envelope) {
303
335
  if (contextSummary)
304
336
  lines.push(contextSummary);
305
337
  const text = lines.join("\n").trim();
306
- return text.length > 0 ? text : "Sentinel callback received.";
338
+ return text.length > 0
339
+ ? text
340
+ : "Sentinel callback received, but no assistant detail was generated.";
307
341
  }
308
342
  function normalizeAssistantRelayText(assistantTexts) {
309
343
  if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
310
344
  return undefined;
311
- const parts = assistantTexts.map((value) => value.trim()).filter(Boolean);
345
+ const parts = assistantTexts
346
+ .map((value) => value.replace(HEARTBEAT_ACK_TOKEN_PATTERN, "").trim())
347
+ .filter(Boolean);
312
348
  if (parts.length === 0)
313
349
  return undefined;
314
350
  return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
@@ -327,9 +363,12 @@ function resolveHookResponseFallbackMode(config) {
327
363
  return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
328
364
  }
329
365
  function buildIsolatedHookSessionKey(envelope, config) {
330
- const rawPrefix = asString(config.hookSessionKey) ??
331
- asString(config.hookSessionPrefix) ??
332
- DEFAULT_HOOK_SESSION_PREFIX;
366
+ const configuredPrefix = asString(config.hookSessionPrefix);
367
+ const legacyPrefix = asString(config.hookSessionKey);
368
+ const hasCustomPrefix = typeof configuredPrefix === "string" && configuredPrefix !== DEFAULT_HOOK_SESSION_PREFIX;
369
+ const rawPrefix = hasCustomPrefix
370
+ ? configuredPrefix
371
+ : (legacyPrefix ?? configuredPrefix ?? DEFAULT_HOOK_SESSION_PREFIX);
333
372
  const prefix = rawPrefix.replace(/:+$/g, "");
334
373
  const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
335
374
  if (group) {
@@ -343,10 +382,28 @@ function buildIsolatedHookSessionKey(envelope, config) {
343
382
  }
344
383
  return `${prefix}:event:unknown`;
345
384
  }
385
+ function assertJsonContentType(req) {
386
+ const raw = req.headers["content-type"];
387
+ const header = Array.isArray(raw) ? raw[0] : raw;
388
+ if (!header)
389
+ return;
390
+ const normalized = header.toLowerCase();
391
+ const isJson = normalized.includes("application/json") ||
392
+ normalized.includes("application/cloudevents+json") ||
393
+ normalized.includes("+json");
394
+ if (!isJson) {
395
+ throw new Error(`Unsupported Content-Type: ${header}`);
396
+ }
397
+ }
346
398
  async function readSentinelWebhookPayload(req) {
399
+ assertJsonContentType(req);
347
400
  const preParsed = req.body;
348
- if (isRecord(preParsed))
401
+ if (preParsed !== undefined) {
402
+ if (!isRecord(preParsed)) {
403
+ throw new Error("Payload must be a JSON object");
404
+ }
349
405
  return preParsed;
406
+ }
350
407
  const chunks = [];
351
408
  let total = 0;
352
409
  for await (const chunk of req) {
@@ -439,21 +496,16 @@ class HookResponseRelayManager {
439
496
  recentByDedupe = new Map();
440
497
  pendingByDedupe = new Map();
441
498
  pendingQueueBySession = new Map();
499
+ cleanupTimer;
500
+ disposed = false;
442
501
  constructor(config, api) {
443
502
  this.config = config;
444
503
  this.api = api;
445
504
  }
446
505
  register(args) {
506
+ this.cleanup();
447
507
  const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
448
508
  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
509
  const existingTs = this.recentByDedupe.get(args.dedupeKey);
458
510
  if (dedupeWindowMs > 0 &&
459
511
  typeof existingTs === "number" &&
@@ -470,6 +522,7 @@ class HookResponseRelayManager {
470
522
  };
471
523
  }
472
524
  this.recentByDedupe.set(args.dedupeKey, now);
525
+ this.scheduleCleanup();
473
526
  const timeoutMs = resolveHookResponseTimeoutMs(this.config);
474
527
  const fallbackMode = resolveHookResponseFallbackMode(this.config);
475
528
  if (args.relayTargets.length === 0) {
@@ -531,6 +584,64 @@ class HookResponseRelayManager {
531
584
  return;
532
585
  await this.completeWithMessage(pending, assistantMessage, "assistant");
533
586
  }
587
+ dispose() {
588
+ if (this.disposed)
589
+ return;
590
+ this.disposed = true;
591
+ if (this.cleanupTimer) {
592
+ clearTimeout(this.cleanupTimer);
593
+ this.cleanupTimer = undefined;
594
+ }
595
+ for (const pending of this.pendingByDedupe.values()) {
596
+ if (pending.timer) {
597
+ clearTimeout(pending.timer);
598
+ pending.timer = undefined;
599
+ }
600
+ }
601
+ this.pendingByDedupe.clear();
602
+ this.pendingQueueBySession.clear();
603
+ this.recentByDedupe.clear();
604
+ }
605
+ scheduleCleanup() {
606
+ if (this.disposed || this.cleanupTimer)
607
+ return;
608
+ this.cleanupTimer = setTimeout(() => {
609
+ this.cleanupTimer = undefined;
610
+ this.cleanup();
611
+ }, HOOK_RESPONSE_RELAY_CLEANUP_INTERVAL_MS);
612
+ this.cleanupTimer.unref?.();
613
+ }
614
+ cleanup(now = Date.now()) {
615
+ const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
616
+ if (dedupeWindowMs > 0) {
617
+ for (const [key, ts] of this.recentByDedupe.entries()) {
618
+ if (now - ts > dedupeWindowMs) {
619
+ this.recentByDedupe.delete(key);
620
+ }
621
+ }
622
+ }
623
+ for (const [key, pending] of this.pendingByDedupe.entries()) {
624
+ const gcAfterMs = Math.max(pending.timeoutMs, dedupeWindowMs, 1_000);
625
+ if (pending.state !== "pending" && now - pending.createdAt > gcAfterMs) {
626
+ this.pendingByDedupe.delete(key);
627
+ this.removeFromSessionQueue(pending.sessionKey, key);
628
+ }
629
+ }
630
+ if (this.pendingByDedupe.size > 0 || this.recentByDedupe.size > 0) {
631
+ this.scheduleCleanup();
632
+ }
633
+ }
634
+ removeFromSessionQueue(sessionKey, dedupeKey) {
635
+ const queue = this.pendingQueueBySession.get(sessionKey);
636
+ if (!queue || queue.length === 0)
637
+ return;
638
+ const filtered = queue.filter((key) => key !== dedupeKey);
639
+ if (filtered.length === 0) {
640
+ this.pendingQueueBySession.delete(sessionKey);
641
+ return;
642
+ }
643
+ this.pendingQueueBySession.set(sessionKey, filtered);
644
+ }
534
645
  popNextPendingDedupe(sessionKey) {
535
646
  const queue = this.pendingQueueBySession.get(sessionKey);
536
647
  if (!queue || queue.length === 0)
@@ -579,7 +690,7 @@ export function createSentinelPlugin(overrides) {
579
690
  const config = {
580
691
  allowedHosts: [],
581
692
  localDispatchBase: "http://127.0.0.1:18789",
582
- dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
693
+ dispatchAuthToken: asString(process.env.SENTINEL_DISPATCH_TOKEN),
583
694
  hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
584
695
  hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
585
696
  hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
@@ -599,11 +710,24 @@ export function createSentinelPlugin(overrides) {
599
710
  const headers = { "content-type": "application/json" };
600
711
  if (config.dispatchAuthToken)
601
712
  headers.authorization = `Bearer ${config.dispatchAuthToken}`;
602
- await fetch(`${config.localDispatchBase}${path}`, {
713
+ const response = await fetch(`${config.localDispatchBase}${path}`, {
603
714
  method: "POST",
604
715
  headers,
605
716
  body: JSON.stringify(body),
606
717
  });
718
+ if (!response.ok) {
719
+ let responseBody = "";
720
+ try {
721
+ responseBody = await response.text();
722
+ }
723
+ catch {
724
+ responseBody = "";
725
+ }
726
+ const details = responseBody ? ` body=${trimText(responseBody, 256)}` : "";
727
+ const error = new Error(`dispatch failed with status ${response.status}${details}`);
728
+ error.status = response.status;
729
+ throw error;
730
+ }
607
731
  },
608
732
  });
609
733
  return {
@@ -615,11 +739,18 @@ export function createSentinelPlugin(overrides) {
615
739
  const runtimeConfig = resolveSentinelPluginConfig(api);
616
740
  if (Object.keys(runtimeConfig).length > 0)
617
741
  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
- });
742
+ config.dispatchAuthToken = asString(config.dispatchAuthToken);
743
+ manager.setLogger(api.logger);
744
+ if (Array.isArray(config.allowedHosts) && config.allowedHosts.length === 0) {
745
+ api.logger?.warn?.("[openclaw-sentinel] allowedHosts is empty. Watcher creation will fail until at least one host is configured.");
746
+ }
747
+ const hasLegacyHookSessionKey = !!asString(config.hookSessionKey);
748
+ const hasCustomHookSessionPrefix = !!asString(config.hookSessionPrefix) &&
749
+ asString(config.hookSessionPrefix) !== DEFAULT_HOOK_SESSION_PREFIX;
750
+ if (hasLegacyHookSessionKey) {
751
+ api.logger?.warn?.(hasCustomHookSessionPrefix
752
+ ? "[openclaw-sentinel] hookSessionKey is deprecated and ignored when hookSessionPrefix is set. Remove hookSessionKey from config."
753
+ : "[openclaw-sentinel] hookSessionKey is deprecated. Rename it to hookSessionPrefix.");
623
754
  }
624
755
  manager.setNotifier({
625
756
  async notify(target, message) {
@@ -641,6 +772,12 @@ export function createSentinelPlugin(overrides) {
641
772
  manager.setWebhookRegistrationStatus("ok", "Route already registered (idempotent)", path);
642
773
  return;
643
774
  }
775
+ const hookResponseRelayManager = new HookResponseRelayManager(config, api);
776
+ if (typeof api.on === "function") {
777
+ api.on("llm_output", (event, ctx) => {
778
+ void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
779
+ });
780
+ }
644
781
  try {
645
782
  api.registerHttpRoute({
646
783
  path,
@@ -658,9 +795,12 @@ export function createSentinelPlugin(overrides) {
658
795
  const envelope = buildSentinelEventEnvelope(payload);
659
796
  const sessionKey = buildIsolatedHookSessionKey(envelope, config);
660
797
  const text = buildSentinelSystemEvent(envelope);
661
- const enqueued = api.runtime.system.enqueueSystemEvent(text, { sessionKey });
798
+ const enqueued = api.runtime.system.enqueueSystemEvent(text, {
799
+ sessionKey,
800
+ contextKey: SENTINEL_CALLBACK_CONTEXT_KEY,
801
+ });
662
802
  api.runtime.system.requestHeartbeatNow({
663
- reason: "hook:sentinel",
803
+ reason: SENTINEL_CALLBACK_WAKE_REASON,
664
804
  sessionKey,
665
805
  });
666
806
  const relayTargets = inferRelayTargets(payload, envelope);
@@ -683,7 +823,14 @@ export function createSentinelPlugin(overrides) {
683
823
  const message = String(err?.message ?? err);
684
824
  const badRequest = message.includes("Invalid JSON payload") ||
685
825
  message.includes("Payload must be a JSON object");
686
- const status = message.includes("too large") ? 413 : badRequest ? 400 : 500;
826
+ const unsupportedMediaType = message.includes("Unsupported Content-Type");
827
+ const status = message.includes("too large")
828
+ ? 413
829
+ : unsupportedMediaType
830
+ ? 415
831
+ : badRequest
832
+ ? 400
833
+ : 500;
687
834
  res.writeHead(status, { "content-type": "application/json" });
688
835
  res.end(JSON.stringify({ error: message }));
689
836
  }
@@ -694,6 +841,7 @@ export function createSentinelPlugin(overrides) {
694
841
  api.logger?.info?.(`[openclaw-sentinel] Registered default webhook route ${path}`);
695
842
  }
696
843
  catch (err) {
844
+ hookResponseRelayManager.dispose();
697
845
  const msg = `Failed to register default webhook route ${path}: ${String(err?.message ?? err)}`;
698
846
  manager.setWebhookRegistrationStatus("error", msg, path);
699
847
  api.logger?.error?.(`[openclaw-sentinel] ${msg}`);
@@ -709,8 +857,8 @@ const sentinelPlugin = {
709
857
  configSchema: sentinelConfigSchema,
710
858
  register(api) {
711
859
  const plugin = createSentinelPlugin(api.pluginConfig);
712
- void plugin.init();
713
860
  plugin.register(api);
861
+ void plugin.init();
714
862
  },
715
863
  };
716
864
  export const register = sentinelPlugin.register.bind(sentinelPlugin);
@@ -1,29 +1,55 @@
1
+ function isAbortError(err) {
2
+ if (!(err instanceof Error))
3
+ return false;
4
+ return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
5
+ }
1
6
  export const httpLongPollStrategy = async (watcher, onPayload, onError) => {
2
7
  let active = true;
8
+ let inFlightAbort;
3
9
  const loop = async () => {
4
10
  while (active) {
5
11
  try {
12
+ inFlightAbort = new AbortController();
6
13
  const response = await fetch(watcher.endpoint, {
7
14
  method: watcher.method ?? "GET",
8
15
  headers: watcher.headers,
9
16
  body: watcher.body,
10
- signal: AbortSignal.timeout(watcher.timeoutMs ?? 60000),
17
+ signal: AbortSignal.any([
18
+ inFlightAbort.signal,
19
+ AbortSignal.timeout(watcher.timeoutMs ?? 60000),
20
+ ]),
21
+ redirect: "error",
11
22
  });
12
23
  if (!response.ok)
13
24
  throw new Error(`http-long-poll non-2xx: ${response.status}`);
14
25
  const contentType = response.headers.get("content-type") ?? "";
15
- if (!contentType.toLowerCase().includes("json"))
26
+ if (!contentType.toLowerCase().includes("json")) {
16
27
  throw new Error(`http-long-poll expected JSON, got: ${contentType || "unknown"}`);
17
- await onPayload(await response.json());
28
+ }
29
+ let payload;
30
+ try {
31
+ payload = await response.json();
32
+ }
33
+ catch (err) {
34
+ throw new Error(`http-long-poll invalid JSON response: ${String(err?.message ?? err)}`);
35
+ }
36
+ await onPayload(payload);
18
37
  }
19
38
  catch (err) {
39
+ if (!active && isAbortError(err))
40
+ return;
20
41
  await onError(err);
21
42
  return;
22
43
  }
44
+ finally {
45
+ inFlightAbort = undefined;
46
+ }
23
47
  }
24
48
  };
25
49
  void loop();
26
50
  return async () => {
27
51
  active = false;
52
+ inFlightAbort?.abort();
53
+ inFlightAbort = undefined;
28
54
  };
29
55
  };
@@ -1,35 +1,66 @@
1
+ function isAbortError(err) {
2
+ if (!(err instanceof Error))
3
+ return false;
4
+ return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
5
+ }
1
6
  export const httpPollStrategy = async (watcher, onPayload, onError) => {
2
7
  const interval = watcher.intervalMs ?? 30000;
3
8
  let active = true;
9
+ let timer;
10
+ let inFlightAbort;
4
11
  const tick = async () => {
5
12
  if (!active)
6
13
  return;
7
14
  try {
15
+ inFlightAbort = new AbortController();
8
16
  const response = await fetch(watcher.endpoint, {
9
17
  method: watcher.method ?? "GET",
10
18
  headers: watcher.headers,
11
19
  body: watcher.body,
12
- signal: AbortSignal.timeout(watcher.timeoutMs ?? 15000),
20
+ signal: AbortSignal.any([
21
+ inFlightAbort.signal,
22
+ AbortSignal.timeout(watcher.timeoutMs ?? 15000),
23
+ ]),
24
+ redirect: "error",
13
25
  });
14
26
  if (!response.ok)
15
27
  throw new Error(`http-poll non-2xx: ${response.status}`);
16
28
  const contentType = response.headers.get("content-type") ?? "";
17
- if (!contentType.toLowerCase().includes("json"))
29
+ if (!contentType.toLowerCase().includes("json")) {
18
30
  throw new Error(`http-poll expected JSON, got: ${contentType || "unknown"}`);
19
- const payload = await response.json();
31
+ }
32
+ let payload;
33
+ try {
34
+ payload = await response.json();
35
+ }
36
+ catch (err) {
37
+ throw new Error(`http-poll invalid JSON response: ${String(err?.message ?? err)}`);
38
+ }
20
39
  await onPayload(payload);
21
40
  }
22
41
  catch (err) {
42
+ if (!active && isAbortError(err))
43
+ return;
23
44
  await onError(err);
24
45
  return;
25
46
  }
26
- if (active)
27
- setTimeout(() => {
47
+ finally {
48
+ inFlightAbort = undefined;
49
+ }
50
+ if (active) {
51
+ timer = setTimeout(() => {
28
52
  void tick();
29
53
  }, interval);
54
+ }
30
55
  };
31
56
  void tick();
32
57
  return async () => {
33
58
  active = false;
59
+ if (timer) {
60
+ clearTimeout(timer);
61
+ timer = undefined;
62
+ }
63
+ inFlightAbort?.abort();
64
+ inFlightAbort = undefined;
34
65
  };
35
66
  };
@@ -1,41 +1,73 @@
1
+ function isAbortError(err) {
2
+ if (!(err instanceof Error))
3
+ return false;
4
+ return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
5
+ }
1
6
  export const sseStrategy = async (watcher, onPayload, onError) => {
2
7
  let active = true;
8
+ let inFlightAbort;
9
+ let sleepTimer;
10
+ const wait = (ms) => new Promise((resolve) => {
11
+ sleepTimer = setTimeout(() => {
12
+ sleepTimer = undefined;
13
+ resolve();
14
+ }, ms);
15
+ });
3
16
  const loop = async () => {
4
17
  while (active) {
5
18
  try {
19
+ inFlightAbort = new AbortController();
6
20
  const response = await fetch(watcher.endpoint, {
7
21
  headers: { Accept: "text/event-stream", ...(watcher.headers ?? {}) },
8
- signal: AbortSignal.timeout(watcher.timeoutMs ?? 60000),
22
+ signal: AbortSignal.any([
23
+ inFlightAbort.signal,
24
+ AbortSignal.timeout(watcher.timeoutMs ?? 60000),
25
+ ]),
26
+ redirect: "error",
9
27
  });
10
28
  if (!response.ok)
11
29
  throw new Error(`sse non-2xx: ${response.status}`);
12
30
  const contentType = response.headers.get("content-type") ?? "";
13
- if (!contentType.toLowerCase().includes("text/event-stream"))
31
+ if (!contentType.toLowerCase().includes("text/event-stream")) {
14
32
  throw new Error(`sse expected text/event-stream, got: ${contentType || "unknown"}`);
33
+ }
15
34
  const text = await response.text();
16
35
  for (const line of text.split("\n")) {
17
- if (line.startsWith("data:")) {
18
- const raw = line.slice(5).trim();
19
- if (!raw)
20
- continue;
21
- try {
22
- await onPayload(JSON.parse(raw));
23
- }
24
- catch {
25
- await onPayload({ message: raw });
26
- }
36
+ if (!line.startsWith("data:"))
37
+ continue;
38
+ const raw = line.slice(5).trim();
39
+ if (!raw)
40
+ continue;
41
+ try {
42
+ await onPayload(JSON.parse(raw));
43
+ }
44
+ catch {
45
+ await onPayload({ message: raw });
27
46
  }
28
47
  }
29
- await new Promise((r) => setTimeout(r, watcher.intervalMs ?? 1000));
48
+ if (active) {
49
+ await wait(watcher.intervalMs ?? 1000);
50
+ }
30
51
  }
31
52
  catch (err) {
53
+ if (!active && isAbortError(err))
54
+ return;
32
55
  await onError(err);
33
56
  return;
34
57
  }
58
+ finally {
59
+ inFlightAbort = undefined;
60
+ }
35
61
  }
36
62
  };
37
63
  void loop();
38
64
  return async () => {
39
65
  active = false;
66
+ inFlightAbort?.abort();
67
+ inFlightAbort = undefined;
68
+ if (sleepTimer) {
69
+ clearTimeout(sleepTimer);
70
+ sleepTimer = undefined;
71
+ }
40
72
  };
41
73
  };
@@ -1,14 +1,42 @@
1
1
  import WebSocket from "ws";
2
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
2
3
  export const websocketStrategy = async (watcher, onPayload, onError, callbacks) => {
3
4
  let active = true;
4
5
  let ws = null;
6
+ let connectTimer;
7
+ const clearConnectTimer = () => {
8
+ if (!connectTimer)
9
+ return;
10
+ clearTimeout(connectTimer);
11
+ connectTimer = undefined;
12
+ };
13
+ const connectTimeoutMs = Math.max(1, watcher.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
5
14
  const connect = () => {
6
15
  let pendingError = null;
7
16
  let failureReported = false;
8
- ws = new WebSocket(watcher.endpoint, { headers: watcher.headers });
17
+ const reportFailure = (reason) => {
18
+ if (!active || failureReported)
19
+ return;
20
+ failureReported = true;
21
+ clearConnectTimer();
22
+ void onError(reason);
23
+ };
24
+ ws = new WebSocket(watcher.endpoint, {
25
+ headers: watcher.headers,
26
+ handshakeTimeout: connectTimeoutMs,
27
+ });
28
+ connectTimer = setTimeout(() => {
29
+ if (!active || !ws)
30
+ return;
31
+ if (ws.readyState === WebSocket.CONNECTING) {
32
+ pendingError = new Error(`websocket connect timeout after ${connectTimeoutMs}ms`);
33
+ ws.terminate();
34
+ }
35
+ }, connectTimeoutMs);
9
36
  ws.on("open", () => {
10
37
  if (!active)
11
38
  return;
39
+ clearConnectTimer();
12
40
  callbacks?.onConnect?.();
13
41
  });
14
42
  ws.on("message", async (data) => {
@@ -28,17 +56,24 @@ export const websocketStrategy = async (watcher, onPayload, onError, callbacks)
28
56
  pendingError = err instanceof Error ? err : new Error(String(err));
29
57
  });
30
58
  ws.on("close", (code) => {
31
- if (!active || failureReported)
59
+ if (!active)
32
60
  return;
33
- failureReported = true;
34
61
  const reason = pendingError?.message ?? `websocket closed: ${code}`;
35
- void onError(new Error(reason));
62
+ reportFailure(new Error(reason));
36
63
  });
37
64
  };
38
65
  connect();
39
66
  return async () => {
40
67
  active = false;
41
- if (ws && ws.readyState === WebSocket.OPEN)
68
+ clearConnectTimer();
69
+ if (!ws)
70
+ return;
71
+ if (ws.readyState === WebSocket.CONNECTING) {
72
+ ws.terminate();
73
+ return;
74
+ }
75
+ if (ws.readyState === WebSocket.OPEN) {
42
76
  ws.close();
77
+ }
43
78
  };
44
79
  };
package/dist/template.js CHANGED
@@ -1,7 +1,5 @@
1
+ import { getPath } from "./utils.js";
1
2
  const placeholderPattern = /^\$\{(watcher\.(id|skillId)|event\.(name)|payload\.[a-zA-Z0-9_.-]+|timestamp)\}$/;
2
- function getPath(obj, path) {
3
- return path.split(".").reduce((acc, part) => acc?.[part], obj);
4
- }
5
3
  function renderValue(value, context) {
6
4
  if (value === null || typeof value === "number" || typeof value === "boolean")
7
5
  return value;
@@ -1,6 +1,7 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { TemplateValueSchema } from "./templateValueSchema.js";
3
3
  const TemplateValueRefSchema = Type.Ref(TemplateValueSchema);
4
+ const WATCHER_ID_PATTERN = "^[A-Za-z0-9_-]{1,128}$";
4
5
  const ConditionSchema = Type.Object({
5
6
  path: Type.String({ description: "JSONPath expression to evaluate against the response" }),
6
7
  op: Type.Union([
@@ -58,7 +59,11 @@ const DeliveryTargetSchema = Type.Object({
58
59
  accountId: Type.Optional(Type.String({ description: "Optional account id for multi-account channels" })),
59
60
  }, { additionalProperties: false });
60
61
  const WatcherSchema = Type.Object({
61
- id: Type.String({ description: "Unique watcher identifier" }),
62
+ id: Type.String({
63
+ pattern: WATCHER_ID_PATTERN,
64
+ maxLength: 128,
65
+ description: "Unique watcher identifier (letters, numbers, hyphen, underscore)",
66
+ }),
62
67
  skillId: Type.String({ description: "ID of the skill that owns this watcher" }),
63
68
  enabled: Type.Boolean({ description: "Whether the watcher is actively polling" }),
64
69
  strategy: Type.Union([
@@ -114,7 +119,11 @@ const CreateActionSchema = Type.Object({
114
119
  }, { additionalProperties: false });
115
120
  const IdActionSchema = Type.Object({
116
121
  action: IdActionNameSchema,
117
- id: Type.String({ description: "Watcher ID for action target" }),
122
+ id: Type.String({
123
+ pattern: WATCHER_ID_PATTERN,
124
+ maxLength: 128,
125
+ description: "Watcher ID for action target",
126
+ }),
118
127
  }, { additionalProperties: false });
119
128
  const ListActionSchema = Type.Object({
120
129
  action: ListActionNameSchema,
@@ -127,7 +136,11 @@ export const SentinelToolValidationSchema = Type.Union([CreateActionSchema, IdAc
127
136
  export const SentinelToolSchema = Type.Object({
128
137
  action: AnyActionNameSchema,
129
138
  watcher: Type.Optional(WatcherSchema),
130
- id: Type.Optional(Type.String({ description: "Watcher ID for action target" })),
139
+ id: Type.Optional(Type.String({
140
+ pattern: WATCHER_ID_PATTERN,
141
+ maxLength: 128,
142
+ description: "Watcher ID for action target",
143
+ })),
131
144
  }, {
132
145
  additionalProperties: false,
133
146
  $defs: {
package/dist/types.d.ts CHANGED
@@ -67,6 +67,8 @@ export interface WatcherRuntimeState {
67
67
  lastConnectAt?: string;
68
68
  lastDisconnectAt?: string;
69
69
  lastDisconnectReason?: string;
70
+ lastDispatchError?: string;
71
+ lastDispatchErrorAt?: string;
70
72
  lastDelivery?: {
71
73
  attemptedAt: string;
72
74
  successCount: number;
@@ -0,0 +1 @@
1
+ export declare function getPath(obj: unknown, path: string): unknown;
package/dist/utils.js ADDED
@@ -0,0 +1,9 @@
1
+ export function getPath(obj, path) {
2
+ if (!path)
3
+ return obj;
4
+ return path.split(".").reduce((acc, part) => {
5
+ if (acc === null || acc === undefined)
6
+ return undefined;
7
+ return acc[part];
8
+ }, obj);
9
+ }
package/dist/validator.js CHANGED
@@ -5,6 +5,7 @@ import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
5
5
  const TemplateValueRefSchema = Type.Ref(TemplateValueSchema);
6
6
  const codeyKeyPattern = /(script|code|eval|handler|function|import|require)/i;
7
7
  const codeyValuePattern = /(=>|\bfunction\b|\bimport\s+|\brequire\s*\(|\beval\s*\()/i;
8
+ const WATCHER_ID_PATTERN = "^[A-Za-z0-9_-]{1,128}$";
8
9
  const ConditionSchema = Type.Object({
9
10
  path: Type.String({ minLength: 1 }),
10
11
  op: Type.Union([
@@ -23,7 +24,7 @@ const ConditionSchema = Type.Object({
23
24
  value: Type.Optional(Type.Unknown()),
24
25
  }, { additionalProperties: false });
25
26
  export const WatcherSchema = Type.Object({
26
- id: Type.String({ minLength: 1 }),
27
+ id: Type.String({ pattern: WATCHER_ID_PATTERN, maxLength: 128 }),
27
28
  skillId: Type.String({ minLength: 1 }),
28
29
  enabled: Type.Boolean(),
29
30
  strategy: Type.Union([
@@ -6,6 +6,11 @@ export interface WatcherCreateContext {
6
6
  export interface WatcherNotifier {
7
7
  notify(target: DeliveryTarget, message: string): Promise<void>;
8
8
  }
9
+ export interface WatcherLogger {
10
+ info?(message: string): void;
11
+ warn?(message: string): void;
12
+ error?(message: string): void;
13
+ }
9
14
  export declare const backoff: (base: number, max: number, failures: number) => number;
10
15
  export declare class WatcherManager {
11
16
  private config;
@@ -16,6 +21,7 @@ export declare class WatcherManager {
16
21
  private stops;
17
22
  private retryTimers;
18
23
  private statePath;
24
+ private logger?;
19
25
  private webhookRegistration;
20
26
  constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher, notifier?: WatcherNotifier | undefined);
21
27
  init(): Promise<void>;
@@ -23,6 +29,7 @@ export declare class WatcherManager {
23
29
  list(): WatcherDefinition[];
24
30
  status(id: string): WatcherRuntimeState | undefined;
25
31
  setNotifier(notifier: WatcherNotifier | undefined): void;
32
+ setLogger(logger: WatcherLogger | undefined): void;
26
33
  setWebhookRegistrationStatus(status: "ok" | "error", message?: string, path?: string): void;
27
34
  enable(id: string): Promise<void>;
28
35
  disable(id: string): Promise<void>;
@@ -50,6 +50,7 @@ export class WatcherManager {
50
50
  stops = new Map();
51
51
  retryTimers = new Map();
52
52
  statePath;
53
+ logger;
53
54
  webhookRegistration = {
54
55
  path: DEFAULT_SENTINEL_WEBHOOK_PATH,
55
56
  status: "pending",
@@ -81,6 +82,8 @@ export class WatcherManager {
81
82
  lastEvaluated: prev?.lastEvaluated,
82
83
  lastPayloadHash: prev?.lastPayloadHash,
83
84
  lastPayload: prev?.lastPayload,
85
+ lastDispatchError: prev?.lastDispatchError,
86
+ lastDispatchErrorAt: prev?.lastDispatchErrorAt,
84
87
  };
85
88
  }
86
89
  }
@@ -112,6 +115,9 @@ export class WatcherManager {
112
115
  setNotifier(notifier) {
113
116
  this.notifier = notifier;
114
117
  }
118
+ setLogger(logger) {
119
+ this.logger = logger;
120
+ }
115
121
  setWebhookRegistrationStatus(status, message, path) {
116
122
  this.webhookRegistration = {
117
123
  path: path ?? this.webhookRegistration.path,
@@ -213,39 +219,58 @@ export class WatcherManager {
213
219
  matchedAt,
214
220
  webhookPath,
215
221
  });
216
- await this.dispatcher.dispatch(webhookPath, body);
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) {
223
- const attemptedAt = new Date().toISOString();
224
- const message = buildDeliveryNotificationMessage(watcher, body, deliveryMode);
225
- const failures = [];
226
- let successCount = 0;
227
- await Promise.all(watcher.deliveryTargets.map(async (target) => {
228
- try {
229
- await this.notifier?.notify(target, message);
230
- successCount += 1;
231
- }
232
- catch (err) {
233
- failures.push({
234
- target,
235
- error: String(err?.message ?? err),
236
- });
237
- }
238
- }));
239
- rt.lastDelivery = {
240
- attemptedAt,
241
- successCount,
242
- failureCount: failures.length,
243
- failures: failures.length > 0 ? failures : undefined,
244
- };
222
+ let dispatchSucceeded = false;
223
+ try {
224
+ await this.dispatcher.dispatch(webhookPath, body);
225
+ dispatchSucceeded = true;
226
+ rt.lastDispatchError = undefined;
227
+ rt.lastDispatchErrorAt = undefined;
228
+ }
229
+ catch (err) {
230
+ const message = String(err?.message ?? err);
231
+ const status = err?.status;
232
+ rt.lastDispatchError = message;
233
+ rt.lastDispatchErrorAt = new Date().toISOString();
234
+ rt.lastError = message;
235
+ this.logger?.warn?.(`[openclaw-sentinel] Dispatch failed for watcher=${watcher.id} webhookPath=${webhookPath}: ${message}`);
236
+ if (status === 401 || status === 403) {
237
+ this.logger?.warn?.("[openclaw-sentinel] Dispatch authorization rejected (401/403). dispatchAuthToken may be missing or invalid. Sentinel now auto-detects gateway auth token when possible; explicit config/env overrides still take precedence.");
238
+ }
245
239
  }
246
- if (watcher.fireOnce) {
247
- watcher.enabled = false;
248
- await this.stopWatcher(id);
240
+ if (dispatchSucceeded) {
241
+ const deliveryMode = resolveNotificationPayloadMode(this.config, watcher);
242
+ const isSentinelWebhook = webhookPath === DEFAULT_SENTINEL_WEBHOOK_PATH;
243
+ if (deliveryMode !== "none" &&
244
+ watcher.deliveryTargets?.length &&
245
+ this.notifier &&
246
+ !isSentinelWebhook) {
247
+ const attemptedAt = new Date().toISOString();
248
+ const message = buildDeliveryNotificationMessage(watcher, body, deliveryMode);
249
+ const failures = [];
250
+ let successCount = 0;
251
+ await Promise.all(watcher.deliveryTargets.map(async (target) => {
252
+ try {
253
+ await this.notifier?.notify(target, message);
254
+ successCount += 1;
255
+ }
256
+ catch (err) {
257
+ failures.push({
258
+ target,
259
+ error: String(err?.message ?? err),
260
+ });
261
+ }
262
+ }));
263
+ rt.lastDelivery = {
264
+ attemptedAt,
265
+ successCount,
266
+ failureCount: failures.length,
267
+ failures: failures.length > 0 ? failures : undefined,
268
+ };
269
+ }
270
+ if (watcher.fireOnce) {
271
+ watcher.enabled = false;
272
+ await this.stopWatcher(id);
273
+ }
249
274
  }
250
275
  }
251
276
  await this.persist();
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "dispatchAuthToken": {
21
21
  "type": "string",
22
- "description": "Bearer token for authenticating webhook dispatch requests"
22
+ "description": "Optional bearer token override for webhook dispatch auth. Sentinel auto-detects gateway auth token when available."
23
23
  },
24
24
  "hookSessionKey": {
25
25
  "type": "string",
@@ -35,7 +35,7 @@
35
35
  "description": "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session."
36
36
  },
37
37
  "hookRelayDedupeWindowMs": {
38
- "type": "number",
38
+ "type": "integer",
39
39
  "minimum": 0,
40
40
  "description": "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
41
41
  "default": 120000
@@ -56,29 +56,29 @@
56
56
  "description": "Resource limits for watcher creation",
57
57
  "properties": {
58
58
  "maxWatchersTotal": {
59
- "type": "number",
59
+ "type": "integer",
60
60
  "description": "Maximum total watchers across all skills",
61
61
  "default": 200
62
62
  },
63
63
  "maxWatchersPerSkill": {
64
- "type": "number",
64
+ "type": "integer",
65
65
  "description": "Maximum watchers per skill",
66
66
  "default": 20
67
67
  },
68
68
  "maxConditionsPerWatcher": {
69
- "type": "number",
69
+ "type": "integer",
70
70
  "description": "Maximum conditions per watcher definition",
71
71
  "default": 25
72
72
  },
73
73
  "maxIntervalMsFloor": {
74
- "type": "number",
74
+ "type": "integer",
75
75
  "description": "Minimum allowed polling interval in milliseconds",
76
76
  "default": 1000
77
77
  }
78
78
  }
79
79
  },
80
80
  "hookResponseTimeoutMs": {
81
- "type": "number",
81
+ "type": "integer",
82
82
  "minimum": 0,
83
83
  "description": "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
84
84
  "default": 30000
@@ -90,7 +90,7 @@
90
90
  "default": "concise"
91
91
  },
92
92
  "hookResponseDedupeWindowMs": {
93
- "type": "number",
93
+ "type": "integer",
94
94
  "minimum": 0,
95
95
  "description": "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
96
96
  "default": 120000
@@ -108,7 +108,7 @@
108
108
  },
109
109
  "dispatchAuthToken": {
110
110
  "label": "Dispatch Auth Token",
111
- "help": "Bearer token for webhook dispatch authentication",
111
+ "help": "Optional override for webhook dispatch authentication. Sentinel auto-detects gateway auth token when available.",
112
112
  "sensitive": true,
113
113
  "placeholder": "sk-..."
114
114
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeexdev/openclaw-sentinel",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",