@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.
@@ -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.1",
3
+ "version": "0.7.0",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",