@coffeexdev/openclaw-sentinel 0.5.1 → 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 +40 -11
- package/dist/callbackEnvelope.js +1 -3
- package/dist/configSchema.js +80 -18
- package/dist/evaluator.js +1 -3
- package/dist/index.js +168 -30
- 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
|
|
@@ -257,9 +265,16 @@ Global mode options:
|
|
|
257
265
|
|
|
258
266
|
```json5
|
|
259
267
|
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
```
|
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;
|
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");
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ 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;
|
|
@@ -43,20 +44,48 @@ function asIsoString(value) {
|
|
|
43
44
|
function isRecord(value) {
|
|
44
45
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
45
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
|
+
}
|
|
46
70
|
function resolveSentinelPluginConfig(api) {
|
|
47
71
|
const pluginConfig = isRecord(api.pluginConfig)
|
|
48
|
-
? api.pluginConfig
|
|
72
|
+
? { ...api.pluginConfig }
|
|
49
73
|
: {};
|
|
50
74
|
const configRoot = isRecord(api.config) ? api.config : undefined;
|
|
51
75
|
const legacyRootConfig = configRoot?.sentinel;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
|
60
89
|
}
|
|
61
90
|
function isDeliveryTarget(value) {
|
|
62
91
|
return (isRecord(value) &&
|
|
@@ -334,9 +363,12 @@ function resolveHookResponseFallbackMode(config) {
|
|
|
334
363
|
return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
|
|
335
364
|
}
|
|
336
365
|
function buildIsolatedHookSessionKey(envelope, config) {
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
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);
|
|
340
372
|
const prefix = rawPrefix.replace(/:+$/g, "");
|
|
341
373
|
const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
|
|
342
374
|
if (group) {
|
|
@@ -350,10 +382,28 @@ function buildIsolatedHookSessionKey(envelope, config) {
|
|
|
350
382
|
}
|
|
351
383
|
return `${prefix}:event:unknown`;
|
|
352
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
|
+
}
|
|
353
398
|
async function readSentinelWebhookPayload(req) {
|
|
399
|
+
assertJsonContentType(req);
|
|
354
400
|
const preParsed = req.body;
|
|
355
|
-
if (
|
|
401
|
+
if (preParsed !== undefined) {
|
|
402
|
+
if (!isRecord(preParsed)) {
|
|
403
|
+
throw new Error("Payload must be a JSON object");
|
|
404
|
+
}
|
|
356
405
|
return preParsed;
|
|
406
|
+
}
|
|
357
407
|
const chunks = [];
|
|
358
408
|
let total = 0;
|
|
359
409
|
for await (const chunk of req) {
|
|
@@ -446,21 +496,16 @@ class HookResponseRelayManager {
|
|
|
446
496
|
recentByDedupe = new Map();
|
|
447
497
|
pendingByDedupe = new Map();
|
|
448
498
|
pendingQueueBySession = new Map();
|
|
499
|
+
cleanupTimer;
|
|
500
|
+
disposed = false;
|
|
449
501
|
constructor(config, api) {
|
|
450
502
|
this.config = config;
|
|
451
503
|
this.api = api;
|
|
452
504
|
}
|
|
453
505
|
register(args) {
|
|
506
|
+
this.cleanup();
|
|
454
507
|
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
455
508
|
const now = Date.now();
|
|
456
|
-
if (dedupeWindowMs > 0) {
|
|
457
|
-
for (const [key, ts] of this.recentByDedupe.entries()) {
|
|
458
|
-
if (now - ts > dedupeWindowMs) {
|
|
459
|
-
this.recentByDedupe.delete(key);
|
|
460
|
-
this.pendingByDedupe.delete(key);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
509
|
const existingTs = this.recentByDedupe.get(args.dedupeKey);
|
|
465
510
|
if (dedupeWindowMs > 0 &&
|
|
466
511
|
typeof existingTs === "number" &&
|
|
@@ -477,6 +522,7 @@ class HookResponseRelayManager {
|
|
|
477
522
|
};
|
|
478
523
|
}
|
|
479
524
|
this.recentByDedupe.set(args.dedupeKey, now);
|
|
525
|
+
this.scheduleCleanup();
|
|
480
526
|
const timeoutMs = resolveHookResponseTimeoutMs(this.config);
|
|
481
527
|
const fallbackMode = resolveHookResponseFallbackMode(this.config);
|
|
482
528
|
if (args.relayTargets.length === 0) {
|
|
@@ -538,6 +584,64 @@ class HookResponseRelayManager {
|
|
|
538
584
|
return;
|
|
539
585
|
await this.completeWithMessage(pending, assistantMessage, "assistant");
|
|
540
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
|
+
}
|
|
541
645
|
popNextPendingDedupe(sessionKey) {
|
|
542
646
|
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
543
647
|
if (!queue || queue.length === 0)
|
|
@@ -586,7 +690,7 @@ export function createSentinelPlugin(overrides) {
|
|
|
586
690
|
const config = {
|
|
587
691
|
allowedHosts: [],
|
|
588
692
|
localDispatchBase: "http://127.0.0.1:18789",
|
|
589
|
-
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
693
|
+
dispatchAuthToken: asString(process.env.SENTINEL_DISPATCH_TOKEN),
|
|
590
694
|
hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
|
|
591
695
|
hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
592
696
|
hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
|
|
@@ -606,11 +710,24 @@ export function createSentinelPlugin(overrides) {
|
|
|
606
710
|
const headers = { "content-type": "application/json" };
|
|
607
711
|
if (config.dispatchAuthToken)
|
|
608
712
|
headers.authorization = `Bearer ${config.dispatchAuthToken}`;
|
|
609
|
-
await fetch(`${config.localDispatchBase}${path}`, {
|
|
713
|
+
const response = await fetch(`${config.localDispatchBase}${path}`, {
|
|
610
714
|
method: "POST",
|
|
611
715
|
headers,
|
|
612
716
|
body: JSON.stringify(body),
|
|
613
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
|
+
}
|
|
614
731
|
},
|
|
615
732
|
});
|
|
616
733
|
return {
|
|
@@ -622,11 +739,18 @@ export function createSentinelPlugin(overrides) {
|
|
|
622
739
|
const runtimeConfig = resolveSentinelPluginConfig(api);
|
|
623
740
|
if (Object.keys(runtimeConfig).length > 0)
|
|
624
741
|
Object.assign(config, runtimeConfig);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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.");
|
|
630
754
|
}
|
|
631
755
|
manager.setNotifier({
|
|
632
756
|
async notify(target, message) {
|
|
@@ -648,6 +772,12 @@ export function createSentinelPlugin(overrides) {
|
|
|
648
772
|
manager.setWebhookRegistrationStatus("ok", "Route already registered (idempotent)", path);
|
|
649
773
|
return;
|
|
650
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
|
+
}
|
|
651
781
|
try {
|
|
652
782
|
api.registerHttpRoute({
|
|
653
783
|
path,
|
|
@@ -693,7 +823,14 @@ export function createSentinelPlugin(overrides) {
|
|
|
693
823
|
const message = String(err?.message ?? err);
|
|
694
824
|
const badRequest = message.includes("Invalid JSON payload") ||
|
|
695
825
|
message.includes("Payload must be a JSON object");
|
|
696
|
-
const
|
|
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;
|
|
697
834
|
res.writeHead(status, { "content-type": "application/json" });
|
|
698
835
|
res.end(JSON.stringify({ error: message }));
|
|
699
836
|
}
|
|
@@ -704,6 +841,7 @@ export function createSentinelPlugin(overrides) {
|
|
|
704
841
|
api.logger?.info?.(`[openclaw-sentinel] Registered default webhook route ${path}`);
|
|
705
842
|
}
|
|
706
843
|
catch (err) {
|
|
844
|
+
hookResponseRelayManager.dispose();
|
|
707
845
|
const msg = `Failed to register default webhook route ${path}: ${String(err?.message ?? err)}`;
|
|
708
846
|
manager.setWebhookRegistrationStatus("error", msg, path);
|
|
709
847
|
api.logger?.error?.(`[openclaw-sentinel] ${msg}`);
|
|
@@ -719,8 +857,8 @@ const sentinelPlugin = {
|
|
|
719
857
|
configSchema: sentinelConfigSchema,
|
|
720
858
|
register(api) {
|
|
721
859
|
const plugin = createSentinelPlugin(api.pluginConfig);
|
|
722
|
-
void plugin.init();
|
|
723
860
|
plugin.register(api);
|
|
861
|
+
void plugin.init();
|
|
724
862
|
},
|
|
725
863
|
};
|
|
726
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
};
|
package/dist/strategies/sse.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
+
if (!active)
|
|
32
60
|
return;
|
|
33
|
-
failureReported = true;
|
|
34
61
|
const reason = pendingError?.message ?? `websocket closed: ${code}`;
|
|
35
|
-
|
|
62
|
+
reportFailure(new Error(reason));
|
|
36
63
|
});
|
|
37
64
|
};
|
|
38
65
|
connect();
|
|
39
66
|
return async () => {
|
|
40
67
|
active = false;
|
|
41
|
-
|
|
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;
|
package/dist/toolSchema.js
CHANGED
|
@@ -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({
|
|
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({
|
|
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({
|
|
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;
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getPath(obj: unknown, path: string): unknown;
|
package/dist/utils.js
ADDED
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({
|
|
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([
|
package/dist/watcherManager.d.ts
CHANGED
|
@@ -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>;
|
package/dist/watcherManager.js
CHANGED
|
@@ -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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const message =
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 (
|
|
247
|
-
|
|
248
|
-
|
|
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();
|
package/openclaw.plugin.json
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dispatchAuthToken": {
|
|
21
21
|
"type": "string",
|
|
22
|
-
"description": "
|
|
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": "
|
|
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": "
|
|
59
|
+
"type": "integer",
|
|
60
60
|
"description": "Maximum total watchers across all skills",
|
|
61
61
|
"default": 200
|
|
62
62
|
},
|
|
63
63
|
"maxWatchersPerSkill": {
|
|
64
|
-
"type": "
|
|
64
|
+
"type": "integer",
|
|
65
65
|
"description": "Maximum watchers per skill",
|
|
66
66
|
"default": 20
|
|
67
67
|
},
|
|
68
68
|
"maxConditionsPerWatcher": {
|
|
69
|
-
"type": "
|
|
69
|
+
"type": "integer",
|
|
70
70
|
"description": "Maximum conditions per watcher definition",
|
|
71
71
|
"default": 25
|
|
72
72
|
},
|
|
73
73
|
"maxIntervalMsFloor": {
|
|
74
|
-
"type": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
},
|