@coffeexdev/openclaw-sentinel 0.2.1 → 0.3.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 +26 -7
- package/dist/cli.js +0 -0
- package/dist/configSchema.js +0 -1
- package/dist/index.js +132 -19
- package/dist/strategies/base.d.ts +4 -1
- package/dist/strategies/websocket.js +13 -4
- package/dist/tool.d.ts +7 -1
- package/dist/tool.js +19 -3
- package/dist/toolSchema.d.ts +5 -0
- package/dist/toolSchema.js +8 -0
- package/dist/types.d.ts +19 -0
- package/dist/validator.js +5 -0
- package/dist/watcherManager.d.ts +13 -3
- package/dist/watcherManager.js +66 -13
- package/openclaw.plugin.json +0 -1
- package/package.json +13 -14
package/README.md
CHANGED
|
@@ -88,11 +88,24 @@ Use `sentinel_control`:
|
|
|
88
88
|
|
|
89
89
|
1. Sentinel evaluates conditions.
|
|
90
90
|
2. On match, it dispatches to `localDispatchBase + webhookPath`.
|
|
91
|
-
3.
|
|
92
|
-
4.
|
|
91
|
+
3. It also sends a notification message to each configured `deliveryTargets` destination (defaults to the current chat context when watcher is created from a channel session).
|
|
92
|
+
4. For `/hooks/sentinel`, the plugin route enqueues a system event (instruction prefix + structured JSON envelope) and requests heartbeat wake.
|
|
93
|
+
5. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
|
|
93
94
|
|
|
94
95
|
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
|
|
95
96
|
|
|
97
|
+
### `/hooks/sentinel` wake event format
|
|
98
|
+
|
|
99
|
+
Sentinel enqueues deterministic system-event text in this shape:
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
SENTINEL_TRIGGER: This system event came from /hooks/sentinel. Evaluate action policy, decide whether to notify configured deliveryTargets, and execute safe follow-up actions.
|
|
103
|
+
SENTINEL_ENVELOPE_JSON:
|
|
104
|
+
{ ...stable JSON envelope... }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Envelope keys: `watcherId`, `eventName`, `skillId` (if present), `matchedAt`, `payload` (bounded with truncation marker when clipped), `dedupeKey`, `correlationId`, `deliveryTargets` (if present), `source` (`route`, `plugin`).
|
|
108
|
+
|
|
96
109
|
## Why Sentinel
|
|
97
110
|
|
|
98
111
|
Sentinel runs watcher lifecycles inside the gateway with fixed strategies and declarative conditions.
|
|
@@ -143,11 +156,17 @@ It **does not** execute user-authored code from watcher definitions.
|
|
|
143
156
|
"ts": "${timestamp}"
|
|
144
157
|
}
|
|
145
158
|
},
|
|
146
|
-
"retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 }
|
|
159
|
+
"retry": { "maxRetries": 5, "baseMs": 250, "maxMs": 5000 },
|
|
160
|
+
"deliveryTargets": [
|
|
161
|
+
{ "channel": "telegram", "to": "5613673222" },
|
|
162
|
+
{ "channel": "discord", "to": "123456789012345678", "accountId": "main" }
|
|
163
|
+
]
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
```
|
|
150
167
|
|
|
168
|
+
`deliveryTargets` is optional. If omitted on `create`, Sentinel infers a default target from the current tool/session context (channel + current peer).
|
|
169
|
+
|
|
151
170
|
## Runtime controls
|
|
152
171
|
|
|
153
172
|
```json
|
|
@@ -185,10 +204,10 @@ openclaw-sentinel audit
|
|
|
185
204
|
## Development
|
|
186
205
|
|
|
187
206
|
```bash
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
207
|
+
pnpm install
|
|
208
|
+
pnpm run lint
|
|
209
|
+
pnpm test
|
|
210
|
+
pnpm run build
|
|
192
211
|
```
|
|
193
212
|
|
|
194
213
|
## License
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/configSchema.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { sentinelConfigSchema } from "./configSchema.js";
|
|
2
3
|
import { registerSentinelControl } from "./tool.js";
|
|
3
4
|
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
@@ -5,14 +6,9 @@ import { WatcherManager } from "./watcherManager.js";
|
|
|
5
6
|
const registeredWebhookPathsByRegistrar = new WeakMap();
|
|
6
7
|
const DEFAULT_HOOK_SESSION_KEY = "agent:main:main";
|
|
7
8
|
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
8
|
-
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!trimmed)
|
|
12
|
-
return DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
13
|
-
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
14
|
-
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
15
|
-
}
|
|
9
|
+
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
10
|
+
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
11
|
+
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.";
|
|
16
12
|
function trimText(value, max) {
|
|
17
13
|
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
18
14
|
}
|
|
@@ -22,22 +18,93 @@ function asString(value) {
|
|
|
22
18
|
const trimmed = value.trim();
|
|
23
19
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
24
20
|
}
|
|
21
|
+
function asIsoString(value) {
|
|
22
|
+
const text = asString(value);
|
|
23
|
+
if (!text)
|
|
24
|
+
return undefined;
|
|
25
|
+
const timestamp = Date.parse(text);
|
|
26
|
+
return Number.isNaN(timestamp) ? undefined : new Date(timestamp).toISOString();
|
|
27
|
+
}
|
|
25
28
|
function isRecord(value) {
|
|
26
29
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
27
30
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
function isDeliveryTarget(value) {
|
|
32
|
+
return (isRecord(value) &&
|
|
33
|
+
typeof value.channel === "string" &&
|
|
34
|
+
typeof value.to === "string" &&
|
|
35
|
+
(value.accountId === undefined || typeof value.accountId === "string"));
|
|
36
|
+
}
|
|
37
|
+
function normalizePath(path) {
|
|
38
|
+
const trimmed = path.trim();
|
|
39
|
+
if (!trimmed)
|
|
40
|
+
return DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
41
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
42
|
+
return withSlash.length > 1 && withSlash.endsWith("/") ? withSlash.slice(0, -1) : withSlash;
|
|
43
|
+
}
|
|
44
|
+
function clipPayloadForPrompt(value) {
|
|
45
|
+
const serialized = JSON.stringify(value);
|
|
46
|
+
if (!serialized)
|
|
47
|
+
return value;
|
|
48
|
+
if (serialized.length <= MAX_SENTINEL_PAYLOAD_JSON_CHARS)
|
|
49
|
+
return value;
|
|
50
|
+
const clipped = serialized.slice(0, MAX_SENTINEL_PAYLOAD_JSON_CHARS);
|
|
51
|
+
const overflow = serialized.length - clipped.length;
|
|
52
|
+
return {
|
|
53
|
+
__truncated: true,
|
|
54
|
+
truncatedChars: overflow,
|
|
55
|
+
maxChars: MAX_SENTINEL_PAYLOAD_JSON_CHARS,
|
|
56
|
+
preview: `${clipped}…`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function buildSentinelEventEnvelope(payload) {
|
|
60
|
+
const watcherId = asString(payload.watcherId) ??
|
|
61
|
+
(isRecord(payload.watcher) ? asString(payload.watcher.id) : undefined);
|
|
33
62
|
const eventName = asString(payload.eventName) ??
|
|
34
63
|
(isRecord(payload.event) ? asString(payload.event.name) : undefined);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
const skillId = asString(payload.skillId) ??
|
|
65
|
+
(isRecord(payload.watcher) ? asString(payload.watcher.skillId) : undefined) ??
|
|
66
|
+
undefined;
|
|
67
|
+
const matchedAt = asIsoString(payload.matchedAt) ?? asIsoString(payload.timestamp) ?? new Date().toISOString();
|
|
68
|
+
const rawPayload = payload.payload ??
|
|
69
|
+
(isRecord(payload.event) ? (payload.event.payload ?? payload.event.data) : undefined) ??
|
|
70
|
+
payload;
|
|
71
|
+
const boundedPayload = clipPayloadForPrompt(rawPayload);
|
|
72
|
+
const dedupeSeed = JSON.stringify({
|
|
73
|
+
watcherId: watcherId ?? null,
|
|
74
|
+
eventName: eventName ?? null,
|
|
75
|
+
matchedAt,
|
|
76
|
+
});
|
|
77
|
+
const generatedDedupe = createHash("sha256").update(dedupeSeed).digest("hex").slice(0, 16);
|
|
78
|
+
const dedupeKey = asString(payload.dedupeKey) ??
|
|
79
|
+
asString(payload.correlationId) ??
|
|
80
|
+
asString(payload.correlationID) ??
|
|
81
|
+
generatedDedupe;
|
|
82
|
+
const deliveryTargets = Array.isArray(payload.deliveryTargets)
|
|
83
|
+
? payload.deliveryTargets.filter(isDeliveryTarget)
|
|
84
|
+
: undefined;
|
|
85
|
+
const envelope = {
|
|
86
|
+
watcherId: watcherId ?? null,
|
|
87
|
+
eventName: eventName ?? null,
|
|
88
|
+
matchedAt,
|
|
89
|
+
payload: boundedPayload,
|
|
90
|
+
dedupeKey,
|
|
91
|
+
correlationId: dedupeKey,
|
|
92
|
+
source: {
|
|
93
|
+
route: DEFAULT_SENTINEL_WEBHOOK_PATH,
|
|
94
|
+
plugin: "openclaw-sentinel",
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
if (skillId)
|
|
98
|
+
envelope.skillId = skillId;
|
|
99
|
+
if (deliveryTargets && deliveryTargets.length > 0)
|
|
100
|
+
envelope.deliveryTargets = deliveryTargets;
|
|
101
|
+
return envelope;
|
|
102
|
+
}
|
|
103
|
+
function buildSentinelSystemEvent(payload) {
|
|
104
|
+
const envelope = buildSentinelEventEnvelope(payload);
|
|
105
|
+
const jsonEnvelope = JSON.stringify(envelope, null, 2);
|
|
106
|
+
const text = `${SENTINEL_EVENT_INSTRUCTION_PREFIX}\nSENTINEL_ENVELOPE_JSON:\n${jsonEnvelope}`;
|
|
107
|
+
return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
41
108
|
}
|
|
42
109
|
async function readSentinelWebhookPayload(req) {
|
|
43
110
|
const preParsed = req.body;
|
|
@@ -70,6 +137,47 @@ async function readSentinelWebhookPayload(req) {
|
|
|
70
137
|
}
|
|
71
138
|
return parsed;
|
|
72
139
|
}
|
|
140
|
+
async function notifyDeliveryTarget(api, target, message) {
|
|
141
|
+
switch (target.channel) {
|
|
142
|
+
case "telegram":
|
|
143
|
+
await api.runtime.channel.telegram.sendMessageTelegram(target.to, message, {
|
|
144
|
+
accountId: target.accountId,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
case "discord":
|
|
148
|
+
await api.runtime.channel.discord.sendMessageDiscord(target.to, message, {
|
|
149
|
+
accountId: target.accountId,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
case "slack":
|
|
153
|
+
await api.runtime.channel.slack.sendMessageSlack(target.to, message, {
|
|
154
|
+
accountId: target.accountId,
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
case "signal":
|
|
158
|
+
await api.runtime.channel.signal.sendMessageSignal(target.to, message, {
|
|
159
|
+
accountId: target.accountId,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
case "imessage":
|
|
163
|
+
await api.runtime.channel.imessage.sendMessageIMessage(target.to, message, {
|
|
164
|
+
accountId: target.accountId,
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
case "whatsapp":
|
|
168
|
+
await api.runtime.channel.whatsapp.sendMessageWhatsApp(target.to, message, {
|
|
169
|
+
accountId: target.accountId,
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
case "line":
|
|
173
|
+
await api.runtime.channel.line.sendMessageLine(target.to, message, {
|
|
174
|
+
accountId: target.accountId,
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
default:
|
|
178
|
+
throw new Error(`Unsupported delivery target channel: ${target.channel}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
73
181
|
export function createSentinelPlugin(overrides) {
|
|
74
182
|
const config = {
|
|
75
183
|
allowedHosts: [],
|
|
@@ -102,6 +210,11 @@ export function createSentinelPlugin(overrides) {
|
|
|
102
210
|
await manager.init();
|
|
103
211
|
},
|
|
104
212
|
register(api) {
|
|
213
|
+
manager.setNotifier({
|
|
214
|
+
async notify(target, message) {
|
|
215
|
+
await notifyDeliveryTarget(api, target, message);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
105
218
|
registerSentinelControl(api.registerTool.bind(api), manager);
|
|
106
219
|
const path = normalizePath(DEFAULT_SENTINEL_WEBHOOK_PATH);
|
|
107
220
|
if (!api.registerHttpRoute) {
|
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
import { WatcherDefinition } from "../types.js";
|
|
2
|
-
export
|
|
2
|
+
export interface StrategyCallbacks {
|
|
3
|
+
onConnect?: () => void;
|
|
4
|
+
}
|
|
5
|
+
export type StrategyHandler = (watcher: WatcherDefinition, onPayload: (payload: unknown) => Promise<void>, onError: (error: unknown) => Promise<void>, callbacks?: StrategyCallbacks) => Promise<() => void>;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
export const websocketStrategy = async (watcher, onPayload, onError) => {
|
|
2
|
+
export const websocketStrategy = async (watcher, onPayload, onError, callbacks) => {
|
|
3
3
|
let active = true;
|
|
4
4
|
let ws = null;
|
|
5
5
|
const connect = () => {
|
|
6
|
+
let pendingError = null;
|
|
7
|
+
let failureReported = false;
|
|
6
8
|
ws = new WebSocket(watcher.endpoint, { headers: watcher.headers });
|
|
9
|
+
ws.on("open", () => {
|
|
10
|
+
if (!active)
|
|
11
|
+
return;
|
|
12
|
+
callbacks?.onConnect?.();
|
|
13
|
+
});
|
|
7
14
|
ws.on("message", async (data) => {
|
|
8
15
|
if (!active)
|
|
9
16
|
return;
|
|
@@ -18,12 +25,14 @@ export const websocketStrategy = async (watcher, onPayload, onError) => {
|
|
|
18
25
|
ws.on("error", (err) => {
|
|
19
26
|
if (!active)
|
|
20
27
|
return;
|
|
21
|
-
|
|
28
|
+
pendingError = err instanceof Error ? err : new Error(String(err));
|
|
22
29
|
});
|
|
23
30
|
ws.on("close", (code) => {
|
|
24
|
-
if (!active)
|
|
31
|
+
if (!active || failureReported)
|
|
25
32
|
return;
|
|
26
|
-
|
|
33
|
+
failureReported = true;
|
|
34
|
+
const reason = pendingError?.message ?? `websocket closed: ${code}`;
|
|
35
|
+
void onError(new Error(reason));
|
|
27
36
|
});
|
|
28
37
|
};
|
|
29
38
|
connect();
|
package/dist/tool.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ import type { Static } from "@sinclair/typebox";
|
|
|
3
3
|
import { WatcherManager } from "./watcherManager.js";
|
|
4
4
|
import { SentinelToolSchema } from "./toolSchema.js";
|
|
5
5
|
export type SentinelToolParams = Static<typeof SentinelToolSchema>;
|
|
6
|
-
type
|
|
6
|
+
type SentinelToolContext = {
|
|
7
|
+
messageChannel?: string;
|
|
8
|
+
requesterSenderId?: string;
|
|
9
|
+
agentAccountId?: string;
|
|
10
|
+
sessionKey?: string;
|
|
11
|
+
};
|
|
12
|
+
type RegisterToolFn = (tool: AnyAgentTool | ((ctx: SentinelToolContext) => AnyAgentTool)) => void;
|
|
7
13
|
export declare function registerSentinelControl(registerTool: RegisterToolFn, manager: WatcherManager): void;
|
|
8
14
|
export {};
|
package/dist/tool.js
CHANGED
|
@@ -11,8 +11,22 @@ function validateParams(params) {
|
|
|
11
11
|
}
|
|
12
12
|
return candidate;
|
|
13
13
|
}
|
|
14
|
+
function inferDefaultDeliveryTargets(ctx) {
|
|
15
|
+
const channel = ctx.messageChannel?.trim();
|
|
16
|
+
if (!channel)
|
|
17
|
+
return [];
|
|
18
|
+
const fromSender = ctx.requesterSenderId?.trim();
|
|
19
|
+
if (fromSender) {
|
|
20
|
+
return [{ channel, to: fromSender, accountId: ctx.agentAccountId }];
|
|
21
|
+
}
|
|
22
|
+
const sessionPeer = ctx.sessionKey?.split(":").at(-1)?.trim();
|
|
23
|
+
if (sessionPeer) {
|
|
24
|
+
return [{ channel, to: sessionPeer, accountId: ctx.agentAccountId }];
|
|
25
|
+
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
14
28
|
export function registerSentinelControl(registerTool, manager) {
|
|
15
|
-
registerTool({
|
|
29
|
+
registerTool((ctx) => ({
|
|
16
30
|
name: "sentinel_control",
|
|
17
31
|
label: "sentinel_control",
|
|
18
32
|
description: "Create/manage sentinel watchers",
|
|
@@ -21,7 +35,9 @@ export function registerSentinelControl(registerTool, manager) {
|
|
|
21
35
|
const payload = validateParams(params);
|
|
22
36
|
switch (payload.action) {
|
|
23
37
|
case "create":
|
|
24
|
-
return jsonResult(await manager.create(payload.watcher
|
|
38
|
+
return jsonResult(await manager.create(payload.watcher, {
|
|
39
|
+
deliveryTargets: inferDefaultDeliveryTargets(ctx),
|
|
40
|
+
}));
|
|
25
41
|
case "enable":
|
|
26
42
|
return jsonResult(await manager.enable(payload.id ?? ""));
|
|
27
43
|
case "disable":
|
|
@@ -34,5 +50,5 @@ export function registerSentinelControl(registerTool, manager) {
|
|
|
34
50
|
return jsonResult(manager.list());
|
|
35
51
|
}
|
|
36
52
|
},
|
|
37
|
-
});
|
|
53
|
+
}));
|
|
38
54
|
}
|
package/dist/toolSchema.d.ts
CHANGED
|
@@ -29,6 +29,11 @@ export declare const SentinelToolSchema: import("@sinclair/typebox").TObject<{
|
|
|
29
29
|
maxMs: import("@sinclair/typebox").TNumber;
|
|
30
30
|
}>;
|
|
31
31
|
fireOnce: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
32
|
+
deliveryTargets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
|
|
33
|
+
channel: import("@sinclair/typebox").TString;
|
|
34
|
+
to: import("@sinclair/typebox").TString;
|
|
35
|
+
accountId: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
36
|
+
}>>>;
|
|
32
37
|
metadata: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
|
|
33
38
|
}>>;
|
|
34
39
|
}>;
|
package/dist/toolSchema.js
CHANGED
|
@@ -32,6 +32,11 @@ const RetryPolicySchema = Type.Object({
|
|
|
32
32
|
baseMs: Type.Number({ description: "Base delay in milliseconds for exponential backoff" }),
|
|
33
33
|
maxMs: Type.Number({ description: "Maximum delay cap in milliseconds" }),
|
|
34
34
|
});
|
|
35
|
+
const DeliveryTargetSchema = Type.Object({
|
|
36
|
+
channel: Type.String({ description: "Channel/provider id (e.g. telegram, discord)" }),
|
|
37
|
+
to: Type.String({ description: "Destination id within the channel" }),
|
|
38
|
+
accountId: Type.Optional(Type.String({ description: "Optional account id for multi-account channels" })),
|
|
39
|
+
}, { additionalProperties: false });
|
|
35
40
|
const WatcherSchema = Type.Object({
|
|
36
41
|
id: Type.String({ description: "Unique watcher identifier" }),
|
|
37
42
|
skillId: Type.String({ description: "ID of the skill that owns this watcher" }),
|
|
@@ -61,6 +66,9 @@ const WatcherSchema = Type.Object({
|
|
|
61
66
|
fire: FireConfigSchema,
|
|
62
67
|
retry: RetryPolicySchema,
|
|
63
68
|
fireOnce: Type.Optional(Type.Boolean({ description: "If true, the watcher disables itself after firing once" })),
|
|
69
|
+
deliveryTargets: Type.Optional(Type.Array(DeliveryTargetSchema, {
|
|
70
|
+
description: "Optional notification delivery targets. Defaults to the current chat/session context when omitted.",
|
|
71
|
+
})),
|
|
64
72
|
metadata: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Arbitrary key-value metadata" })),
|
|
65
73
|
}, { description: "Full watcher definition" });
|
|
66
74
|
export const SentinelToolSchema = Type.Object({
|
package/dist/types.d.ts
CHANGED
|
@@ -16,6 +16,11 @@ export interface RetryPolicy {
|
|
|
16
16
|
baseMs: number;
|
|
17
17
|
maxMs: number;
|
|
18
18
|
}
|
|
19
|
+
export interface DeliveryTarget {
|
|
20
|
+
channel: string;
|
|
21
|
+
to: string;
|
|
22
|
+
accountId?: string;
|
|
23
|
+
}
|
|
19
24
|
export interface WatcherDefinition {
|
|
20
25
|
id: string;
|
|
21
26
|
skillId: string;
|
|
@@ -32,6 +37,7 @@ export interface WatcherDefinition {
|
|
|
32
37
|
fire: FireConfig;
|
|
33
38
|
retry: RetryPolicy;
|
|
34
39
|
fireOnce?: boolean;
|
|
40
|
+
deliveryTargets?: DeliveryTarget[];
|
|
35
41
|
metadata?: Record<string, string>;
|
|
36
42
|
}
|
|
37
43
|
export interface WatcherRuntimeState {
|
|
@@ -39,9 +45,22 @@ export interface WatcherRuntimeState {
|
|
|
39
45
|
lastError?: string;
|
|
40
46
|
lastResponseAt?: string;
|
|
41
47
|
consecutiveFailures: number;
|
|
48
|
+
reconnectAttempts: number;
|
|
42
49
|
lastPayloadHash?: string;
|
|
43
50
|
lastPayload?: unknown;
|
|
44
51
|
lastEvaluated?: string;
|
|
52
|
+
lastConnectAt?: string;
|
|
53
|
+
lastDisconnectAt?: string;
|
|
54
|
+
lastDisconnectReason?: string;
|
|
55
|
+
lastDelivery?: {
|
|
56
|
+
attemptedAt: string;
|
|
57
|
+
successCount: number;
|
|
58
|
+
failureCount: number;
|
|
59
|
+
failures?: Array<{
|
|
60
|
+
target: DeliveryTarget;
|
|
61
|
+
error: string;
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
45
64
|
}
|
|
46
65
|
export interface SentinelStateFile {
|
|
47
66
|
watchers: WatcherDefinition[];
|
package/dist/validator.js
CHANGED
|
@@ -49,6 +49,11 @@ const WatcherSchema = Type.Object({
|
|
|
49
49
|
maxMs: Type.Integer({ minimum: 100, maximum: 300000 }),
|
|
50
50
|
}, { additionalProperties: false }),
|
|
51
51
|
fireOnce: Type.Optional(Type.Boolean()),
|
|
52
|
+
deliveryTargets: Type.Optional(Type.Array(Type.Object({
|
|
53
|
+
channel: Type.String({ minLength: 1 }),
|
|
54
|
+
to: Type.String({ minLength: 1 }),
|
|
55
|
+
accountId: Type.Optional(Type.String({ minLength: 1 })),
|
|
56
|
+
}, { additionalProperties: false }), { minItems: 1 })),
|
|
52
57
|
metadata: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
53
58
|
}, { additionalProperties: false });
|
|
54
59
|
function scanNoCodeLike(input, parentKey = "") {
|
package/dist/watcherManager.d.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
import { GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
|
|
1
|
+
import { DeliveryTarget, GatewayWebhookDispatcher, SentinelConfig, WatcherDefinition, WatcherRuntimeState } from "./types.js";
|
|
2
|
+
export declare const RESET_BACKOFF_AFTER_MS = 60000;
|
|
3
|
+
export interface WatcherCreateContext {
|
|
4
|
+
deliveryTargets?: DeliveryTarget[];
|
|
5
|
+
}
|
|
6
|
+
export interface WatcherNotifier {
|
|
7
|
+
notify(target: DeliveryTarget, message: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare const backoff: (base: number, max: number, failures: number) => number;
|
|
2
10
|
export declare class WatcherManager {
|
|
3
11
|
private config;
|
|
4
12
|
private dispatcher;
|
|
13
|
+
private notifier?;
|
|
5
14
|
private watchers;
|
|
6
15
|
private runtime;
|
|
7
16
|
private stops;
|
|
8
17
|
private retryTimers;
|
|
9
18
|
private statePath;
|
|
10
19
|
private webhookRegistration;
|
|
11
|
-
constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher);
|
|
20
|
+
constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher, notifier?: WatcherNotifier | undefined);
|
|
12
21
|
init(): Promise<void>;
|
|
13
|
-
create(input: unknown): Promise<WatcherDefinition>;
|
|
22
|
+
create(input: unknown, ctx?: WatcherCreateContext): Promise<WatcherDefinition>;
|
|
14
23
|
list(): WatcherDefinition[];
|
|
15
24
|
status(id: string): WatcherRuntimeState | undefined;
|
|
25
|
+
setNotifier(notifier: WatcherNotifier | undefined): void;
|
|
16
26
|
setWebhookRegistrationStatus(status: "ok" | "error", message?: string, path?: string): void;
|
|
17
27
|
enable(id: string): Promise<void>;
|
|
18
28
|
disable(id: string): Promise<void>;
|
package/dist/watcherManager.js
CHANGED
|
@@ -8,7 +8,8 @@ import { httpLongPollStrategy } from "./strategies/httpLongPoll.js";
|
|
|
8
8
|
import { sseStrategy } from "./strategies/sse.js";
|
|
9
9
|
import { websocketStrategy } from "./strategies/websocket.js";
|
|
10
10
|
import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
|
|
11
|
-
const
|
|
11
|
+
export const RESET_BACKOFF_AFTER_MS = 60_000;
|
|
12
|
+
export const backoff = (base, max, failures) => {
|
|
12
13
|
const raw = Math.min(max, base * 2 ** failures);
|
|
13
14
|
const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
|
|
14
15
|
return Math.max(base, raw + jitter);
|
|
@@ -16,6 +17,7 @@ const backoff = (base, max, failures) => {
|
|
|
16
17
|
export class WatcherManager {
|
|
17
18
|
config;
|
|
18
19
|
dispatcher;
|
|
20
|
+
notifier;
|
|
19
21
|
watchers = new Map();
|
|
20
22
|
runtime = {};
|
|
21
23
|
stops = new Map();
|
|
@@ -25,9 +27,10 @@ export class WatcherManager {
|
|
|
25
27
|
path: DEFAULT_SENTINEL_WEBHOOK_PATH,
|
|
26
28
|
status: "pending",
|
|
27
29
|
};
|
|
28
|
-
constructor(config, dispatcher) {
|
|
30
|
+
constructor(config, dispatcher, notifier) {
|
|
29
31
|
this.config = config;
|
|
30
32
|
this.dispatcher = dispatcher;
|
|
33
|
+
this.notifier = notifier;
|
|
31
34
|
this.statePath = config.stateFilePath ?? defaultStatePath();
|
|
32
35
|
}
|
|
33
36
|
async init() {
|
|
@@ -41,28 +44,33 @@ export class WatcherManager {
|
|
|
41
44
|
this.watchers.set(watcher.id, watcher);
|
|
42
45
|
}
|
|
43
46
|
catch (err) {
|
|
47
|
+
const prev = this.runtime[rawWatcher.id];
|
|
44
48
|
this.runtime[rawWatcher.id] = {
|
|
45
49
|
id: rawWatcher.id,
|
|
46
|
-
consecutiveFailures: (
|
|
50
|
+
consecutiveFailures: (prev?.consecutiveFailures ?? 0) + 1,
|
|
51
|
+
reconnectAttempts: prev?.reconnectAttempts ?? 0,
|
|
47
52
|
lastError: `Invalid persisted watcher: ${String(err?.message ?? err)}`,
|
|
48
|
-
lastResponseAt:
|
|
49
|
-
lastEvaluated:
|
|
50
|
-
lastPayloadHash:
|
|
51
|
-
lastPayload:
|
|
53
|
+
lastResponseAt: prev?.lastResponseAt,
|
|
54
|
+
lastEvaluated: prev?.lastEvaluated,
|
|
55
|
+
lastPayloadHash: prev?.lastPayloadHash,
|
|
56
|
+
lastPayload: prev?.lastPayload,
|
|
52
57
|
};
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
for (const watcher of this.list().filter((w) => w.enabled))
|
|
56
61
|
await this.startWatcher(watcher.id);
|
|
57
62
|
}
|
|
58
|
-
async create(input) {
|
|
63
|
+
async create(input, ctx) {
|
|
59
64
|
const watcher = validateWatcherDefinition(input);
|
|
65
|
+
if (!watcher.deliveryTargets?.length && ctx?.deliveryTargets?.length) {
|
|
66
|
+
watcher.deliveryTargets = ctx.deliveryTargets;
|
|
67
|
+
}
|
|
60
68
|
assertHostAllowed(this.config, watcher.endpoint);
|
|
61
69
|
assertWatcherLimits(this.config, this.list(), watcher);
|
|
62
70
|
if (this.watchers.has(watcher.id))
|
|
63
71
|
throw new Error(`Watcher already exists: ${watcher.id}`);
|
|
64
72
|
this.watchers.set(watcher.id, watcher);
|
|
65
|
-
this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0 };
|
|
73
|
+
this.runtime[watcher.id] = { id: watcher.id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
66
74
|
if (watcher.enabled)
|
|
67
75
|
await this.startWatcher(watcher.id);
|
|
68
76
|
await this.persist();
|
|
@@ -74,6 +82,9 @@ export class WatcherManager {
|
|
|
74
82
|
status(id) {
|
|
75
83
|
return this.runtime[id];
|
|
76
84
|
}
|
|
85
|
+
setNotifier(notifier) {
|
|
86
|
+
this.notifier = notifier;
|
|
87
|
+
}
|
|
77
88
|
setWebhookRegistrationStatus(status, message, path) {
|
|
78
89
|
this.webhookRegistration = {
|
|
79
90
|
path: path ?? this.webhookRegistration.path,
|
|
@@ -117,9 +128,19 @@ export class WatcherManager {
|
|
|
117
128
|
"http-long-poll": httpLongPollStrategy,
|
|
118
129
|
}[watcher.strategy];
|
|
119
130
|
const handleFailure = async (err) => {
|
|
120
|
-
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
|
|
131
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
132
|
+
rt.reconnectAttempts ??= 0;
|
|
133
|
+
const errMsg = String(err instanceof Error ? err.message : err);
|
|
134
|
+
rt.lastDisconnectAt = new Date().toISOString();
|
|
135
|
+
rt.lastDisconnectReason = errMsg;
|
|
136
|
+
rt.lastError = errMsg;
|
|
137
|
+
if (rt.lastConnectAt) {
|
|
138
|
+
const connectedMs = Date.now() - new Date(rt.lastConnectAt).getTime();
|
|
139
|
+
if (connectedMs >= RESET_BACKOFF_AFTER_MS) {
|
|
140
|
+
rt.consecutiveFailures = 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
121
143
|
rt.consecutiveFailures += 1;
|
|
122
|
-
rt.lastError = String(err?.message ?? err);
|
|
123
144
|
this.runtime[id] = rt;
|
|
124
145
|
if (this.retryTimers.has(id)) {
|
|
125
146
|
await this.persist();
|
|
@@ -127,6 +148,7 @@ export class WatcherManager {
|
|
|
127
148
|
}
|
|
128
149
|
const delay = backoff(watcher.retry.baseMs, watcher.retry.maxMs, rt.consecutiveFailures);
|
|
129
150
|
if (rt.consecutiveFailures <= watcher.retry.maxRetries && watcher.enabled) {
|
|
151
|
+
rt.reconnectAttempts += 1;
|
|
130
152
|
await this.stopWatcher(id);
|
|
131
153
|
const timer = setTimeout(() => {
|
|
132
154
|
this.retryTimers.delete(id);
|
|
@@ -137,7 +159,7 @@ export class WatcherManager {
|
|
|
137
159
|
await this.persist();
|
|
138
160
|
};
|
|
139
161
|
const stop = await handler(watcher, async (payload) => {
|
|
140
|
-
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0 };
|
|
162
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
141
163
|
const previousPayload = rt.lastPayload;
|
|
142
164
|
const matched = evaluateConditions(watcher.conditions, watcher.match, payload, previousPayload);
|
|
143
165
|
rt.lastPayloadHash = hashPayload(payload);
|
|
@@ -145,6 +167,7 @@ export class WatcherManager {
|
|
|
145
167
|
rt.lastResponseAt = new Date().toISOString();
|
|
146
168
|
rt.lastEvaluated = rt.lastResponseAt;
|
|
147
169
|
rt.consecutiveFailures = 0;
|
|
170
|
+
rt.reconnectAttempts = 0;
|
|
148
171
|
rt.lastError = undefined;
|
|
149
172
|
this.runtime[id] = rt;
|
|
150
173
|
if (matched) {
|
|
@@ -155,13 +178,43 @@ export class WatcherManager {
|
|
|
155
178
|
timestamp: new Date().toISOString(),
|
|
156
179
|
});
|
|
157
180
|
await this.dispatcher.dispatch(watcher.fire.webhookPath ?? DEFAULT_SENTINEL_WEBHOOK_PATH, body);
|
|
181
|
+
if (watcher.deliveryTargets?.length && this.notifier) {
|
|
182
|
+
const attemptedAt = new Date().toISOString();
|
|
183
|
+
const message = JSON.stringify(body);
|
|
184
|
+
const failures = [];
|
|
185
|
+
let successCount = 0;
|
|
186
|
+
await Promise.all(watcher.deliveryTargets.map(async (target) => {
|
|
187
|
+
try {
|
|
188
|
+
await this.notifier?.notify(target, message);
|
|
189
|
+
successCount += 1;
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
failures.push({
|
|
193
|
+
target,
|
|
194
|
+
error: String(err?.message ?? err),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}));
|
|
198
|
+
rt.lastDelivery = {
|
|
199
|
+
attemptedAt,
|
|
200
|
+
successCount,
|
|
201
|
+
failureCount: failures.length,
|
|
202
|
+
failures: failures.length > 0 ? failures : undefined,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
158
205
|
if (watcher.fireOnce) {
|
|
159
206
|
watcher.enabled = false;
|
|
160
207
|
await this.stopWatcher(id);
|
|
161
208
|
}
|
|
162
209
|
}
|
|
163
210
|
await this.persist();
|
|
164
|
-
}, handleFailure
|
|
211
|
+
}, handleFailure, {
|
|
212
|
+
onConnect: () => {
|
|
213
|
+
const rt = this.runtime[id] ?? { id, consecutiveFailures: 0, reconnectAttempts: 0 };
|
|
214
|
+
rt.lastConnectAt = new Date().toISOString();
|
|
215
|
+
this.runtime[id] = rt;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
165
218
|
this.stops.set(id, stop);
|
|
166
219
|
}
|
|
167
220
|
async stopWatcher(id) {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffeexdev/openclaw-sentinel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Secure declarative gateway-native watcher plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
|
@@ -29,18 +29,6 @@
|
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"build": "tsc -p tsconfig.json",
|
|
34
|
-
"test": "vitest run",
|
|
35
|
-
"lint": "tsc --noEmit",
|
|
36
|
-
"format": "oxfmt --write .",
|
|
37
|
-
"format:check": "oxfmt --check .",
|
|
38
|
-
"changeset": "changeset",
|
|
39
|
-
"version-packages": "changeset version",
|
|
40
|
-
"release": "changeset publish",
|
|
41
|
-
"prepack": "npm run build",
|
|
42
|
-
"prepare": "husky"
|
|
43
|
-
},
|
|
44
32
|
"dependencies": {
|
|
45
33
|
"@sinclair/typebox": "^0.34.48",
|
|
46
34
|
"re2-wasm": "^1.0.2",
|
|
@@ -74,5 +62,16 @@
|
|
|
74
62
|
"extensions": [
|
|
75
63
|
"dist/index.js"
|
|
76
64
|
]
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.json",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:smoke-install": "./scripts/smoke-install.sh",
|
|
70
|
+
"lint": "tsc --noEmit",
|
|
71
|
+
"format": "oxfmt --write .",
|
|
72
|
+
"format:check": "oxfmt --check .",
|
|
73
|
+
"changeset": "changeset",
|
|
74
|
+
"version-packages": "changeset version",
|
|
75
|
+
"release": "changeset publish"
|
|
77
76
|
}
|
|
78
|
-
}
|
|
77
|
+
}
|