@coffeexdev/openclaw-sentinel 0.1.8 → 0.2.1
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 +119 -98
- package/dist/configSchema.js +12 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +134 -2
- package/dist/types.d.ts +3 -1
- package/dist/types.js +1 -1
- package/dist/validator.js +10 -2
- package/dist/watcherManager.d.ts +2 -0
- package/dist/watcherManager.js +15 -1
- package/openclaw.plugin.json +10 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,16 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
Secure, declarative, gateway-native background watcher plugin for OpenClaw.
|
|
4
4
|
|
|
5
|
+
## Quick start (OpenClaw users)
|
|
6
|
+
|
|
7
|
+
If you only read one section, read this.
|
|
8
|
+
|
|
9
|
+
### 1) Install plugin
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
openclaw plugins install @coffeexdev/openclaw-sentinel
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### 2) Configure Sentinel
|
|
16
|
+
|
|
17
|
+
Add/update `~/.openclaw/openclaw.json`:
|
|
18
|
+
|
|
19
|
+
```json5
|
|
20
|
+
{
|
|
21
|
+
sentinel: {
|
|
22
|
+
// Required: watchers can only call endpoints on these hosts.
|
|
23
|
+
allowedHosts: ["api.github.com", "api.coingecko.com"],
|
|
24
|
+
|
|
25
|
+
// Default dispatch base for internal webhook callbacks.
|
|
26
|
+
localDispatchBase: "http://127.0.0.1:18789",
|
|
27
|
+
|
|
28
|
+
// Optional: where /hooks/sentinel events are queued in the LLM loop.
|
|
29
|
+
hookSessionKey: "agent:main:main",
|
|
30
|
+
|
|
31
|
+
// Optional: bearer token used for dispatch calls back to gateway.
|
|
32
|
+
// Set this to your gateway auth token when gateway auth is enabled.
|
|
33
|
+
// dispatchAuthToken: "<gateway-token>"
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 3) Restart gateway
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
openclaw gateway restart
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 4) Create your first watcher (`sentinel_control`)
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"action": "create",
|
|
49
|
+
"watcher": {
|
|
50
|
+
"id": "eth-price-watch",
|
|
51
|
+
"skillId": "skills.alerts",
|
|
52
|
+
"enabled": true,
|
|
53
|
+
"strategy": "http-poll",
|
|
54
|
+
"endpoint": "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd",
|
|
55
|
+
"intervalMs": 15000,
|
|
56
|
+
"match": "all",
|
|
57
|
+
"conditions": [{ "path": "ethereum.usd", "op": "gte", "value": 5000 }],
|
|
58
|
+
"fire": {
|
|
59
|
+
"webhookPath": "/hooks/sentinel",
|
|
60
|
+
"eventName": "eth_target_hit",
|
|
61
|
+
"payloadTemplate": {
|
|
62
|
+
"event": "${event.name}",
|
|
63
|
+
"price": "${payload.ethereum.usd}",
|
|
64
|
+
"ts": "${timestamp}"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"retry": { "maxRetries": 5, "baseMs": 500, "maxMs": 15000 },
|
|
68
|
+
"fireOnce": true
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 5) Verify
|
|
74
|
+
|
|
75
|
+
Use `sentinel_control`:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{ "action": "list" }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{ "action": "status", "id": "eth-price-watch" }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## What happens when a watcher fires?
|
|
88
|
+
|
|
89
|
+
1. Sentinel evaluates conditions.
|
|
90
|
+
2. On match, it dispatches to `localDispatchBase + webhookPath`.
|
|
91
|
+
3. For `/hooks/sentinel`, the plugin route enqueues a system event and requests heartbeat wake.
|
|
92
|
+
4. OpenClaw wakes and processes that event in the configured session (`hookSessionKey`, default `agent:main:main`).
|
|
93
|
+
|
|
94
|
+
The `/hooks/sentinel` route is auto-registered on plugin startup (idempotent).
|
|
95
|
+
|
|
5
96
|
## Why Sentinel
|
|
6
97
|
|
|
7
|
-
|
|
98
|
+
Sentinel runs watcher lifecycles inside the gateway with fixed strategies and declarative conditions.
|
|
8
99
|
It **does not** execute user-authored code from watcher definitions.
|
|
9
100
|
|
|
10
101
|
## Features
|
|
11
102
|
|
|
12
103
|
- Tool registration: `sentinel_control`
|
|
13
104
|
- actions: `create`, `enable`, `disable`, `remove`, `status`, `list`
|
|
14
|
-
- Strict schema validation (
|
|
105
|
+
- Strict schema validation (TypeBox, strict object checks) + code-like field/value rejection
|
|
15
106
|
- Strategies:
|
|
16
107
|
- `http-poll`
|
|
17
108
|
- `websocket`
|
|
@@ -21,50 +112,13 @@ It **does not** execute user-authored code from watcher definitions.
|
|
|
21
112
|
- `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `exists`, `absent`, `contains`, `matches`, `changed`
|
|
22
113
|
- Match mode: `all` / `any`
|
|
23
114
|
- Fire templating: substitution-only placeholders, non-Turing-complete
|
|
24
|
-
-
|
|
115
|
+
- Local webhook dispatch model (no outbound custom fire URL)
|
|
116
|
+
- Default callback route: `/hooks/sentinel` (auto-registered)
|
|
25
117
|
- Persistence: `~/.openclaw/sentinel-state.json`
|
|
26
118
|
- Resource limits and per-skill limits
|
|
27
119
|
- `allowedHosts` endpoint enforcement
|
|
28
120
|
- CLI surface: `list`, `status`, `enable`, `disable`, `audit`
|
|
29
121
|
|
|
30
|
-
## JSON Schema
|
|
31
|
-
|
|
32
|
-
Formal JSON Schema for sentinel config/watchers is available at:
|
|
33
|
-
|
|
34
|
-
- `schema/sentinel.schema.json`
|
|
35
|
-
|
|
36
|
-
You can validate a watcher config document (for example `.sentinel.json`) against this schema in CI or local tooling.
|
|
37
|
-
|
|
38
|
-
## Documentation
|
|
39
|
-
|
|
40
|
-
- [Usage Guide](docs/USAGE.md)
|
|
41
|
-
|
|
42
|
-
## Install
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
npm i @coffeexdev/openclaw-sentinel
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
## Quick usage
|
|
49
|
-
|
|
50
|
-
No hosts are allowed by default — you must explicitly configure `allowedHosts` for watchers to connect to any endpoint.
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
import { createSentinelPlugin } from "@coffeexdev/openclaw-sentinel";
|
|
54
|
-
|
|
55
|
-
const sentinel = createSentinelPlugin({
|
|
56
|
-
allowedHosts: ["api.github.com", "api.coingecko.com"],
|
|
57
|
-
localDispatchBase: "http://127.0.0.1:4389",
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
await sentinel.init();
|
|
61
|
-
sentinel.register({
|
|
62
|
-
registerTool(name, handler) {
|
|
63
|
-
// gateway tool registry hook
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
```
|
|
67
|
-
|
|
68
122
|
## Tool input example (`sentinel_control:create`)
|
|
69
123
|
|
|
70
124
|
```json
|
|
@@ -72,7 +126,7 @@ sentinel.register({
|
|
|
72
126
|
"action": "create",
|
|
73
127
|
"watcher": {
|
|
74
128
|
"id": "sentinel-alert",
|
|
75
|
-
"skillId": "skills.general-monitor"
|
|
129
|
+
"skillId": "skills.general-monitor",
|
|
76
130
|
"enabled": true,
|
|
77
131
|
"strategy": "http-poll",
|
|
78
132
|
"endpoint": "https://api.github.com/events",
|
|
@@ -80,7 +134,7 @@ sentinel.register({
|
|
|
80
134
|
"match": "any",
|
|
81
135
|
"conditions": [{ "path": "type", "op": "eq", "value": "PushEvent" }],
|
|
82
136
|
"fire": {
|
|
83
|
-
"webhookPath": "/
|
|
137
|
+
"webhookPath": "/hooks/sentinel",
|
|
84
138
|
"eventName": "sentinel_push",
|
|
85
139
|
"payloadTemplate": {
|
|
86
140
|
"watcher": "${watcher.id}",
|
|
@@ -94,72 +148,39 @@ sentinel.register({
|
|
|
94
148
|
}
|
|
95
149
|
```
|
|
96
150
|
|
|
97
|
-
##
|
|
151
|
+
## Runtime controls
|
|
98
152
|
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
openclaw-sentinel status <watcher-id>
|
|
102
|
-
openclaw-sentinel enable <watcher-id>
|
|
103
|
-
openclaw-sentinel disable <watcher-id>
|
|
104
|
-
openclaw-sentinel audit
|
|
153
|
+
```json
|
|
154
|
+
{ "action": "status", "id": "sentinel-alert" }
|
|
105
155
|
```
|
|
106
156
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
## Example scenarios
|
|
112
|
-
|
|
113
|
-
### Feed monitoring example
|
|
114
|
-
|
|
115
|
-
Watch API changes and fire internal webhook events for orchestration.
|
|
116
|
-
|
|
117
|
-
### Blockchain price watch
|
|
118
|
-
|
|
119
|
-
`http-poll` against `api.coingecko.com`, `gt/lte/changed` conditions, routed to local webhook.
|
|
120
|
-
|
|
121
|
-
### CI monitoring
|
|
122
|
-
|
|
123
|
-
`sse` or `http-long-poll` against approved CI host endpoint; fire standardized internal events.
|
|
124
|
-
|
|
125
|
-
## Security model
|
|
157
|
+
```json
|
|
158
|
+
{ "action": "disable", "id": "sentinel-alert" }
|
|
159
|
+
```
|
|
126
160
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
- Code-like fields/values rejected
|
|
131
|
-
- Allowed-host enforcement on endpoints
|
|
132
|
-
- Local-only fire routing through `localDispatchBase + webhookPath`
|
|
133
|
-
- Bounded retries with backoff
|
|
134
|
-
- Global/per-skill/condition limits
|
|
161
|
+
```json
|
|
162
|
+
{ "action": "remove", "id": "sentinel-alert" }
|
|
163
|
+
```
|
|
135
164
|
|
|
136
|
-
##
|
|
165
|
+
## JSON Schema
|
|
137
166
|
|
|
138
|
-
|
|
139
|
-
- Improve reconnect behavior and dedupe close+error failure handling.
|
|
140
|
-
- Add stronger circuit-breaker/backoff controls under bursty failures.
|
|
167
|
+
Formal JSON Schema for sentinel config/watchers:
|
|
141
168
|
|
|
142
|
-
|
|
143
|
-
- Add optional payload redaction or `do-not-persist-payload` mode so `lastPayload` does not store sensitive data on disk.
|
|
144
|
-
- Keep `changed` semantics while minimizing persisted sensitive fields.
|
|
169
|
+
- `schema/sentinel.schema.json`
|
|
145
170
|
|
|
146
|
-
|
|
147
|
-
- Add optional HMAC signing for internal webhook dispatch payloads in addition to bearer auth token support.
|
|
148
|
-
- Validate signature at receiver to prevent tampering in misconfigured local planes.
|
|
171
|
+
## Documentation
|
|
149
172
|
|
|
150
|
-
|
|
151
|
-
- Continue standardizing on `re2` with `re2-wasm` fallback.
|
|
152
|
-
- Document/centralize behavior for unsupported regex features (e.g., lookahead/backrefs).
|
|
173
|
+
- [Usage Guide](docs/USAGE.md)
|
|
153
174
|
|
|
154
|
-
|
|
155
|
-
- Add explicit `stopAll()` shutdown path in plugin lifecycle hooks for deterministic cleanup.
|
|
156
|
-
- Expand startup/reload behavior tests to ensure no orphaned watchers/timers.
|
|
175
|
+
## CLI
|
|
157
176
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
177
|
+
```bash
|
|
178
|
+
openclaw-sentinel list
|
|
179
|
+
openclaw-sentinel status <watcher-id>
|
|
180
|
+
openclaw-sentinel enable <watcher-id>
|
|
181
|
+
openclaw-sentinel disable <watcher-id>
|
|
182
|
+
openclaw-sentinel audit
|
|
183
|
+
```
|
|
163
184
|
|
|
164
185
|
## Development
|
|
165
186
|
|
package/dist/configSchema.js
CHANGED
|
@@ -10,6 +10,7 @@ const ConfigSchema = Type.Object({
|
|
|
10
10
|
allowedHosts: Type.Array(Type.String()),
|
|
11
11
|
localDispatchBase: Type.String({ minLength: 1 }),
|
|
12
12
|
dispatchAuthToken: Type.Optional(Type.String()),
|
|
13
|
+
hookSessionKey: Type.Optional(Type.String({ minLength: 1 })),
|
|
13
14
|
stateFilePath: Type.Optional(Type.String()),
|
|
14
15
|
limits: Type.Optional(LimitsSchema),
|
|
15
16
|
}, { additionalProperties: false });
|
|
@@ -21,6 +22,7 @@ function withDefaults(value) {
|
|
|
21
22
|
? value.localDispatchBase
|
|
22
23
|
: "http://127.0.0.1:18789",
|
|
23
24
|
dispatchAuthToken: typeof value.dispatchAuthToken === "string" ? value.dispatchAuthToken : undefined,
|
|
25
|
+
hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : "agent:main:main",
|
|
24
26
|
stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
|
|
25
27
|
limits: {
|
|
26
28
|
maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" ? limitsIn.maxWatchersTotal : 200,
|
|
@@ -93,6 +95,11 @@ export const sentinelConfigSchema = {
|
|
|
93
95
|
type: "string",
|
|
94
96
|
description: "Bearer token for authenticating webhook dispatch requests",
|
|
95
97
|
},
|
|
98
|
+
hookSessionKey: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description: "Session key used when /hooks/sentinel enqueues system events into the LLM loop",
|
|
101
|
+
default: "agent:main:main",
|
|
102
|
+
},
|
|
96
103
|
stateFilePath: {
|
|
97
104
|
type: "string",
|
|
98
105
|
description: "Custom path for the sentinel state persistence file",
|
|
@@ -141,6 +148,11 @@ export const sentinelConfigSchema = {
|
|
|
141
148
|
sensitive: true,
|
|
142
149
|
placeholder: "sk-...",
|
|
143
150
|
},
|
|
151
|
+
hookSessionKey: {
|
|
152
|
+
label: "Sentinel Hook Session Key",
|
|
153
|
+
help: "Session key that receives /hooks/sentinel callback events (default: agent:main:main)",
|
|
154
|
+
advanced: true,
|
|
155
|
+
},
|
|
144
156
|
stateFilePath: {
|
|
145
157
|
label: "State File Path",
|
|
146
158
|
help: "Custom path for sentinel state persistence file",
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
-
import { WatcherManager } from "./watcherManager.js";
|
|
3
2
|
import { SentinelConfig } from "./types.js";
|
|
3
|
+
import { WatcherManager } from "./watcherManager.js";
|
|
4
4
|
export declare function createSentinelPlugin(overrides?: Partial<SentinelConfig>): {
|
|
5
5
|
manager: WatcherManager;
|
|
6
6
|
init(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,81 @@
|
|
|
1
|
+
import { sentinelConfigSchema } from "./configSchema.js";
|
|
1
2
|
import { registerSentinelControl } from "./tool.js";
|
|
3
|
+
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
2
4
|
import { WatcherManager } from "./watcherManager.js";
|
|
3
|
-
|
|
5
|
+
const registeredWebhookPathsByRegistrar = new WeakMap();
|
|
6
|
+
const DEFAULT_HOOK_SESSION_KEY = "agent:main:main";
|
|
7
|
+
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 2000;
|
|
9
|
+
function normalizePath(path) {
|
|
10
|
+
const trimmed = path.trim();
|
|
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
|
+
}
|
|
16
|
+
function trimText(value, max) {
|
|
17
|
+
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
18
|
+
}
|
|
19
|
+
function asString(value) {
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
return undefined;
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
function buildSentinelSystemEvent(payload) {
|
|
29
|
+
const text = asString(payload.text) ?? asString(payload.message);
|
|
30
|
+
if (text)
|
|
31
|
+
return trimText(text, MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
32
|
+
const watcherId = asString(payload.watcherId);
|
|
33
|
+
const eventName = asString(payload.eventName) ??
|
|
34
|
+
(isRecord(payload.event) ? asString(payload.event.name) : undefined);
|
|
35
|
+
const labels = ["Sentinel webhook received"];
|
|
36
|
+
if (eventName)
|
|
37
|
+
labels.push(`event=${eventName}`);
|
|
38
|
+
if (watcherId)
|
|
39
|
+
labels.push(`watcher=${watcherId}`);
|
|
40
|
+
return trimText(labels.join(" "), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
41
|
+
}
|
|
42
|
+
async function readSentinelWebhookPayload(req) {
|
|
43
|
+
const preParsed = req.body;
|
|
44
|
+
if (isRecord(preParsed))
|
|
45
|
+
return preParsed;
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let total = 0;
|
|
48
|
+
for await (const chunk of req) {
|
|
49
|
+
const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
50
|
+
total += next.length;
|
|
51
|
+
if (total > MAX_SENTINEL_WEBHOOK_BODY_BYTES) {
|
|
52
|
+
throw new Error("Request body too large");
|
|
53
|
+
}
|
|
54
|
+
chunks.push(next);
|
|
55
|
+
}
|
|
56
|
+
if (chunks.length === 0)
|
|
57
|
+
return {};
|
|
58
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
59
|
+
if (!raw)
|
|
60
|
+
return {};
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error("Invalid JSON payload");
|
|
67
|
+
}
|
|
68
|
+
if (!isRecord(parsed)) {
|
|
69
|
+
throw new Error("Payload must be a JSON object");
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
4
73
|
export function createSentinelPlugin(overrides) {
|
|
5
74
|
const config = {
|
|
6
75
|
allowedHosts: [],
|
|
7
76
|
localDispatchBase: "http://127.0.0.1:18789",
|
|
8
77
|
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
78
|
+
hookSessionKey: DEFAULT_HOOK_SESSION_KEY,
|
|
9
79
|
limits: {
|
|
10
80
|
maxWatchersTotal: 200,
|
|
11
81
|
maxWatchersPerSkill: 20,
|
|
@@ -18,7 +88,7 @@ export function createSentinelPlugin(overrides) {
|
|
|
18
88
|
async dispatch(path, body) {
|
|
19
89
|
const headers = { "content-type": "application/json" };
|
|
20
90
|
if (config.dispatchAuthToken)
|
|
21
|
-
headers
|
|
91
|
+
headers.authorization = `Bearer ${config.dispatchAuthToken}`;
|
|
22
92
|
await fetch(`${config.localDispatchBase}${path}`, {
|
|
23
93
|
method: "POST",
|
|
24
94
|
headers,
|
|
@@ -33,6 +103,68 @@ export function createSentinelPlugin(overrides) {
|
|
|
33
103
|
},
|
|
34
104
|
register(api) {
|
|
35
105
|
registerSentinelControl(api.registerTool.bind(api), manager);
|
|
106
|
+
const path = normalizePath(DEFAULT_SENTINEL_WEBHOOK_PATH);
|
|
107
|
+
if (!api.registerHttpRoute) {
|
|
108
|
+
const msg = "registerHttpRoute API not available; default sentinel webhook route was not registered";
|
|
109
|
+
manager.setWebhookRegistrationStatus("error", msg, path);
|
|
110
|
+
api.logger?.error?.(`[openclaw-sentinel] ${msg}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const registrarKey = api.registerHttpRoute;
|
|
114
|
+
const registeredPaths = registeredWebhookPathsByRegistrar.get(registrarKey) ?? new Set();
|
|
115
|
+
registeredWebhookPathsByRegistrar.set(registrarKey, registeredPaths);
|
|
116
|
+
if (registeredPaths.has(path)) {
|
|
117
|
+
manager.setWebhookRegistrationStatus("ok", "Route already registered (idempotent)", path);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
api.registerHttpRoute({
|
|
122
|
+
path,
|
|
123
|
+
auth: "gateway",
|
|
124
|
+
match: "exact",
|
|
125
|
+
replaceExisting: true,
|
|
126
|
+
async handler(req, res) {
|
|
127
|
+
if (req.method !== "POST") {
|
|
128
|
+
res.writeHead(405, { "content-type": "application/json" });
|
|
129
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const payload = await readSentinelWebhookPayload(req);
|
|
134
|
+
const sessionKey = config.hookSessionKey ?? DEFAULT_HOOK_SESSION_KEY;
|
|
135
|
+
const text = buildSentinelSystemEvent(payload);
|
|
136
|
+
const enqueued = api.runtime.system.enqueueSystemEvent(text, { sessionKey });
|
|
137
|
+
api.runtime.system.requestHeartbeatNow({
|
|
138
|
+
reason: "hook:sentinel",
|
|
139
|
+
sessionKey,
|
|
140
|
+
});
|
|
141
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
142
|
+
res.end(JSON.stringify({
|
|
143
|
+
ok: true,
|
|
144
|
+
route: path,
|
|
145
|
+
sessionKey,
|
|
146
|
+
enqueued,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
const message = String(err?.message ?? err);
|
|
151
|
+
const badRequest = message.includes("Invalid JSON payload") ||
|
|
152
|
+
message.includes("Payload must be a JSON object");
|
|
153
|
+
const status = message.includes("too large") ? 413 : badRequest ? 400 : 500;
|
|
154
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
155
|
+
res.end(JSON.stringify({ error: message }));
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
registeredPaths.add(path);
|
|
160
|
+
manager.setWebhookRegistrationStatus("ok", "Route registered", path);
|
|
161
|
+
api.logger?.info?.(`[openclaw-sentinel] Registered default webhook route ${path}`);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const msg = `Failed to register default webhook route ${path}: ${String(err?.message ?? err)}`;
|
|
165
|
+
manager.setWebhookRegistrationStatus("error", msg, path);
|
|
166
|
+
api.logger?.error?.(`[openclaw-sentinel] ${msg}`);
|
|
167
|
+
}
|
|
36
168
|
},
|
|
37
169
|
};
|
|
38
170
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -5,8 +5,9 @@ export interface Condition {
|
|
|
5
5
|
op: Operator;
|
|
6
6
|
value?: unknown;
|
|
7
7
|
}
|
|
8
|
+
export declare const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
|
|
8
9
|
export interface FireConfig {
|
|
9
|
-
webhookPath
|
|
10
|
+
webhookPath?: string;
|
|
10
11
|
eventName: string;
|
|
11
12
|
payloadTemplate: Record<string, string | number | boolean | null>;
|
|
12
13
|
}
|
|
@@ -57,6 +58,7 @@ export interface SentinelConfig {
|
|
|
57
58
|
allowedHosts: string[];
|
|
58
59
|
localDispatchBase: string;
|
|
59
60
|
dispatchAuthToken?: string;
|
|
61
|
+
hookSessionKey?: string;
|
|
60
62
|
stateFilePath?: string;
|
|
61
63
|
limits: SentinelLimits;
|
|
62
64
|
}
|
package/dist/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const DEFAULT_SENTINEL_WEBHOOK_PATH = "/hooks/sentinel";
|
package/dist/validator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
3
4
|
const codeyKeyPattern = /(script|code|eval|handler|function|import|require)/i;
|
|
4
5
|
const codeyValuePattern = /(=>|\bfunction\b|\bimport\s+|\brequire\s*\(|\beval\s*\()/i;
|
|
5
6
|
const ConditionSchema = Type.Object({
|
|
@@ -38,7 +39,7 @@ const WatcherSchema = Type.Object({
|
|
|
38
39
|
match: Type.Union([Type.Literal("all"), Type.Literal("any")]),
|
|
39
40
|
conditions: Type.Array(ConditionSchema, { minItems: 1 }),
|
|
40
41
|
fire: Type.Object({
|
|
41
|
-
webhookPath: Type.String({ pattern: "^/" }),
|
|
42
|
+
webhookPath: Type.Optional(Type.String({ pattern: "^/" })),
|
|
42
43
|
eventName: Type.String({ minLength: 1 }),
|
|
43
44
|
payloadTemplate: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()])),
|
|
44
45
|
}, { additionalProperties: false }),
|
|
@@ -89,5 +90,12 @@ export function validateWatcherDefinition(input) {
|
|
|
89
90
|
catch {
|
|
90
91
|
throw new Error("Invalid watcher definition at /endpoint: Invalid URL");
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
+
const watcher = input;
|
|
94
|
+
return {
|
|
95
|
+
...watcher,
|
|
96
|
+
fire: {
|
|
97
|
+
...watcher.fire,
|
|
98
|
+
webhookPath: watcher.fire.webhookPath ?? DEFAULT_SENTINEL_WEBHOOK_PATH,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
93
101
|
}
|
package/dist/watcherManager.d.ts
CHANGED
|
@@ -7,11 +7,13 @@ export declare class WatcherManager {
|
|
|
7
7
|
private stops;
|
|
8
8
|
private retryTimers;
|
|
9
9
|
private statePath;
|
|
10
|
+
private webhookRegistration;
|
|
10
11
|
constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher);
|
|
11
12
|
init(): Promise<void>;
|
|
12
13
|
create(input: unknown): Promise<WatcherDefinition>;
|
|
13
14
|
list(): WatcherDefinition[];
|
|
14
15
|
status(id: string): WatcherRuntimeState | undefined;
|
|
16
|
+
setWebhookRegistrationStatus(status: "ok" | "error", message?: string, path?: string): void;
|
|
15
17
|
enable(id: string): Promise<void>;
|
|
16
18
|
disable(id: string): Promise<void>;
|
|
17
19
|
remove(id: string): Promise<void>;
|
package/dist/watcherManager.js
CHANGED
|
@@ -7,6 +7,7 @@ import { httpPollStrategy } from "./strategies/httpPoll.js";
|
|
|
7
7
|
import { httpLongPollStrategy } from "./strategies/httpLongPoll.js";
|
|
8
8
|
import { sseStrategy } from "./strategies/sse.js";
|
|
9
9
|
import { websocketStrategy } from "./strategies/websocket.js";
|
|
10
|
+
import { DEFAULT_SENTINEL_WEBHOOK_PATH, } from "./types.js";
|
|
10
11
|
const backoff = (base, max, failures) => {
|
|
11
12
|
const raw = Math.min(max, base * 2 ** failures);
|
|
12
13
|
const jitter = Math.floor(raw * 0.25 * (Math.random() * 2 - 1));
|
|
@@ -20,6 +21,10 @@ export class WatcherManager {
|
|
|
20
21
|
stops = new Map();
|
|
21
22
|
retryTimers = new Map();
|
|
22
23
|
statePath;
|
|
24
|
+
webhookRegistration = {
|
|
25
|
+
path: DEFAULT_SENTINEL_WEBHOOK_PATH,
|
|
26
|
+
status: "pending",
|
|
27
|
+
};
|
|
23
28
|
constructor(config, dispatcher) {
|
|
24
29
|
this.config = config;
|
|
25
30
|
this.dispatcher = dispatcher;
|
|
@@ -69,6 +74,14 @@ export class WatcherManager {
|
|
|
69
74
|
status(id) {
|
|
70
75
|
return this.runtime[id];
|
|
71
76
|
}
|
|
77
|
+
setWebhookRegistrationStatus(status, message, path) {
|
|
78
|
+
this.webhookRegistration = {
|
|
79
|
+
path: path ?? this.webhookRegistration.path,
|
|
80
|
+
status,
|
|
81
|
+
message,
|
|
82
|
+
updatedAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
72
85
|
async enable(id) {
|
|
73
86
|
const w = this.require(id);
|
|
74
87
|
w.enabled = true;
|
|
@@ -141,7 +154,7 @@ export class WatcherManager {
|
|
|
141
154
|
payload,
|
|
142
155
|
timestamp: new Date().toISOString(),
|
|
143
156
|
});
|
|
144
|
-
await this.dispatcher.dispatch(watcher.fire.webhookPath, body);
|
|
157
|
+
await this.dispatcher.dispatch(watcher.fire.webhookPath ?? DEFAULT_SENTINEL_WEBHOOK_PATH, body);
|
|
145
158
|
if (watcher.fireOnce) {
|
|
146
159
|
watcher.enabled = false;
|
|
147
160
|
await this.stopWatcher(id);
|
|
@@ -177,6 +190,7 @@ export class WatcherManager {
|
|
|
177
190
|
allowedHosts: this.config.allowedHosts,
|
|
178
191
|
limits: this.config.limits,
|
|
179
192
|
statePath: this.statePath,
|
|
193
|
+
webhookRegistration: this.webhookRegistration,
|
|
180
194
|
};
|
|
181
195
|
}
|
|
182
196
|
async persist() {
|
package/openclaw.plugin.json
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
"type": "string",
|
|
21
21
|
"description": "Bearer token for authenticating webhook dispatch requests"
|
|
22
22
|
},
|
|
23
|
+
"hookSessionKey": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Session key used when /hooks/sentinel enqueues system events into the LLM loop",
|
|
26
|
+
"default": "agent:main:main"
|
|
27
|
+
},
|
|
23
28
|
"stateFilePath": {
|
|
24
29
|
"type": "string",
|
|
25
30
|
"description": "Custom path for the sentinel state persistence file"
|
|
@@ -68,6 +73,11 @@
|
|
|
68
73
|
"sensitive": true,
|
|
69
74
|
"placeholder": "sk-..."
|
|
70
75
|
},
|
|
76
|
+
"hookSessionKey": {
|
|
77
|
+
"label": "Sentinel Hook Session Key",
|
|
78
|
+
"help": "Session key that receives /hooks/sentinel callback events (default: agent:main:main)",
|
|
79
|
+
"advanced": true
|
|
80
|
+
},
|
|
71
81
|
"stateFilePath": {
|
|
72
82
|
"label": "State File Path",
|
|
73
83
|
"help": "Custom path for sentinel state persistence file",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffeexdev/openclaw-sentinel",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Secure declarative gateway-native watcher plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"openclaw": {
|
|
74
74
|
"extensions": [
|
|
75
|
-
"
|
|
75
|
+
"dist/index.js"
|
|
76
76
|
]
|
|
77
77
|
}
|
|
78
78
|
}
|