@coffeexdev/openclaw-sentinel 0.4.4 → 0.5.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 +192 -18
- package/dist/callbackEnvelope.js +30 -0
- package/dist/configSchema.js +106 -5
- package/dist/index.js +433 -13
- package/dist/tool.js +29 -2
- package/dist/toolSchema.d.ts +4 -0
- package/dist/toolSchema.js +11 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.js +4 -0
- package/dist/validator.d.ts +2 -0
- package/dist/validator.js +7 -0
- package/dist/watcherManager.js +33 -2
- package/openclaw.plugin.json +80 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { sentinelConfigSchema } from "./configSchema.js";
|
|
3
3
|
import { registerSentinelControl } from "./tool.js";
|
|
4
|
-
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
4
|
+
import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
|
|
5
5
|
import { WatcherManager } from "./watcherManager.js";
|
|
6
6
|
const registeredWebhookPathsByRegistrar = new WeakMap();
|
|
7
|
-
const
|
|
7
|
+
const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
|
|
8
|
+
const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
|
|
9
|
+
const DEFAULT_HOOK_RESPONSE_TIMEOUT_MS = 30_000;
|
|
10
|
+
const DEFAULT_HOOK_RESPONSE_FALLBACK_MODE = "concise";
|
|
8
11
|
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
9
12
|
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
10
13
|
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
11
14
|
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
|
+
const SUPPORTED_DELIVERY_CHANNELS = new Set([
|
|
16
|
+
"telegram",
|
|
17
|
+
"discord",
|
|
18
|
+
"slack",
|
|
19
|
+
"signal",
|
|
20
|
+
"imessage",
|
|
21
|
+
"whatsapp",
|
|
22
|
+
"line",
|
|
23
|
+
]);
|
|
12
24
|
function trimText(value, max) {
|
|
13
25
|
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
14
26
|
}
|
|
@@ -28,6 +40,21 @@ function asIsoString(value) {
|
|
|
28
40
|
function isRecord(value) {
|
|
29
41
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
30
42
|
}
|
|
43
|
+
function resolveSentinelPluginConfig(api) {
|
|
44
|
+
const pluginConfig = isRecord(api.pluginConfig)
|
|
45
|
+
? api.pluginConfig
|
|
46
|
+
: {};
|
|
47
|
+
const configRoot = isRecord(api.config) ? api.config : undefined;
|
|
48
|
+
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;
|
|
57
|
+
}
|
|
31
58
|
function isDeliveryTarget(value) {
|
|
32
59
|
return (isRecord(value) &&
|
|
33
60
|
typeof value.channel === "string" &&
|
|
@@ -41,6 +68,14 @@ function normalizePath(path) {
|
|
|
41
68
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
42
69
|
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
43
70
|
}
|
|
71
|
+
function sanitizeSessionSegment(value) {
|
|
72
|
+
const sanitized = value
|
|
73
|
+
.trim()
|
|
74
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
|
75
|
+
.replace(/-+/g, "-")
|
|
76
|
+
.replace(/^-|-$/g, "");
|
|
77
|
+
return sanitized.length > 0 ? sanitized.slice(0, 64) : "unknown";
|
|
78
|
+
}
|
|
44
79
|
function clipPayloadForPrompt(value) {
|
|
45
80
|
const serialized = JSON.stringify(value);
|
|
46
81
|
if (!serialized)
|
|
@@ -56,15 +91,63 @@ function clipPayloadForPrompt(value) {
|
|
|
56
91
|
preview: `${clipped}…`,
|
|
57
92
|
};
|
|
58
93
|
}
|
|
94
|
+
function getNestedString(value, path) {
|
|
95
|
+
let cursor = value;
|
|
96
|
+
for (const segment of path) {
|
|
97
|
+
if (!isRecord(cursor))
|
|
98
|
+
return undefined;
|
|
99
|
+
cursor = cursor[segment];
|
|
100
|
+
}
|
|
101
|
+
return asString(cursor);
|
|
102
|
+
}
|
|
103
|
+
function extractDeliveryContext(payload) {
|
|
104
|
+
const raw = isRecord(payload.deliveryContext) ? payload.deliveryContext : undefined;
|
|
105
|
+
if (!raw)
|
|
106
|
+
return undefined;
|
|
107
|
+
const sessionKey = asString(raw.sessionKey) ??
|
|
108
|
+
asString(raw.sourceSessionKey) ??
|
|
109
|
+
getNestedString(raw, ["source", "sessionKey"]);
|
|
110
|
+
const messageChannel = asString(raw.messageChannel);
|
|
111
|
+
const requesterSenderId = asString(raw.requesterSenderId);
|
|
112
|
+
const agentAccountId = asString(raw.agentAccountId);
|
|
113
|
+
const currentChat = isDeliveryTarget(raw.currentChat)
|
|
114
|
+
? raw.currentChat
|
|
115
|
+
: isDeliveryTarget(raw.deliveryTarget)
|
|
116
|
+
? raw.deliveryTarget
|
|
117
|
+
: undefined;
|
|
118
|
+
const deliveryTargets = Array.isArray(raw.deliveryTargets)
|
|
119
|
+
? raw.deliveryTargets.filter(isDeliveryTarget)
|
|
120
|
+
: undefined;
|
|
121
|
+
const context = {};
|
|
122
|
+
if (sessionKey)
|
|
123
|
+
context.sessionKey = sessionKey;
|
|
124
|
+
if (messageChannel)
|
|
125
|
+
context.messageChannel = messageChannel;
|
|
126
|
+
if (requesterSenderId)
|
|
127
|
+
context.requesterSenderId = requesterSenderId;
|
|
128
|
+
if (agentAccountId)
|
|
129
|
+
context.agentAccountId = agentAccountId;
|
|
130
|
+
if (currentChat)
|
|
131
|
+
context.currentChat = currentChat;
|
|
132
|
+
if (deliveryTargets && deliveryTargets.length > 0)
|
|
133
|
+
context.deliveryTargets = deliveryTargets;
|
|
134
|
+
return Object.keys(context).length > 0 ? context : undefined;
|
|
135
|
+
}
|
|
59
136
|
function buildSentinelEventEnvelope(payload) {
|
|
60
137
|
const watcherId = asString(payload.watcherId) ??
|
|
61
|
-
(
|
|
138
|
+
getNestedString(payload, ["watcher", "id"]) ??
|
|
139
|
+
getNestedString(payload, ["context", "watcherId"]);
|
|
62
140
|
const eventName = asString(payload.eventName) ??
|
|
63
|
-
(
|
|
141
|
+
getNestedString(payload, ["watcher", "eventName"]) ??
|
|
142
|
+
getNestedString(payload, ["event", "name"]);
|
|
64
143
|
const skillId = asString(payload.skillId) ??
|
|
65
|
-
(
|
|
144
|
+
getNestedString(payload, ["watcher", "skillId"]) ??
|
|
145
|
+
getNestedString(payload, ["context", "skillId"]) ??
|
|
66
146
|
undefined;
|
|
67
|
-
const matchedAt = asIsoString(payload.matchedAt) ??
|
|
147
|
+
const matchedAt = asIsoString(payload.matchedAt) ??
|
|
148
|
+
asIsoString(payload.timestamp) ??
|
|
149
|
+
asIsoString(getNestedString(payload, ["trigger", "matchedAt"])) ??
|
|
150
|
+
new Date().toISOString();
|
|
68
151
|
const rawPayload = payload.payload ??
|
|
69
152
|
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
70
153
|
payload;
|
|
@@ -78,10 +161,17 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
78
161
|
const dedupeKey = asString(payload.dedupeKey) ??
|
|
79
162
|
asString(payload.correlationId) ??
|
|
80
163
|
asString(payload.correlationID) ??
|
|
164
|
+
getNestedString(payload, ["trigger", "dedupeKey"]) ??
|
|
81
165
|
generatedDedupe;
|
|
82
166
|
const deliveryTargets = Array.isArray(payload.deliveryTargets)
|
|
83
167
|
? payload.deliveryTargets.filter(isDeliveryTarget)
|
|
84
168
|
: undefined;
|
|
169
|
+
const sourceRoute = getNestedString(payload, ["source", "route"]) ?? DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
170
|
+
const sourcePlugin = getNestedString(payload, ["source", "plugin"]) ?? "openclaw-sentinel";
|
|
171
|
+
const hookSessionGroup = asString(payload.hookSessionGroup) ??
|
|
172
|
+
asString(payload.sessionGroup) ??
|
|
173
|
+
getNestedString(payload, ["watcher", "sessionGroup"]);
|
|
174
|
+
const deliveryContext = extractDeliveryContext(payload);
|
|
85
175
|
const envelope = {
|
|
86
176
|
watcherId: watcherId ?? null,
|
|
87
177
|
eventName: eventName ?? null,
|
|
@@ -90,22 +180,169 @@ function buildSentinelEventEnvelope(payload) {
|
|
|
90
180
|
dedupeKey,
|
|
91
181
|
correlationId: dedupeKey,
|
|
92
182
|
source: {
|
|
93
|
-
route:
|
|
94
|
-
plugin:
|
|
183
|
+
route: sourceRoute,
|
|
184
|
+
plugin: sourcePlugin,
|
|
95
185
|
},
|
|
96
186
|
};
|
|
97
187
|
if (skillId)
|
|
98
188
|
envelope.skillId = skillId;
|
|
189
|
+
if (hookSessionGroup)
|
|
190
|
+
envelope.hookSessionGroup = hookSessionGroup;
|
|
99
191
|
if (deliveryTargets && deliveryTargets.length > 0)
|
|
100
192
|
envelope.deliveryTargets = deliveryTargets;
|
|
193
|
+
if (deliveryContext)
|
|
194
|
+
envelope.deliveryContext = deliveryContext;
|
|
101
195
|
return envelope;
|
|
102
196
|
}
|
|
103
|
-
function buildSentinelSystemEvent(
|
|
104
|
-
const envelope = buildSentinelEventEnvelope(payload);
|
|
197
|
+
function buildSentinelSystemEvent(envelope) {
|
|
105
198
|
const jsonEnvelope = JSON.stringify(envelope, null, 2);
|
|
106
199
|
const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
|
|
107
200
|
return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
108
201
|
}
|
|
202
|
+
function normalizeDeliveryTargets(targets) {
|
|
203
|
+
const deduped = new Map();
|
|
204
|
+
for (const target of targets) {
|
|
205
|
+
const channel = asString(target.channel);
|
|
206
|
+
const to = asString(target.to);
|
|
207
|
+
if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
|
|
208
|
+
continue;
|
|
209
|
+
const accountId = asString(target.accountId);
|
|
210
|
+
const key = `${channel}:${to}:${accountId ?? ""}`;
|
|
211
|
+
deduped.set(key, { channel, to, ...(accountId ? { accountId } : {}) });
|
|
212
|
+
}
|
|
213
|
+
return [...deduped.values()];
|
|
214
|
+
}
|
|
215
|
+
function inferTargetFromSessionKey(sessionKey, accountId) {
|
|
216
|
+
const segments = sessionKey
|
|
217
|
+
.split(":")
|
|
218
|
+
.map((part) => part.trim())
|
|
219
|
+
.filter(Boolean);
|
|
220
|
+
if (segments.length < 5)
|
|
221
|
+
return undefined;
|
|
222
|
+
const channel = segments[2];
|
|
223
|
+
const to = segments.at(-1);
|
|
224
|
+
if (!channel || !to || !SUPPORTED_DELIVERY_CHANNELS.has(channel))
|
|
225
|
+
return undefined;
|
|
226
|
+
return {
|
|
227
|
+
channel,
|
|
228
|
+
to,
|
|
229
|
+
...(accountId ? { accountId } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function inferRelayTargets(payload, envelope) {
|
|
233
|
+
const inferred = [];
|
|
234
|
+
if (envelope.deliveryTargets?.length)
|
|
235
|
+
inferred.push(...envelope.deliveryTargets);
|
|
236
|
+
if (envelope.deliveryContext?.deliveryTargets?.length) {
|
|
237
|
+
inferred.push(...envelope.deliveryContext.deliveryTargets);
|
|
238
|
+
}
|
|
239
|
+
if (envelope.deliveryContext?.currentChat)
|
|
240
|
+
inferred.push(envelope.deliveryContext.currentChat);
|
|
241
|
+
if (envelope.deliveryContext?.messageChannel && envelope.deliveryContext?.requesterSenderId) {
|
|
242
|
+
if (SUPPORTED_DELIVERY_CHANNELS.has(envelope.deliveryContext.messageChannel)) {
|
|
243
|
+
inferred.push({
|
|
244
|
+
channel: envelope.deliveryContext.messageChannel,
|
|
245
|
+
to: envelope.deliveryContext.requesterSenderId,
|
|
246
|
+
...(envelope.deliveryContext.agentAccountId
|
|
247
|
+
? { accountId: envelope.deliveryContext.agentAccountId }
|
|
248
|
+
: {}),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (envelope.deliveryContext?.sessionKey) {
|
|
253
|
+
const target = inferTargetFromSessionKey(envelope.deliveryContext.sessionKey, envelope.deliveryContext.agentAccountId);
|
|
254
|
+
if (target)
|
|
255
|
+
inferred.push(target);
|
|
256
|
+
}
|
|
257
|
+
if (isDeliveryTarget(payload.currentChat))
|
|
258
|
+
inferred.push(payload.currentChat);
|
|
259
|
+
const sourceCurrentChat = isRecord(payload.source) ? payload.source.currentChat : undefined;
|
|
260
|
+
if (isDeliveryTarget(sourceCurrentChat))
|
|
261
|
+
inferred.push(sourceCurrentChat);
|
|
262
|
+
const messageChannel = asString(payload.messageChannel);
|
|
263
|
+
const requesterSenderId = asString(payload.requesterSenderId);
|
|
264
|
+
if (messageChannel && requesterSenderId && SUPPORTED_DELIVERY_CHANNELS.has(messageChannel)) {
|
|
265
|
+
inferred.push({ channel: messageChannel, to: requesterSenderId });
|
|
266
|
+
}
|
|
267
|
+
const fromSessionKey = asString(payload.sessionKey);
|
|
268
|
+
if (fromSessionKey) {
|
|
269
|
+
const target = inferTargetFromSessionKey(fromSessionKey, asString(payload.agentAccountId));
|
|
270
|
+
if (target)
|
|
271
|
+
inferred.push(target);
|
|
272
|
+
}
|
|
273
|
+
const sourceSessionKey = getNestedString(payload, ["source", "sessionKey"]);
|
|
274
|
+
if (sourceSessionKey) {
|
|
275
|
+
const sourceAccountId = getNestedString(payload, ["source", "accountId"]);
|
|
276
|
+
const target = inferTargetFromSessionKey(sourceSessionKey, sourceAccountId);
|
|
277
|
+
if (target)
|
|
278
|
+
inferred.push(target);
|
|
279
|
+
}
|
|
280
|
+
return normalizeDeliveryTargets(inferred);
|
|
281
|
+
}
|
|
282
|
+
function summarizeContext(value) {
|
|
283
|
+
if (!isRecord(value))
|
|
284
|
+
return undefined;
|
|
285
|
+
const entries = Object.entries(value).slice(0, 3);
|
|
286
|
+
if (entries.length === 0)
|
|
287
|
+
return undefined;
|
|
288
|
+
const chunks = entries.map(([key, val]) => {
|
|
289
|
+
if (typeof val === "string")
|
|
290
|
+
return `${key}=${trimText(val, 64)}`;
|
|
291
|
+
if (typeof val === "number" || typeof val === "boolean")
|
|
292
|
+
return `${key}=${String(val)}`;
|
|
293
|
+
return `${key}=${trimText(JSON.stringify(val), 64)}`;
|
|
294
|
+
});
|
|
295
|
+
return chunks.join(" · ");
|
|
296
|
+
}
|
|
297
|
+
function buildRelayMessage(envelope) {
|
|
298
|
+
const title = envelope.eventName ? `Sentinel alert: ${envelope.eventName}` : "Sentinel alert";
|
|
299
|
+
const watcher = envelope.watcherId ? `watcher ${envelope.watcherId}` : "watcher unknown";
|
|
300
|
+
const payloadRecord = isRecord(envelope.payload) ? envelope.payload : undefined;
|
|
301
|
+
const contextSummary = summarizeContext(payloadRecord && isRecord(payloadRecord.context) ? payloadRecord.context : payloadRecord);
|
|
302
|
+
const lines = [title, `${watcher} · ${envelope.matchedAt}`];
|
|
303
|
+
if (contextSummary)
|
|
304
|
+
lines.push(contextSummary);
|
|
305
|
+
const text = lines.join("\n").trim();
|
|
306
|
+
return text.length > 0 ? text : "Sentinel callback received.";
|
|
307
|
+
}
|
|
308
|
+
function normalizeAssistantRelayText(assistantTexts) {
|
|
309
|
+
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
310
|
+
return undefined;
|
|
311
|
+
const parts = assistantTexts.map((value) => value.trim()).filter(Boolean);
|
|
312
|
+
if (parts.length === 0)
|
|
313
|
+
return undefined;
|
|
314
|
+
return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
315
|
+
}
|
|
316
|
+
function resolveHookResponseDedupeWindowMs(config) {
|
|
317
|
+
const candidate = config.hookResponseDedupeWindowMs ??
|
|
318
|
+
config.hookRelayDedupeWindowMs ??
|
|
319
|
+
DEFAULT_RELAY_DEDUPE_WINDOW_MS;
|
|
320
|
+
return Math.max(0, candidate);
|
|
321
|
+
}
|
|
322
|
+
function resolveHookResponseTimeoutMs(config) {
|
|
323
|
+
const candidate = config.hookResponseTimeoutMs ?? DEFAULT_HOOK_RESPONSE_TIMEOUT_MS;
|
|
324
|
+
return Math.max(0, candidate);
|
|
325
|
+
}
|
|
326
|
+
function resolveHookResponseFallbackMode(config) {
|
|
327
|
+
return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
|
|
328
|
+
}
|
|
329
|
+
function buildIsolatedHookSessionKey(envelope, config) {
|
|
330
|
+
const rawPrefix = asString(config.hookSessionKey) ??
|
|
331
|
+
asString(config.hookSessionPrefix) ??
|
|
332
|
+
DEFAULT_HOOK_SESSION_PREFIX;
|
|
333
|
+
const prefix = rawPrefix.replace(/:+$/g, "");
|
|
334
|
+
const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
|
|
335
|
+
if (group) {
|
|
336
|
+
return `${prefix}:group:${sanitizeSessionSegment(group)}`;
|
|
337
|
+
}
|
|
338
|
+
if (envelope.watcherId) {
|
|
339
|
+
return `${prefix}:watcher:${sanitizeSessionSegment(envelope.watcherId)}`;
|
|
340
|
+
}
|
|
341
|
+
if (envelope.dedupeKey) {
|
|
342
|
+
return `${prefix}:event:${sanitizeSessionSegment(envelope.dedupeKey.slice(0, 24))}`;
|
|
343
|
+
}
|
|
344
|
+
return `${prefix}:event:unknown`;
|
|
345
|
+
}
|
|
109
346
|
async function readSentinelWebhookPayload(req) {
|
|
110
347
|
const preParsed = req.body;
|
|
111
348
|
if (isRecord(preParsed))
|
|
@@ -178,12 +415,177 @@ async function notifyDeliveryTarget(api, target, message) {
|
|
|
178
415
|
throw new Error(`Unsupported delivery target channel: ${target.channel}`);
|
|
179
416
|
}
|
|
180
417
|
}
|
|
418
|
+
async function deliverMessageToTargets(api, targets, message) {
|
|
419
|
+
if (targets.length === 0)
|
|
420
|
+
return { delivered: 0, failed: 0 };
|
|
421
|
+
const results = await Promise.all(targets.map(async (target) => {
|
|
422
|
+
try {
|
|
423
|
+
await notifyDeliveryTarget(api, target, message);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}));
|
|
430
|
+
const delivered = results.filter(Boolean).length;
|
|
431
|
+
return {
|
|
432
|
+
delivered,
|
|
433
|
+
failed: results.length - delivered,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
class HookResponseRelayManager {
|
|
437
|
+
config;
|
|
438
|
+
api;
|
|
439
|
+
recentByDedupe = new Map();
|
|
440
|
+
pendingByDedupe = new Map();
|
|
441
|
+
pendingQueueBySession = new Map();
|
|
442
|
+
constructor(config, api) {
|
|
443
|
+
this.config = config;
|
|
444
|
+
this.api = api;
|
|
445
|
+
}
|
|
446
|
+
register(args) {
|
|
447
|
+
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
448
|
+
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
|
+
const existingTs = this.recentByDedupe.get(args.dedupeKey);
|
|
458
|
+
if (dedupeWindowMs > 0 &&
|
|
459
|
+
typeof existingTs === "number" &&
|
|
460
|
+
now - existingTs <= dedupeWindowMs) {
|
|
461
|
+
return {
|
|
462
|
+
dedupeKey: args.dedupeKey,
|
|
463
|
+
attempted: args.relayTargets.length,
|
|
464
|
+
delivered: 0,
|
|
465
|
+
failed: 0,
|
|
466
|
+
deduped: true,
|
|
467
|
+
pending: false,
|
|
468
|
+
timeoutMs: resolveHookResponseTimeoutMs(this.config),
|
|
469
|
+
fallbackMode: resolveHookResponseFallbackMode(this.config),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
this.recentByDedupe.set(args.dedupeKey, now);
|
|
473
|
+
const timeoutMs = resolveHookResponseTimeoutMs(this.config);
|
|
474
|
+
const fallbackMode = resolveHookResponseFallbackMode(this.config);
|
|
475
|
+
if (args.relayTargets.length === 0) {
|
|
476
|
+
return {
|
|
477
|
+
dedupeKey: args.dedupeKey,
|
|
478
|
+
attempted: 0,
|
|
479
|
+
delivered: 0,
|
|
480
|
+
failed: 0,
|
|
481
|
+
deduped: false,
|
|
482
|
+
pending: false,
|
|
483
|
+
timeoutMs,
|
|
484
|
+
fallbackMode,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const pending = {
|
|
488
|
+
dedupeKey: args.dedupeKey,
|
|
489
|
+
sessionKey: args.sessionKey,
|
|
490
|
+
relayTargets: args.relayTargets,
|
|
491
|
+
fallbackMessage: args.fallbackMessage,
|
|
492
|
+
createdAt: now,
|
|
493
|
+
timeoutMs,
|
|
494
|
+
fallbackMode,
|
|
495
|
+
state: "pending",
|
|
496
|
+
};
|
|
497
|
+
this.pendingByDedupe.set(args.dedupeKey, pending);
|
|
498
|
+
const queue = this.pendingQueueBySession.get(args.sessionKey) ?? [];
|
|
499
|
+
queue.push(args.dedupeKey);
|
|
500
|
+
this.pendingQueueBySession.set(args.sessionKey, queue);
|
|
501
|
+
if (timeoutMs === 0) {
|
|
502
|
+
void this.handleTimeout(args.dedupeKey);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
pending.timer = setTimeout(() => {
|
|
506
|
+
void this.handleTimeout(args.dedupeKey);
|
|
507
|
+
}, timeoutMs);
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
dedupeKey: args.dedupeKey,
|
|
511
|
+
attempted: args.relayTargets.length,
|
|
512
|
+
delivered: 0,
|
|
513
|
+
failed: 0,
|
|
514
|
+
deduped: false,
|
|
515
|
+
pending: true,
|
|
516
|
+
timeoutMs,
|
|
517
|
+
fallbackMode,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async handleLlmOutput(sessionKey, assistantTexts) {
|
|
521
|
+
if (!sessionKey)
|
|
522
|
+
return;
|
|
523
|
+
const assistantMessage = normalizeAssistantRelayText(assistantTexts);
|
|
524
|
+
if (!assistantMessage)
|
|
525
|
+
return;
|
|
526
|
+
const dedupeKey = this.popNextPendingDedupe(sessionKey);
|
|
527
|
+
if (!dedupeKey)
|
|
528
|
+
return;
|
|
529
|
+
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
530
|
+
if (!pending || pending.state !== "pending")
|
|
531
|
+
return;
|
|
532
|
+
await this.completeWithMessage(pending, assistantMessage, "assistant");
|
|
533
|
+
}
|
|
534
|
+
popNextPendingDedupe(sessionKey) {
|
|
535
|
+
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
536
|
+
if (!queue || queue.length === 0)
|
|
537
|
+
return undefined;
|
|
538
|
+
while (queue.length > 0) {
|
|
539
|
+
const next = queue.shift();
|
|
540
|
+
if (!next)
|
|
541
|
+
continue;
|
|
542
|
+
const pending = this.pendingByDedupe.get(next);
|
|
543
|
+
if (pending && pending.state === "pending") {
|
|
544
|
+
if (queue.length === 0)
|
|
545
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
546
|
+
else
|
|
547
|
+
this.pendingQueueBySession.set(sessionKey, queue);
|
|
548
|
+
return next;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
async handleTimeout(dedupeKey) {
|
|
555
|
+
const pending = this.pendingByDedupe.get(dedupeKey);
|
|
556
|
+
if (!pending || pending.state !== "pending")
|
|
557
|
+
return;
|
|
558
|
+
if (pending.fallbackMode === "none") {
|
|
559
|
+
this.markClosed(pending, "timed_out");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
await this.completeWithMessage(pending, pending.fallbackMessage, "timeout");
|
|
563
|
+
}
|
|
564
|
+
async completeWithMessage(pending, message, source) {
|
|
565
|
+
const delivery = await deliverMessageToTargets(this.api, pending.relayTargets, message);
|
|
566
|
+
this.markClosed(pending, source === "assistant" ? "completed" : "timed_out");
|
|
567
|
+
this.api.logger?.info?.(`[openclaw-sentinel] ${source === "assistant" ? "Relayed assistant response" : "Sent timeout fallback"} for dedupe=${pending.dedupeKey} delivered=${delivery.delivered} failed=${delivery.failed}`);
|
|
568
|
+
}
|
|
569
|
+
markClosed(pending, state) {
|
|
570
|
+
pending.state = state;
|
|
571
|
+
if (pending.timer) {
|
|
572
|
+
clearTimeout(pending.timer);
|
|
573
|
+
pending.timer = undefined;
|
|
574
|
+
}
|
|
575
|
+
this.pendingByDedupe.set(pending.dedupeKey, pending);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
181
578
|
export function createSentinelPlugin(overrides) {
|
|
182
579
|
const config = {
|
|
183
580
|
allowedHosts: [],
|
|
184
581
|
localDispatchBase: "http://127.0.0.1:18789",
|
|
185
582
|
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
186
|
-
|
|
583
|
+
hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
|
|
584
|
+
hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
585
|
+
hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
|
|
586
|
+
hookResponseFallbackMode: DEFAULT_HOOK_RESPONSE_FALLBACK_MODE,
|
|
587
|
+
hookResponseDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
588
|
+
notificationPayloadMode: "concise",
|
|
187
589
|
limits: {
|
|
188
590
|
maxWatchersTotal: 200,
|
|
189
591
|
maxWatchersPerSkill: 20,
|
|
@@ -210,6 +612,15 @@ export function createSentinelPlugin(overrides) {
|
|
|
210
612
|
await manager.init();
|
|
211
613
|
},
|
|
212
614
|
register(api) {
|
|
615
|
+
const runtimeConfig = resolveSentinelPluginConfig(api);
|
|
616
|
+
if (Object.keys(runtimeConfig).length > 0)
|
|
617
|
+
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
|
+
});
|
|
623
|
+
}
|
|
213
624
|
manager.setNotifier({
|
|
214
625
|
async notify(target, message) {
|
|
215
626
|
await notifyDeliveryTarget(api, target, message);
|
|
@@ -244,19 +655,28 @@ export function createSentinelPlugin(overrides) {
|
|
|
244
655
|
}
|
|
245
656
|
try {
|
|
246
657
|
const payload = await readSentinelWebhookPayload(req);
|
|
247
|
-
const
|
|
248
|
-
const
|
|
658
|
+
const envelope = buildSentinelEventEnvelope(payload);
|
|
659
|
+
const sessionKey = buildIsolatedHookSessionKey(envelope, config);
|
|
660
|
+
const text = buildSentinelSystemEvent(envelope);
|
|
249
661
|
const enqueued = api.runtime.system.enqueueSystemEvent(text, { sessionKey });
|
|
250
662
|
api.runtime.system.requestHeartbeatNow({
|
|
251
663
|
reason: "hook:sentinel",
|
|
252
664
|
sessionKey,
|
|
253
665
|
});
|
|
666
|
+
const relayTargets = inferRelayTargets(payload, envelope);
|
|
667
|
+
const relay = hookResponseRelayManager.register({
|
|
668
|
+
dedupeKey: envelope.dedupeKey,
|
|
669
|
+
sessionKey,
|
|
670
|
+
relayTargets,
|
|
671
|
+
fallbackMessage: buildRelayMessage(envelope),
|
|
672
|
+
});
|
|
254
673
|
res.writeHead(200, { "content-type": "application/json" });
|
|
255
674
|
res.end(JSON.stringify({
|
|
256
675
|
ok: true,
|
|
257
676
|
route: path,
|
|
258
677
|
sessionKey,
|
|
259
678
|
enqueued,
|
|
679
|
+
relay,
|
|
260
680
|
}));
|
|
261
681
|
}
|
|
262
682
|
catch (err) {
|
package/dist/tool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsonResult } from "openclaw/plugin-sdk";
|
|
2
2
|
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
|
|
3
4
|
import { SentinelToolSchema, SentinelToolValidationSchema } from "./toolSchema.js";
|
|
4
5
|
import { TemplateValueSchema } from "./templateValueSchema.js";
|
|
5
6
|
function validateParams(params) {
|
|
@@ -63,6 +64,30 @@ function inferDefaultDeliveryTargets(ctx) {
|
|
|
63
64
|
}
|
|
64
65
|
return [];
|
|
65
66
|
}
|
|
67
|
+
function maybeSetMetadata(metadata, key, value) {
|
|
68
|
+
const trimmed = value?.trim();
|
|
69
|
+
if (!trimmed)
|
|
70
|
+
return;
|
|
71
|
+
if (!metadata[key])
|
|
72
|
+
metadata[key] = trimmed;
|
|
73
|
+
}
|
|
74
|
+
function addOriginDeliveryMetadata(watcher, ctx) {
|
|
75
|
+
const metadataRaw = watcher.metadata;
|
|
76
|
+
const metadata = metadataRaw && typeof metadataRaw === "object" && !Array.isArray(metadataRaw)
|
|
77
|
+
? { ...metadataRaw }
|
|
78
|
+
: {};
|
|
79
|
+
const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
|
|
80
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_SESSION_KEY_METADATA, ctx.sessionKey);
|
|
81
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_CHANNEL_METADATA, ctx.messageChannel);
|
|
82
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_TARGET_METADATA, ctx.requesterSenderId ?? sessionPeer);
|
|
83
|
+
maybeSetMetadata(metadata, SENTINEL_ORIGIN_ACCOUNT_METADATA, ctx.agentAccountId);
|
|
84
|
+
if (Object.keys(metadata).length === 0)
|
|
85
|
+
return watcher;
|
|
86
|
+
return {
|
|
87
|
+
...watcher,
|
|
88
|
+
metadata,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
66
91
|
export function registerSentinelControl(registerTool, manager) {
|
|
67
92
|
registerTool((ctx) => ({
|
|
68
93
|
name: "sentinel_control",
|
|
@@ -73,10 +98,12 @@ export function registerSentinelControl(registerTool, manager) {
|
|
|
73
98
|
const payload = validateParams(params);
|
|
74
99
|
switch (payload.action) {
|
|
75
100
|
case "create":
|
|
76
|
-
case "add":
|
|
77
|
-
|
|
101
|
+
case "add": {
|
|
102
|
+
const watcherWithContext = addOriginDeliveryMetadata(payload.watcher, ctx);
|
|
103
|
+
return normalizeToolResultText(await manager.create(watcherWithContext, {
|
|
78
104
|
deliveryTargets: inferDefaultDeliveryTargets(ctx),
|
|
79
105
|
}), "Watcher created");
|
|
106
|
+
}
|
|
80
107
|
case "enable":
|
|
81
108
|
await manager.enable(payload.id);
|
|
82
109
|
return normalizeToolResultText(undefined, `Enabled watcher: ${payload.id}`);
|
package/dist/toolSchema.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export declare const SentinelToolValidationSchema: import("@sinclair/typebox").T
|
|
|
26
26
|
priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
|
|
27
27
|
deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
28
28
|
dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
29
|
+
notificationPayloadMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"inherit">, import("@sinclair/typebox").TLiteral<"none">, import("@sinclair/typebox").TLiteral<"concise">, import("@sinclair/typebox").TLiteral<"debug">]>>;
|
|
30
|
+
sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
29
31
|
}>;
|
|
30
32
|
retry: import("@sinclair/typebox").TObject<{
|
|
31
33
|
maxRetries: import("@sinclair/typebox").TNumber;
|
|
@@ -74,6 +76,8 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
|
|
|
74
76
|
priority: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"low">, import("@sinclair/typebox").TLiteral<"normal">, import("@sinclair/typebox").TLiteral<"high">, import("@sinclair/typebox").TLiteral<"critical">]>>;
|
|
75
77
|
deadlineTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
76
78
|
dedupeKeyTemplate: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
79
|
+
notificationPayloadMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"inherit">, import("@sinclair/typebox").TLiteral<"none">, import("@sinclair/typebox").TLiteral<"concise">, import("@sinclair/typebox").TLiteral<"debug">]>>;
|
|
80
|
+
sessionGroup: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
77
81
|
}>;
|
|
78
82
|
retry: import("@sinclair/typebox").TObject<{
|
|
79
83
|
maxRetries: import("@sinclair/typebox").TNumber;
|
package/dist/toolSchema.js
CHANGED
|
@@ -35,6 +35,17 @@ const FireConfigSchema = Type.Object({
|
|
|
35
35
|
priority: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("normal"), Type.Literal("high"), Type.Literal("critical")], { description: "Callback urgency hint" })),
|
|
36
36
|
deadlineTemplate: Type.Optional(Type.String({ description: "Optional templated deadline string for callback consumers" })),
|
|
37
37
|
dedupeKeyTemplate: Type.Optional(Type.String({ description: "Optional template to derive deterministic trigger dedupe key" })),
|
|
38
|
+
notificationPayloadMode: Type.Optional(Type.Union([
|
|
39
|
+
Type.Literal("inherit"),
|
|
40
|
+
Type.Literal("none"),
|
|
41
|
+
Type.Literal("concise"),
|
|
42
|
+
Type.Literal("debug"),
|
|
43
|
+
], {
|
|
44
|
+
description: "Notification payload mode override for deliveryTargets (inherit global default, suppress messages, concise relay text, or debug envelope block)",
|
|
45
|
+
})),
|
|
46
|
+
sessionGroup: Type.Optional(Type.String({
|
|
47
|
+
description: "Optional hook session group key. Watchers with the same key share one isolated callback-processing session.",
|
|
48
|
+
})),
|
|
38
49
|
});
|
|
39
50
|
const RetryPolicySchema = Type.Object({
|
|
40
51
|
maxRetries: Type.Number({ description: "Maximum number of retry attempts" }),
|
package/dist/types.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ export interface Condition {
|
|
|
7
7
|
}
|
|
8
8
|
export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
|
|
9
9
|
export type PriorityLevel = "low" | "normal" | "high" | "critical";
|
|
10
|
+
export type NotificationPayloadMode = "none" | "concise" | "debug";
|
|
11
|
+
export type NotificationPayloadModeOverride = "inherit" | NotificationPayloadMode;
|
|
12
|
+
export type HookResponseFallbackMode = "none" | "concise";
|
|
13
|
+
export declare const SENTINEL_ORIGIN_SESSION_KEY_METADATA = "openclaw.sentinel.origin.sessionKey";
|
|
14
|
+
export declare const SENTINEL_ORIGIN_CHANNEL_METADATA = "openclaw.sentinel.origin.channel";
|
|
15
|
+
export declare const SENTINEL_ORIGIN_TARGET_METADATA = "openclaw.sentinel.origin.to";
|
|
16
|
+
export declare const SENTINEL_ORIGIN_ACCOUNT_METADATA = "openclaw.sentinel.origin.accountId";
|
|
10
17
|
export interface FireConfig {
|
|
11
18
|
webhookPath?: string;
|
|
12
19
|
eventName: string;
|
|
@@ -16,6 +23,8 @@ export interface FireConfig {
|
|
|
16
23
|
priority?: PriorityLevel;
|
|
17
24
|
deadlineTemplate?: string;
|
|
18
25
|
dedupeKeyTemplate?: string;
|
|
26
|
+
notificationPayloadMode?: NotificationPayloadModeOverride;
|
|
27
|
+
sessionGroup?: string;
|
|
19
28
|
}
|
|
20
29
|
export interface RetryPolicy {
|
|
21
30
|
maxRetries: number;
|
|
@@ -83,8 +92,16 @@ export interface SentinelConfig {
|
|
|
83
92
|
allowedHosts: string[];
|
|
84
93
|
localDispatchBase: string;
|
|
85
94
|
dispatchAuthToken?: string;
|
|
95
|
+
/** @deprecated Backward-compatible alias for hookSessionPrefix. */
|
|
86
96
|
hookSessionKey?: string;
|
|
97
|
+
hookSessionPrefix?: string;
|
|
98
|
+
hookSessionGroup?: string;
|
|
99
|
+
hookRelayDedupeWindowMs?: number;
|
|
100
|
+
hookResponseTimeoutMs?: number;
|
|
101
|
+
hookResponseFallbackMode?: HookResponseFallbackMode;
|
|
102
|
+
hookResponseDedupeWindowMs?: number;
|
|
87
103
|
stateFilePath?: string;
|
|
104
|
+
notificationPayloadMode?: NotificationPayloadMode;
|
|
88
105
|
limits: SentinelLimits;
|
|
89
106
|
}
|
|
90
107
|
export interface GatewayWebhookDispatcher {
|