@coffeexdev/openclaw-sentinel 0.1.7 → 0.2.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 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
- OpenClaw Sentinel runs watcher lifecycles inside the gateway using fixed strategies and declarative conditions.
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 (`zod.strict`) + code-like field/value rejection
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
- - Fire route: local internal webhook dispatch path (no outbound fire URL)
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": "/internal/sentinel/fire",
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
- ## CLI
151
+ ## Runtime controls
98
152
 
99
- ```bash
100
- openclaw-sentinel list
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
- ### One-shot watchers
108
-
109
- Set `"fireOnce": true` to automatically disable a watcher after its first matched event.
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
- - No dynamic code execution from watcher definitions
128
- - No dynamic imports/requires from definitions
129
- - Strict input schema, unknown field rejection
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
- ## Future Improvements
165
+ ## JSON Schema
137
166
 
138
- 1. **WebSocket/SSE resilience tuning**
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
- 2. **State persistence hardening**
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
- 3. **Dispatch integrity hardening**
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
- 4. **Regex safety simplification and compatibility policy**
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
- 5. **Lifecycle completeness**
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
- 6. **Test coverage expansion (priority)**
159
- - Add websocket reconnection/error-dedupe tests.
160
- - Add retry/backoff timing behavior tests.
161
- - Add state file permission assertions and payload persistence tests.
162
- - Add dispatch auth matrix tests (token on/off).
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
 
@@ -1,22 +1,79 @@
1
- import { z } from "zod";
2
- const limitsSchema = z.object({
3
- maxWatchersTotal: z.number().int().positive().default(200),
4
- maxWatchersPerSkill: z.number().int().positive().default(20),
5
- maxConditionsPerWatcher: z.number().int().positive().default(25),
6
- maxIntervalMsFloor: z.number().int().positive().default(1000),
7
- });
8
- const configZodSchema = z.object({
9
- allowedHosts: z.array(z.string()).default([]),
10
- localDispatchBase: z.string().url().default("http://127.0.0.1:18789"),
11
- dispatchAuthToken: z.string().optional(),
12
- stateFilePath: z.string().optional(),
13
- limits: limitsSchema.default({}),
14
- });
1
+ import { Type } from "@sinclair/typebox";
2
+ import { Value } from "@sinclair/typebox/value";
3
+ const LimitsSchema = Type.Object({
4
+ maxWatchersTotal: Type.Integer({ minimum: 1 }),
5
+ maxWatchersPerSkill: Type.Integer({ minimum: 1 }),
6
+ maxConditionsPerWatcher: Type.Integer({ minimum: 1 }),
7
+ maxIntervalMsFloor: Type.Integer({ minimum: 1 }),
8
+ }, { additionalProperties: false });
9
+ const ConfigSchema = Type.Object({
10
+ allowedHosts: Type.Array(Type.String()),
11
+ localDispatchBase: Type.String({ minLength: 1 }),
12
+ dispatchAuthToken: Type.Optional(Type.String()),
13
+ hookSessionKey: Type.Optional(Type.String({ minLength: 1 })),
14
+ stateFilePath: Type.Optional(Type.String()),
15
+ limits: Type.Optional(LimitsSchema),
16
+ }, { additionalProperties: false });
17
+ function withDefaults(value) {
18
+ const limitsIn = value.limits ?? {};
19
+ return {
20
+ allowedHosts: Array.isArray(value.allowedHosts) ? value.allowedHosts : [],
21
+ localDispatchBase: typeof value.localDispatchBase === "string" && value.localDispatchBase.length > 0
22
+ ? value.localDispatchBase
23
+ : "http://127.0.0.1:18789",
24
+ dispatchAuthToken: typeof value.dispatchAuthToken === "string" ? value.dispatchAuthToken : undefined,
25
+ hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : "agent:main:main",
26
+ stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
27
+ limits: {
28
+ maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" ? limitsIn.maxWatchersTotal : 200,
29
+ maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" ? limitsIn.maxWatchersPerSkill : 20,
30
+ maxConditionsPerWatcher: typeof limitsIn.maxConditionsPerWatcher === "number"
31
+ ? limitsIn.maxConditionsPerWatcher
32
+ : 25,
33
+ maxIntervalMsFloor: typeof limitsIn.maxIntervalMsFloor === "number" ? limitsIn.maxIntervalMsFloor : 1000,
34
+ },
35
+ };
36
+ }
37
+ function issue(path, message) {
38
+ const segments = path.replace(/^\//, "").split("/").filter(Boolean);
39
+ return {
40
+ path: segments,
41
+ message,
42
+ };
43
+ }
15
44
  export const sentinelConfigSchema = {
16
45
  safeParse: (value) => {
17
46
  if (value === undefined)
18
47
  return { success: true, data: undefined };
19
- return configZodSchema.safeParse(value);
48
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
49
+ return {
50
+ success: false,
51
+ error: { issues: [issue("/", "Config must be an object")] },
52
+ };
53
+ }
54
+ const candidate = withDefaults(value);
55
+ if (!Value.Check(ConfigSchema, candidate)) {
56
+ const first = [...Value.Errors(ConfigSchema, candidate)][0];
57
+ return {
58
+ success: false,
59
+ error: {
60
+ issues: [issue(String(first?.path || "/"), String(first?.message || "Invalid config"))],
61
+ },
62
+ };
63
+ }
64
+ // explicit URL validation (TypeBox format validators are not enabled by default)
65
+ try {
66
+ new URL(candidate.localDispatchBase);
67
+ }
68
+ catch {
69
+ return {
70
+ success: false,
71
+ error: {
72
+ issues: [issue("/localDispatchBase", "Invalid URL")],
73
+ },
74
+ };
75
+ }
76
+ return { success: true, data: candidate };
20
77
  },
21
78
  jsonSchema: {
22
79
  type: "object",
@@ -38,6 +95,11 @@ export const sentinelConfigSchema = {
38
95
  type: "string",
39
96
  description: "Bearer token for authenticating webhook dispatch requests",
40
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
+ },
41
103
  stateFilePath: {
42
104
  type: "string",
43
105
  description: "Custom path for the sentinel state persistence file",
@@ -86,6 +148,11 @@ export const sentinelConfigSchema = {
86
148
  sensitive: true,
87
149
  placeholder: "sk-...",
88
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
+ },
89
156
  stateFilePath: {
90
157
  label: "State File Path",
91
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
- import { sentinelConfigSchema } from "./configSchema.js";
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["authorization"] = `Bearer ${config.dispatchAuthToken}`;
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: string;
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
- return input;
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
  }
@@ -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>;
@@ -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() {
@@ -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.7",
3
+ "version": "0.2.0",
4
4
  "description": "Secure declarative gateway-native watcher plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "openclaw",