@coffeexdev/openclaw-sentinel 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -12
- package/dist/callbackEnvelope.js +1 -3
- package/dist/configSchema.js +80 -18
- package/dist/evaluator.js +1 -3
- package/dist/index.js +182 -34
- package/dist/strategies/httpLongPoll.js +29 -3
- package/dist/strategies/httpPoll.js +36 -5
- package/dist/strategies/sse.js +45 -13
- package/dist/strategies/websocket.js +40 -5
- package/dist/template.js +1 -3
- package/dist/toolSchema.js +16 -3
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +9 -0
- package/dist/validator.js +2 -1
- package/dist/watcherManager.d.ts +7 -0
- package/dist/watcherManager.js +57 -32
- package/openclaw.plugin.json +9 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,8 +59,9 @@ Add/update `~/.openclaw/openclaw.json`:
|
|
|
59
59
|
// Optional legacy alias for hookSessionPrefix (still supported).
|
|
60
60
|
// hookSessionKey: "agent:main:hooks:sentinel",
|
|
61
61
|
|
|
62
|
-
// Optional: bearer token
|
|
63
|
-
//
|
|
62
|
+
// Optional: explicit bearer token override for dispatch calls back to gateway.
|
|
63
|
+
// Sentinel auto-detects gateway auth token from runtime config when available,
|
|
64
|
+
// so manual token copy is usually no longer required.
|
|
64
65
|
// dispatchAuthToken: "<gateway-token>"
|
|
65
66
|
},
|
|
66
67
|
},
|
|
@@ -89,6 +90,13 @@ your config is using the old root-level shape. Move Sentinel config under:
|
|
|
89
90
|
|
|
90
91
|
Sentinel also logs a runtime warning when that legacy root key is still observable, but it never writes a root-level `sentinel` key.
|
|
91
92
|
|
|
93
|
+
### Hardening notes (0.6 minor)
|
|
94
|
+
|
|
95
|
+
- `hookSessionKey` remains supported but is deprecated. If both are present, `hookSessionPrefix` now wins.
|
|
96
|
+
- HTTP watcher strategies now set `redirect: "error"` to prevent host-allowlist bypass via redirects.
|
|
97
|
+
- Watcher IDs are now constrained to `^[A-Za-z0-9_-]{1,128}$`.
|
|
98
|
+
- `/hooks/sentinel` validates JSON `Content-Type` when provided and returns `415` for unsupported media types.
|
|
99
|
+
|
|
92
100
|
### 4) Create your first watcher (`sentinel_control`)
|
|
93
101
|
|
|
94
102
|
```json
|
|
@@ -146,7 +154,7 @@ Use `sentinel_control`:
|
|
|
146
154
|
1. Sentinel evaluates conditions.
|
|
147
155
|
2. On match, it dispatches a generic callback envelope (`type: "sentinel.callback"`) to `localDispatchBase + webhookPath`.
|
|
148
156
|
3. The envelope includes stable keys (`intent`, `context`, `watcher`, `trigger`, bounded `payload`, `deliveryTargets`, `deliveryContext`, `source`) so downstream agent behavior is workflow-agnostic.
|
|
149
|
-
4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event plus structured JSON envelope
|
|
157
|
+
4. For `/hooks/sentinel`, Sentinel enqueues an instruction-prefixed system event plus structured JSON envelope with a cron-tagged callback context, then requests an immediate `cron:sentinel-callback` wake (avoids heartbeat-poll prompting).
|
|
150
158
|
5. The hook route creates a **response-delivery contract** keyed by callback dedupe key, preserving original chat/session context (`deliveryContext`) and intended relay targets.
|
|
151
159
|
6. OpenClaw processes each callback in an isolated hook session: per-watcher by default, or grouped when `hookSessionGroup` / `fire.sessionGroup` is set. Shared global hook-session mode is intentionally not supported.
|
|
152
160
|
7. When hook-session LLM output arrives, Sentinel relays assistant-authored text to the original chat context. If no assistant output arrives before `hookResponseTimeoutMs`, optional fallback relay behavior is applied (`hookResponseFallbackMode`).
|
|
@@ -257,9 +265,16 @@ Global mode options:
|
|
|
257
265
|
|
|
258
266
|
```json5
|
|
259
267
|
{
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
plugins: {
|
|
269
|
+
entries: {
|
|
270
|
+
"openclaw-sentinel": {
|
|
271
|
+
enabled: true,
|
|
272
|
+
config: {
|
|
273
|
+
allowedHosts: ["api.github.com"],
|
|
274
|
+
notificationPayloadMode: "none",
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
263
278
|
},
|
|
264
279
|
}
|
|
265
280
|
```
|
|
@@ -268,9 +283,16 @@ Global mode options:
|
|
|
268
283
|
|
|
269
284
|
```json5
|
|
270
285
|
{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
286
|
+
plugins: {
|
|
287
|
+
entries: {
|
|
288
|
+
"openclaw-sentinel": {
|
|
289
|
+
enabled: true,
|
|
290
|
+
config: {
|
|
291
|
+
allowedHosts: ["api.github.com"],
|
|
292
|
+
notificationPayloadMode: "concise",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
274
296
|
},
|
|
275
297
|
}
|
|
276
298
|
```
|
|
@@ -279,9 +301,16 @@ Global mode options:
|
|
|
279
301
|
|
|
280
302
|
```json5
|
|
281
303
|
{
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
304
|
+
plugins: {
|
|
305
|
+
entries: {
|
|
306
|
+
"openclaw-sentinel": {
|
|
307
|
+
enabled: true,
|
|
308
|
+
config: {
|
|
309
|
+
allowedHosts: ["api.github.com"],
|
|
310
|
+
notificationPayloadMode: "debug",
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
285
314
|
},
|
|
286
315
|
}
|
|
287
316
|
```
|
package/dist/callbackEnvelope.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { SENTINEL_ORIGIN_ACCOUNT_METADATA, SENTINEL_ORIGIN_CHANNEL_METADATA, SENTINEL_ORIGIN_SESSION_KEY_METADATA, SENTINEL_ORIGIN_TARGET_METADATA, } from "./types.js";
|
|
3
3
|
import { renderTemplate } from "./template.js";
|
|
4
|
+
import { getPath } from "./utils.js";
|
|
4
5
|
const MAX_PAYLOAD_JSON_CHARS = 4000;
|
|
5
6
|
function toIntent(eventName) {
|
|
6
7
|
return (eventName
|
|
@@ -57,9 +58,6 @@ function buildDeliveryContextFromMetadata(watcher) {
|
|
|
57
58
|
}
|
|
58
59
|
return Object.keys(context).length > 0 ? context : undefined;
|
|
59
60
|
}
|
|
60
|
-
function getPath(obj, path) {
|
|
61
|
-
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
62
|
-
}
|
|
63
61
|
function getTemplateString(value, context) {
|
|
64
62
|
if (!value)
|
|
65
63
|
return undefined;
|
package/dist/configSchema.js
CHANGED
|
@@ -27,6 +27,42 @@ const ConfigSchema = Type.Object({
|
|
|
27
27
|
notificationPayloadMode: Type.Optional(NotificationPayloadModeSchema),
|
|
28
28
|
limits: Type.Optional(LimitsSchema),
|
|
29
29
|
}, { additionalProperties: false });
|
|
30
|
+
function trimToUndefined(value) {
|
|
31
|
+
if (typeof value !== "string")
|
|
32
|
+
return undefined;
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
35
|
+
}
|
|
36
|
+
function findInvalidNumericPath(input) {
|
|
37
|
+
const numericPaths = [
|
|
38
|
+
"hookRelayDedupeWindowMs",
|
|
39
|
+
"hookResponseTimeoutMs",
|
|
40
|
+
"hookResponseDedupeWindowMs",
|
|
41
|
+
];
|
|
42
|
+
for (const key of numericPaths) {
|
|
43
|
+
const value = input[key];
|
|
44
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
45
|
+
return `/${key}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const limits = input.limits;
|
|
49
|
+
if (limits && typeof limits === "object" && !Array.isArray(limits)) {
|
|
50
|
+
const limitsRecord = limits;
|
|
51
|
+
const limitKeys = [
|
|
52
|
+
"maxWatchersTotal",
|
|
53
|
+
"maxWatchersPerSkill",
|
|
54
|
+
"maxConditionsPerWatcher",
|
|
55
|
+
"maxIntervalMsFloor",
|
|
56
|
+
];
|
|
57
|
+
for (const key of limitKeys) {
|
|
58
|
+
const value = limitsRecord[key];
|
|
59
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
60
|
+
return `/limits/${key}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
30
66
|
function withDefaults(value) {
|
|
31
67
|
const limitsIn = value.limits ?? {};
|
|
32
68
|
return {
|
|
@@ -34,16 +70,23 @@ function withDefaults(value) {
|
|
|
34
70
|
localDispatchBase: typeof value.localDispatchBase === "string" && value.localDispatchBase.length > 0
|
|
35
71
|
? value.localDispatchBase
|
|
36
72
|
: "http://127.0.0.1:18789",
|
|
37
|
-
dispatchAuthToken:
|
|
73
|
+
dispatchAuthToken: trimToUndefined(value.dispatchAuthToken),
|
|
38
74
|
hookSessionKey: typeof value.hookSessionKey === "string" ? value.hookSessionKey : undefined,
|
|
39
75
|
hookSessionPrefix: typeof value.hookSessionPrefix === "string"
|
|
40
76
|
? value.hookSessionPrefix
|
|
41
77
|
: "agent:main:hooks:sentinel",
|
|
42
78
|
hookSessionGroup: typeof value.hookSessionGroup === "string" ? value.hookSessionGroup : undefined,
|
|
43
|
-
hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number"
|
|
44
|
-
|
|
79
|
+
hookRelayDedupeWindowMs: typeof value.hookRelayDedupeWindowMs === "number" &&
|
|
80
|
+
Number.isFinite(value.hookRelayDedupeWindowMs)
|
|
81
|
+
? value.hookRelayDedupeWindowMs
|
|
82
|
+
: 120000,
|
|
83
|
+
hookResponseTimeoutMs: typeof value.hookResponseTimeoutMs === "number" &&
|
|
84
|
+
Number.isFinite(value.hookResponseTimeoutMs)
|
|
85
|
+
? value.hookResponseTimeoutMs
|
|
86
|
+
: 30000,
|
|
45
87
|
hookResponseFallbackMode: value.hookResponseFallbackMode === "none" ? "none" : "concise",
|
|
46
|
-
hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number"
|
|
88
|
+
hookResponseDedupeWindowMs: typeof value.hookResponseDedupeWindowMs === "number" &&
|
|
89
|
+
Number.isFinite(value.hookResponseDedupeWindowMs)
|
|
47
90
|
? value.hookResponseDedupeWindowMs
|
|
48
91
|
: 120000,
|
|
49
92
|
stateFilePath: typeof value.stateFilePath === "string" ? value.stateFilePath : undefined,
|
|
@@ -53,12 +96,21 @@ function withDefaults(value) {
|
|
|
53
96
|
? "debug"
|
|
54
97
|
: "concise",
|
|
55
98
|
limits: {
|
|
56
|
-
maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number"
|
|
57
|
-
|
|
58
|
-
|
|
99
|
+
maxWatchersTotal: typeof limitsIn.maxWatchersTotal === "number" && Number.isFinite(limitsIn.maxWatchersTotal)
|
|
100
|
+
? limitsIn.maxWatchersTotal
|
|
101
|
+
: 200,
|
|
102
|
+
maxWatchersPerSkill: typeof limitsIn.maxWatchersPerSkill === "number" &&
|
|
103
|
+
Number.isFinite(limitsIn.maxWatchersPerSkill)
|
|
104
|
+
? limitsIn.maxWatchersPerSkill
|
|
105
|
+
: 20,
|
|
106
|
+
maxConditionsPerWatcher: typeof limitsIn.maxConditionsPerWatcher === "number" &&
|
|
107
|
+
Number.isFinite(limitsIn.maxConditionsPerWatcher)
|
|
59
108
|
? limitsIn.maxConditionsPerWatcher
|
|
60
109
|
: 25,
|
|
61
|
-
maxIntervalMsFloor: typeof limitsIn.maxIntervalMsFloor === "number"
|
|
110
|
+
maxIntervalMsFloor: typeof limitsIn.maxIntervalMsFloor === "number" &&
|
|
111
|
+
Number.isFinite(limitsIn.maxIntervalMsFloor)
|
|
112
|
+
? limitsIn.maxIntervalMsFloor
|
|
113
|
+
: 1000,
|
|
62
114
|
},
|
|
63
115
|
};
|
|
64
116
|
}
|
|
@@ -79,7 +131,17 @@ export const sentinelConfigSchema = {
|
|
|
79
131
|
error: { issues: [issue("/", "Config must be an object")] },
|
|
80
132
|
};
|
|
81
133
|
}
|
|
82
|
-
const
|
|
134
|
+
const source = value;
|
|
135
|
+
const invalidNumericPath = findInvalidNumericPath(source);
|
|
136
|
+
if (invalidNumericPath) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
error: {
|
|
140
|
+
issues: [issue(invalidNumericPath, "Expected a finite number")],
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const candidate = withDefaults(source);
|
|
83
145
|
if (!Value.Check(ConfigSchema, candidate)) {
|
|
84
146
|
const first = [...Value.Errors(ConfigSchema, candidate)][0];
|
|
85
147
|
return {
|
|
@@ -120,7 +182,7 @@ export const sentinelConfigSchema = {
|
|
|
120
182
|
},
|
|
121
183
|
dispatchAuthToken: {
|
|
122
184
|
type: "string",
|
|
123
|
-
description: "
|
|
185
|
+
description: "Optional bearer token override for webhook dispatch auth. Sentinel auto-detects gateway auth token when available.",
|
|
124
186
|
},
|
|
125
187
|
hookSessionKey: {
|
|
126
188
|
type: "string",
|
|
@@ -136,13 +198,13 @@ export const sentinelConfigSchema = {
|
|
|
136
198
|
description: "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session.",
|
|
137
199
|
},
|
|
138
200
|
hookRelayDedupeWindowMs: {
|
|
139
|
-
type: "
|
|
201
|
+
type: "integer",
|
|
140
202
|
minimum: 0,
|
|
141
203
|
description: "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
|
|
142
204
|
default: 120000,
|
|
143
205
|
},
|
|
144
206
|
hookResponseTimeoutMs: {
|
|
145
|
-
type: "
|
|
207
|
+
type: "integer",
|
|
146
208
|
minimum: 0,
|
|
147
209
|
description: "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
|
|
148
210
|
default: 30000,
|
|
@@ -154,7 +216,7 @@ export const sentinelConfigSchema = {
|
|
|
154
216
|
default: "concise",
|
|
155
217
|
},
|
|
156
218
|
hookResponseDedupeWindowMs: {
|
|
157
|
-
type: "
|
|
219
|
+
type: "integer",
|
|
158
220
|
minimum: 0,
|
|
159
221
|
description: "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
|
|
160
222
|
default: 120000,
|
|
@@ -175,22 +237,22 @@ export const sentinelConfigSchema = {
|
|
|
175
237
|
description: "Resource limits for watcher creation",
|
|
176
238
|
properties: {
|
|
177
239
|
maxWatchersTotal: {
|
|
178
|
-
type: "
|
|
240
|
+
type: "integer",
|
|
179
241
|
description: "Maximum total watchers across all skills",
|
|
180
242
|
default: 200,
|
|
181
243
|
},
|
|
182
244
|
maxWatchersPerSkill: {
|
|
183
|
-
type: "
|
|
245
|
+
type: "integer",
|
|
184
246
|
description: "Maximum watchers per skill",
|
|
185
247
|
default: 20,
|
|
186
248
|
},
|
|
187
249
|
maxConditionsPerWatcher: {
|
|
188
|
-
type: "
|
|
250
|
+
type: "integer",
|
|
189
251
|
description: "Maximum conditions per watcher definition",
|
|
190
252
|
default: 25,
|
|
191
253
|
},
|
|
192
254
|
maxIntervalMsFloor: {
|
|
193
|
-
type: "
|
|
255
|
+
type: "integer",
|
|
194
256
|
description: "Minimum allowed polling interval in milliseconds",
|
|
195
257
|
default: 1000,
|
|
196
258
|
},
|
|
@@ -209,7 +271,7 @@ export const sentinelConfigSchema = {
|
|
|
209
271
|
},
|
|
210
272
|
dispatchAuthToken: {
|
|
211
273
|
label: "Dispatch Auth Token",
|
|
212
|
-
help: "
|
|
274
|
+
help: "Optional override for webhook dispatch auth token. Sentinel auto-detects gateway auth token when available (or use SENTINEL_DISPATCH_TOKEN env var).",
|
|
213
275
|
sensitive: true,
|
|
214
276
|
placeholder: "sk-...",
|
|
215
277
|
},
|
package/dist/evaluator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { getPath } from "./utils.js";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
let cachedRegexCtor = null;
|
|
5
6
|
function getSafeRegexCtor() {
|
|
@@ -26,9 +27,6 @@ function getSafeRegexCtor() {
|
|
|
26
27
|
throw new Error("No safe regex engine available (re2/re2-wasm)");
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
|
-
function getPath(obj, path) {
|
|
30
|
-
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
31
|
-
}
|
|
32
30
|
function safeRegexTest(pattern, input) {
|
|
33
31
|
if (pattern.length > 256)
|
|
34
32
|
throw new Error("Regex pattern too long");
|
package/dist/index.js
CHANGED
|
@@ -8,9 +8,13 @@ const DEFAULT_HOOK_SESSION_PREFIX = "agent:main:hooks:sentinel";
|
|
|
8
8
|
const DEFAULT_RELAY_DEDUPE_WINDOW_MS = 120_000;
|
|
9
9
|
const DEFAULT_HOOK_RESPONSE_TIMEOUT_MS = 30_000;
|
|
10
10
|
const DEFAULT_HOOK_RESPONSE_FALLBACK_MODE = "concise";
|
|
11
|
+
const HOOK_RESPONSE_RELAY_CLEANUP_INTERVAL_MS = 60_000;
|
|
11
12
|
const MAX_SENTINEL_WEBHOOK_BODY_BYTES = 64 * 1024;
|
|
12
13
|
const MAX_SENTINEL_WEBHOOK_TEXT_CHARS = 8000;
|
|
13
14
|
const MAX_SENTINEL_PAYLOAD_JSON_CHARS = 2500;
|
|
15
|
+
const SENTINEL_CALLBACK_WAKE_REASON = "cron:sentinel-callback";
|
|
16
|
+
const SENTINEL_CALLBACK_CONTEXT_KEY = "cron:sentinel-callback";
|
|
17
|
+
const HEARTBEAT_ACK_TOKEN_PATTERN = /\bHEARTBEAT_OK\b/gi;
|
|
14
18
|
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
19
|
const SUPPORTED_DELIVERY_CHANNELS = new Set([
|
|
16
20
|
"telegram",
|
|
@@ -40,20 +44,48 @@ function asIsoString(value) {
|
|
|
40
44
|
function isRecord(value) {
|
|
41
45
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
42
46
|
}
|
|
47
|
+
function sniffGatewayDispatchToken(configRoot) {
|
|
48
|
+
if (!configRoot)
|
|
49
|
+
return undefined;
|
|
50
|
+
const auth = isRecord(configRoot.auth) ? configRoot.auth : undefined;
|
|
51
|
+
const gateway = isRecord(configRoot.gateway) ? configRoot.gateway : undefined;
|
|
52
|
+
const gatewayAuth = gateway && isRecord(gateway.auth) ? gateway.auth : undefined;
|
|
53
|
+
const server = isRecord(configRoot.server) ? configRoot.server : undefined;
|
|
54
|
+
const serverAuth = server && isRecord(server.auth) ? server.auth : undefined;
|
|
55
|
+
const candidates = [
|
|
56
|
+
auth?.token,
|
|
57
|
+
gateway?.authToken,
|
|
58
|
+
gatewayAuth?.token,
|
|
59
|
+
serverAuth?.token,
|
|
60
|
+
configRoot.gatewayAuthToken,
|
|
61
|
+
configRoot.authToken,
|
|
62
|
+
];
|
|
63
|
+
for (const candidate of candidates) {
|
|
64
|
+
const token = asString(candidate);
|
|
65
|
+
if (token)
|
|
66
|
+
return token;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
43
70
|
function resolveSentinelPluginConfig(api) {
|
|
44
71
|
const pluginConfig = isRecord(api.pluginConfig)
|
|
45
|
-
? api.pluginConfig
|
|
72
|
+
? { ...api.pluginConfig }
|
|
46
73
|
: {};
|
|
47
74
|
const configRoot = isRecord(api.config) ? api.config : undefined;
|
|
48
75
|
const legacyRootConfig = configRoot?.sentinel;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
76
|
+
let resolved = pluginConfig;
|
|
77
|
+
if (legacyRootConfig !== undefined) {
|
|
78
|
+
api.logger?.warn?.('[openclaw-sentinel] Detected deprecated root-level config key "sentinel". Move settings to plugins.entries.openclaw-sentinel.config. Root-level "sentinel" may fail with: Unrecognized key: "sentinel".');
|
|
79
|
+
if (isRecord(legacyRootConfig) && Object.keys(pluginConfig).length === 0) {
|
|
80
|
+
resolved = { ...legacyRootConfig };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!asString(resolved.dispatchAuthToken)) {
|
|
84
|
+
const sniffedToken = sniffGatewayDispatchToken(configRoot);
|
|
85
|
+
if (sniffedToken)
|
|
86
|
+
resolved.dispatchAuthToken = sniffedToken;
|
|
87
|
+
}
|
|
88
|
+
return resolved;
|
|
57
89
|
}
|
|
58
90
|
function isDeliveryTarget(value) {
|
|
59
91
|
return (isRecord(value) &&
|
|
@@ -303,12 +335,16 @@ function buildRelayMessage(envelope) {
|
|
|
303
335
|
if (contextSummary)
|
|
304
336
|
lines.push(contextSummary);
|
|
305
337
|
const text = lines.join("\n").trim();
|
|
306
|
-
return text.length > 0
|
|
338
|
+
return text.length > 0
|
|
339
|
+
? text
|
|
340
|
+
: "Sentinel callback received, but no assistant detail was generated.";
|
|
307
341
|
}
|
|
308
342
|
function normalizeAssistantRelayText(assistantTexts) {
|
|
309
343
|
if (!Array.isArray(assistantTexts) || assistantTexts.length === 0)
|
|
310
344
|
return undefined;
|
|
311
|
-
const parts = assistantTexts
|
|
345
|
+
const parts = assistantTexts
|
|
346
|
+
.map((value) => value.replace(HEARTBEAT_ACK_TOKEN_PATTERN, "").trim())
|
|
347
|
+
.filter(Boolean);
|
|
312
348
|
if (parts.length === 0)
|
|
313
349
|
return undefined;
|
|
314
350
|
return trimText(parts.join("\n\n"), MAX_SENTINEL_WEBHOOK_TEXT_CHARS);
|
|
@@ -327,9 +363,12 @@ function resolveHookResponseFallbackMode(config) {
|
|
|
327
363
|
return config.hookResponseFallbackMode === "none" ? "none" : DEFAULT_HOOK_RESPONSE_FALLBACK_MODE;
|
|
328
364
|
}
|
|
329
365
|
function buildIsolatedHookSessionKey(envelope, config) {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
366
|
+
const configuredPrefix = asString(config.hookSessionPrefix);
|
|
367
|
+
const legacyPrefix = asString(config.hookSessionKey);
|
|
368
|
+
const hasCustomPrefix = typeof configuredPrefix === "string" && configuredPrefix !== DEFAULT_HOOK_SESSION_PREFIX;
|
|
369
|
+
const rawPrefix = hasCustomPrefix
|
|
370
|
+
? configuredPrefix
|
|
371
|
+
: (legacyPrefix ?? configuredPrefix ?? DEFAULT_HOOK_SESSION_PREFIX);
|
|
333
372
|
const prefix = rawPrefix.replace(/:+$/g, "");
|
|
334
373
|
const group = asString(envelope.hookSessionGroup) ?? asString(config.hookSessionGroup);
|
|
335
374
|
if (group) {
|
|
@@ -343,10 +382,28 @@ function buildIsolatedHookSessionKey(envelope, config) {
|
|
|
343
382
|
}
|
|
344
383
|
return `${prefix}:event:unknown`;
|
|
345
384
|
}
|
|
385
|
+
function assertJsonContentType(req) {
|
|
386
|
+
const raw = req.headers["content-type"];
|
|
387
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
388
|
+
if (!header)
|
|
389
|
+
return;
|
|
390
|
+
const normalized = header.toLowerCase();
|
|
391
|
+
const isJson = normalized.includes("application/json") ||
|
|
392
|
+
normalized.includes("application/cloudevents+json") ||
|
|
393
|
+
normalized.includes("+json");
|
|
394
|
+
if (!isJson) {
|
|
395
|
+
throw new Error(`Unsupported Content-Type: ${header}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
346
398
|
async function readSentinelWebhookPayload(req) {
|
|
399
|
+
assertJsonContentType(req);
|
|
347
400
|
const preParsed = req.body;
|
|
348
|
-
if (
|
|
401
|
+
if (preParsed !== undefined) {
|
|
402
|
+
if (!isRecord(preParsed)) {
|
|
403
|
+
throw new Error("Payload must be a JSON object");
|
|
404
|
+
}
|
|
349
405
|
return preParsed;
|
|
406
|
+
}
|
|
350
407
|
const chunks = [];
|
|
351
408
|
let total = 0;
|
|
352
409
|
for await (const chunk of req) {
|
|
@@ -439,21 +496,16 @@ class HookResponseRelayManager {
|
|
|
439
496
|
recentByDedupe = new Map();
|
|
440
497
|
pendingByDedupe = new Map();
|
|
441
498
|
pendingQueueBySession = new Map();
|
|
499
|
+
cleanupTimer;
|
|
500
|
+
disposed = false;
|
|
442
501
|
constructor(config, api) {
|
|
443
502
|
this.config = config;
|
|
444
503
|
this.api = api;
|
|
445
504
|
}
|
|
446
505
|
register(args) {
|
|
506
|
+
this.cleanup();
|
|
447
507
|
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
448
508
|
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
509
|
const existingTs = this.recentByDedupe.get(args.dedupeKey);
|
|
458
510
|
if (dedupeWindowMs > 0 &&
|
|
459
511
|
typeof existingTs === "number" &&
|
|
@@ -470,6 +522,7 @@ class HookResponseRelayManager {
|
|
|
470
522
|
};
|
|
471
523
|
}
|
|
472
524
|
this.recentByDedupe.set(args.dedupeKey, now);
|
|
525
|
+
this.scheduleCleanup();
|
|
473
526
|
const timeoutMs = resolveHookResponseTimeoutMs(this.config);
|
|
474
527
|
const fallbackMode = resolveHookResponseFallbackMode(this.config);
|
|
475
528
|
if (args.relayTargets.length === 0) {
|
|
@@ -531,6 +584,64 @@ class HookResponseRelayManager {
|
|
|
531
584
|
return;
|
|
532
585
|
await this.completeWithMessage(pending, assistantMessage, "assistant");
|
|
533
586
|
}
|
|
587
|
+
dispose() {
|
|
588
|
+
if (this.disposed)
|
|
589
|
+
return;
|
|
590
|
+
this.disposed = true;
|
|
591
|
+
if (this.cleanupTimer) {
|
|
592
|
+
clearTimeout(this.cleanupTimer);
|
|
593
|
+
this.cleanupTimer = undefined;
|
|
594
|
+
}
|
|
595
|
+
for (const pending of this.pendingByDedupe.values()) {
|
|
596
|
+
if (pending.timer) {
|
|
597
|
+
clearTimeout(pending.timer);
|
|
598
|
+
pending.timer = undefined;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
this.pendingByDedupe.clear();
|
|
602
|
+
this.pendingQueueBySession.clear();
|
|
603
|
+
this.recentByDedupe.clear();
|
|
604
|
+
}
|
|
605
|
+
scheduleCleanup() {
|
|
606
|
+
if (this.disposed || this.cleanupTimer)
|
|
607
|
+
return;
|
|
608
|
+
this.cleanupTimer = setTimeout(() => {
|
|
609
|
+
this.cleanupTimer = undefined;
|
|
610
|
+
this.cleanup();
|
|
611
|
+
}, HOOK_RESPONSE_RELAY_CLEANUP_INTERVAL_MS);
|
|
612
|
+
this.cleanupTimer.unref?.();
|
|
613
|
+
}
|
|
614
|
+
cleanup(now = Date.now()) {
|
|
615
|
+
const dedupeWindowMs = resolveHookResponseDedupeWindowMs(this.config);
|
|
616
|
+
if (dedupeWindowMs > 0) {
|
|
617
|
+
for (const [key, ts] of this.recentByDedupe.entries()) {
|
|
618
|
+
if (now - ts > dedupeWindowMs) {
|
|
619
|
+
this.recentByDedupe.delete(key);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
for (const [key, pending] of this.pendingByDedupe.entries()) {
|
|
624
|
+
const gcAfterMs = Math.max(pending.timeoutMs, dedupeWindowMs, 1_000);
|
|
625
|
+
if (pending.state !== "pending" && now - pending.createdAt > gcAfterMs) {
|
|
626
|
+
this.pendingByDedupe.delete(key);
|
|
627
|
+
this.removeFromSessionQueue(pending.sessionKey, key);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (this.pendingByDedupe.size > 0 || this.recentByDedupe.size > 0) {
|
|
631
|
+
this.scheduleCleanup();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
removeFromSessionQueue(sessionKey, dedupeKey) {
|
|
635
|
+
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
636
|
+
if (!queue || queue.length === 0)
|
|
637
|
+
return;
|
|
638
|
+
const filtered = queue.filter((key) => key !== dedupeKey);
|
|
639
|
+
if (filtered.length === 0) {
|
|
640
|
+
this.pendingQueueBySession.delete(sessionKey);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
this.pendingQueueBySession.set(sessionKey, filtered);
|
|
644
|
+
}
|
|
534
645
|
popNextPendingDedupe(sessionKey) {
|
|
535
646
|
const queue = this.pendingQueueBySession.get(sessionKey);
|
|
536
647
|
if (!queue || queue.length === 0)
|
|
@@ -579,7 +690,7 @@ export function createSentinelPlugin(overrides) {
|
|
|
579
690
|
const config = {
|
|
580
691
|
allowedHosts: [],
|
|
581
692
|
localDispatchBase: "http://127.0.0.1:18789",
|
|
582
|
-
dispatchAuthToken: process.env.SENTINEL_DISPATCH_TOKEN,
|
|
693
|
+
dispatchAuthToken: asString(process.env.SENTINEL_DISPATCH_TOKEN),
|
|
583
694
|
hookSessionPrefix: DEFAULT_HOOK_SESSION_PREFIX,
|
|
584
695
|
hookRelayDedupeWindowMs: DEFAULT_RELAY_DEDUPE_WINDOW_MS,
|
|
585
696
|
hookResponseTimeoutMs: DEFAULT_HOOK_RESPONSE_TIMEOUT_MS,
|
|
@@ -599,11 +710,24 @@ export function createSentinelPlugin(overrides) {
|
|
|
599
710
|
const headers = { "content-type": "application/json" };
|
|
600
711
|
if (config.dispatchAuthToken)
|
|
601
712
|
headers.authorization = `Bearer ${config.dispatchAuthToken}`;
|
|
602
|
-
await fetch(`${config.localDispatchBase}${path}`, {
|
|
713
|
+
const response = await fetch(`${config.localDispatchBase}${path}`, {
|
|
603
714
|
method: "POST",
|
|
604
715
|
headers,
|
|
605
716
|
body: JSON.stringify(body),
|
|
606
717
|
});
|
|
718
|
+
if (!response.ok) {
|
|
719
|
+
let responseBody = "";
|
|
720
|
+
try {
|
|
721
|
+
responseBody = await response.text();
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
responseBody = "";
|
|
725
|
+
}
|
|
726
|
+
const details = responseBody ? ` body=${trimText(responseBody, 256)}` : "";
|
|
727
|
+
const error = new Error(`dispatch failed with status ${response.status}${details}`);
|
|
728
|
+
error.status = response.status;
|
|
729
|
+
throw error;
|
|
730
|
+
}
|
|
607
731
|
},
|
|
608
732
|
});
|
|
609
733
|
return {
|
|
@@ -615,11 +739,18 @@ export function createSentinelPlugin(overrides) {
|
|
|
615
739
|
const runtimeConfig = resolveSentinelPluginConfig(api);
|
|
616
740
|
if (Object.keys(runtimeConfig).length > 0)
|
|
617
741
|
Object.assign(config, runtimeConfig);
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
742
|
+
config.dispatchAuthToken = asString(config.dispatchAuthToken);
|
|
743
|
+
manager.setLogger(api.logger);
|
|
744
|
+
if (Array.isArray(config.allowedHosts) && config.allowedHosts.length === 0) {
|
|
745
|
+
api.logger?.warn?.("[openclaw-sentinel] allowedHosts is empty. Watcher creation will fail until at least one host is configured.");
|
|
746
|
+
}
|
|
747
|
+
const hasLegacyHookSessionKey = !!asString(config.hookSessionKey);
|
|
748
|
+
const hasCustomHookSessionPrefix = !!asString(config.hookSessionPrefix) &&
|
|
749
|
+
asString(config.hookSessionPrefix) !== DEFAULT_HOOK_SESSION_PREFIX;
|
|
750
|
+
if (hasLegacyHookSessionKey) {
|
|
751
|
+
api.logger?.warn?.(hasCustomHookSessionPrefix
|
|
752
|
+
? "[openclaw-sentinel] hookSessionKey is deprecated and ignored when hookSessionPrefix is set. Remove hookSessionKey from config."
|
|
753
|
+
: "[openclaw-sentinel] hookSessionKey is deprecated. Rename it to hookSessionPrefix.");
|
|
623
754
|
}
|
|
624
755
|
manager.setNotifier({
|
|
625
756
|
async notify(target, message) {
|
|
@@ -641,6 +772,12 @@ export function createSentinelPlugin(overrides) {
|
|
|
641
772
|
manager.setWebhookRegistrationStatus("ok", "Route already registered (idempotent)", path);
|
|
642
773
|
return;
|
|
643
774
|
}
|
|
775
|
+
const hookResponseRelayManager = new HookResponseRelayManager(config, api);
|
|
776
|
+
if (typeof api.on === "function") {
|
|
777
|
+
api.on("llm_output", (event, ctx) => {
|
|
778
|
+
void hookResponseRelayManager.handleLlmOutput(ctx?.sessionKey, event.assistantTexts);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
644
781
|
try {
|
|
645
782
|
api.registerHttpRoute({
|
|
646
783
|
path,
|
|
@@ -658,9 +795,12 @@ export function createSentinelPlugin(overrides) {
|
|
|
658
795
|
const envelope = buildSentinelEventEnvelope(payload);
|
|
659
796
|
const sessionKey = buildIsolatedHookSessionKey(envelope, config);
|
|
660
797
|
const text = buildSentinelSystemEvent(envelope);
|
|
661
|
-
const enqueued = api.runtime.system.enqueueSystemEvent(text, {
|
|
798
|
+
const enqueued = api.runtime.system.enqueueSystemEvent(text, {
|
|
799
|
+
sessionKey,
|
|
800
|
+
contextKey: SENTINEL_CALLBACK_CONTEXT_KEY,
|
|
801
|
+
});
|
|
662
802
|
api.runtime.system.requestHeartbeatNow({
|
|
663
|
-
reason:
|
|
803
|
+
reason: SENTINEL_CALLBACK_WAKE_REASON,
|
|
664
804
|
sessionKey,
|
|
665
805
|
});
|
|
666
806
|
const relayTargets = inferRelayTargets(payload, envelope);
|
|
@@ -683,7 +823,14 @@ export function createSentinelPlugin(overrides) {
|
|
|
683
823
|
const message = String(err?.message ?? err);
|
|
684
824
|
const badRequest = message.includes("Invalid JSON payload") ||
|
|
685
825
|
message.includes("Payload must be a JSON object");
|
|
686
|
-
const
|
|
826
|
+
const unsupportedMediaType = message.includes("Unsupported Content-Type");
|
|
827
|
+
const status = message.includes("too large")
|
|
828
|
+
? 413
|
|
829
|
+
: unsupportedMediaType
|
|
830
|
+
? 415
|
|
831
|
+
: badRequest
|
|
832
|
+
? 400
|
|
833
|
+
: 500;
|
|
687
834
|
res.writeHead(status, { "content-type": "application/json" });
|
|
688
835
|
res.end(JSON.stringify({ error: message }));
|
|
689
836
|
}
|
|
@@ -694,6 +841,7 @@ export function createSentinelPlugin(overrides) {
|
|
|
694
841
|
api.logger?.info?.(`[openclaw-sentinel] Registered default webhook route ${path}`);
|
|
695
842
|
}
|
|
696
843
|
catch (err) {
|
|
844
|
+
hookResponseRelayManager.dispose();
|
|
697
845
|
const msg = `Failed to register default webhook route ${path}: ${String(err?.message ?? err)}`;
|
|
698
846
|
manager.setWebhookRegistrationStatus("error", msg, path);
|
|
699
847
|
api.logger?.error?.(`[openclaw-sentinel] ${msg}`);
|
|
@@ -709,8 +857,8 @@ const sentinelPlugin = {
|
|
|
709
857
|
configSchema: sentinelConfigSchema,
|
|
710
858
|
register(api) {
|
|
711
859
|
const plugin = createSentinelPlugin(api.pluginConfig);
|
|
712
|
-
void plugin.init();
|
|
713
860
|
plugin.register(api);
|
|
861
|
+
void plugin.init();
|
|
714
862
|
},
|
|
715
863
|
};
|
|
716
864
|
export const register = sentinelPlugin.register.bind(sentinelPlugin);
|
|
@@ -1,29 +1,55 @@
|
|
|
1
|
+
function isAbortError(err) {
|
|
2
|
+
if (!(err instanceof Error))
|
|
3
|
+
return false;
|
|
4
|
+
return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
|
|
5
|
+
}
|
|
1
6
|
export const httpLongPollStrategy = async (watcher, onPayload, onError) => {
|
|
2
7
|
let active = true;
|
|
8
|
+
let inFlightAbort;
|
|
3
9
|
const loop = async () => {
|
|
4
10
|
while (active) {
|
|
5
11
|
try {
|
|
12
|
+
inFlightAbort = new AbortController();
|
|
6
13
|
const response = await fetch(watcher.endpoint, {
|
|
7
14
|
method: watcher.method ?? "GET",
|
|
8
15
|
headers: watcher.headers,
|
|
9
16
|
body: watcher.body,
|
|
10
|
-
signal: AbortSignal.
|
|
17
|
+
signal: AbortSignal.any([
|
|
18
|
+
inFlightAbort.signal,
|
|
19
|
+
AbortSignal.timeout(watcher.timeoutMs ?? 60000),
|
|
20
|
+
]),
|
|
21
|
+
redirect: "error",
|
|
11
22
|
});
|
|
12
23
|
if (!response.ok)
|
|
13
24
|
throw new Error(`http-long-poll non-2xx: ${response.status}`);
|
|
14
25
|
const contentType = response.headers.get("content-type") ?? "";
|
|
15
|
-
if (!contentType.toLowerCase().includes("json"))
|
|
26
|
+
if (!contentType.toLowerCase().includes("json")) {
|
|
16
27
|
throw new Error(`http-long-poll expected JSON, got: ${contentType || "unknown"}`);
|
|
17
|
-
|
|
28
|
+
}
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = await response.json();
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
throw new Error(`http-long-poll invalid JSON response: ${String(err?.message ?? err)}`);
|
|
35
|
+
}
|
|
36
|
+
await onPayload(payload);
|
|
18
37
|
}
|
|
19
38
|
catch (err) {
|
|
39
|
+
if (!active && isAbortError(err))
|
|
40
|
+
return;
|
|
20
41
|
await onError(err);
|
|
21
42
|
return;
|
|
22
43
|
}
|
|
44
|
+
finally {
|
|
45
|
+
inFlightAbort = undefined;
|
|
46
|
+
}
|
|
23
47
|
}
|
|
24
48
|
};
|
|
25
49
|
void loop();
|
|
26
50
|
return async () => {
|
|
27
51
|
active = false;
|
|
52
|
+
inFlightAbort?.abort();
|
|
53
|
+
inFlightAbort = undefined;
|
|
28
54
|
};
|
|
29
55
|
};
|
|
@@ -1,35 +1,66 @@
|
|
|
1
|
+
function isAbortError(err) {
|
|
2
|
+
if (!(err instanceof Error))
|
|
3
|
+
return false;
|
|
4
|
+
return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
|
|
5
|
+
}
|
|
1
6
|
export const httpPollStrategy = async (watcher, onPayload, onError) => {
|
|
2
7
|
const interval = watcher.intervalMs ?? 30000;
|
|
3
8
|
let active = true;
|
|
9
|
+
let timer;
|
|
10
|
+
let inFlightAbort;
|
|
4
11
|
const tick = async () => {
|
|
5
12
|
if (!active)
|
|
6
13
|
return;
|
|
7
14
|
try {
|
|
15
|
+
inFlightAbort = new AbortController();
|
|
8
16
|
const response = await fetch(watcher.endpoint, {
|
|
9
17
|
method: watcher.method ?? "GET",
|
|
10
18
|
headers: watcher.headers,
|
|
11
19
|
body: watcher.body,
|
|
12
|
-
signal: AbortSignal.
|
|
20
|
+
signal: AbortSignal.any([
|
|
21
|
+
inFlightAbort.signal,
|
|
22
|
+
AbortSignal.timeout(watcher.timeoutMs ?? 15000),
|
|
23
|
+
]),
|
|
24
|
+
redirect: "error",
|
|
13
25
|
});
|
|
14
26
|
if (!response.ok)
|
|
15
27
|
throw new Error(`http-poll non-2xx: ${response.status}`);
|
|
16
28
|
const contentType = response.headers.get("content-type") ?? "";
|
|
17
|
-
if (!contentType.toLowerCase().includes("json"))
|
|
29
|
+
if (!contentType.toLowerCase().includes("json")) {
|
|
18
30
|
throw new Error(`http-poll expected JSON, got: ${contentType || "unknown"}`);
|
|
19
|
-
|
|
31
|
+
}
|
|
32
|
+
let payload;
|
|
33
|
+
try {
|
|
34
|
+
payload = await response.json();
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new Error(`http-poll invalid JSON response: ${String(err?.message ?? err)}`);
|
|
38
|
+
}
|
|
20
39
|
await onPayload(payload);
|
|
21
40
|
}
|
|
22
41
|
catch (err) {
|
|
42
|
+
if (!active && isAbortError(err))
|
|
43
|
+
return;
|
|
23
44
|
await onError(err);
|
|
24
45
|
return;
|
|
25
46
|
}
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
finally {
|
|
48
|
+
inFlightAbort = undefined;
|
|
49
|
+
}
|
|
50
|
+
if (active) {
|
|
51
|
+
timer = setTimeout(() => {
|
|
28
52
|
void tick();
|
|
29
53
|
}, interval);
|
|
54
|
+
}
|
|
30
55
|
};
|
|
31
56
|
void tick();
|
|
32
57
|
return async () => {
|
|
33
58
|
active = false;
|
|
59
|
+
if (timer) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = undefined;
|
|
62
|
+
}
|
|
63
|
+
inFlightAbort?.abort();
|
|
64
|
+
inFlightAbort = undefined;
|
|
34
65
|
};
|
|
35
66
|
};
|
package/dist/strategies/sse.js
CHANGED
|
@@ -1,41 +1,73 @@
|
|
|
1
|
+
function isAbortError(err) {
|
|
2
|
+
if (!(err instanceof Error))
|
|
3
|
+
return false;
|
|
4
|
+
return err.name === "AbortError" || err.message.toLowerCase().includes("aborted");
|
|
5
|
+
}
|
|
1
6
|
export const sseStrategy = async (watcher, onPayload, onError) => {
|
|
2
7
|
let active = true;
|
|
8
|
+
let inFlightAbort;
|
|
9
|
+
let sleepTimer;
|
|
10
|
+
const wait = (ms) => new Promise((resolve) => {
|
|
11
|
+
sleepTimer = setTimeout(() => {
|
|
12
|
+
sleepTimer = undefined;
|
|
13
|
+
resolve();
|
|
14
|
+
}, ms);
|
|
15
|
+
});
|
|
3
16
|
const loop = async () => {
|
|
4
17
|
while (active) {
|
|
5
18
|
try {
|
|
19
|
+
inFlightAbort = new AbortController();
|
|
6
20
|
const response = await fetch(watcher.endpoint, {
|
|
7
21
|
headers: { Accept: "text/event-stream", ...(watcher.headers ?? {}) },
|
|
8
|
-
signal: AbortSignal.
|
|
22
|
+
signal: AbortSignal.any([
|
|
23
|
+
inFlightAbort.signal,
|
|
24
|
+
AbortSignal.timeout(watcher.timeoutMs ?? 60000),
|
|
25
|
+
]),
|
|
26
|
+
redirect: "error",
|
|
9
27
|
});
|
|
10
28
|
if (!response.ok)
|
|
11
29
|
throw new Error(`sse non-2xx: ${response.status}`);
|
|
12
30
|
const contentType = response.headers.get("content-type") ?? "";
|
|
13
|
-
if (!contentType.toLowerCase().includes("text/event-stream"))
|
|
31
|
+
if (!contentType.toLowerCase().includes("text/event-stream")) {
|
|
14
32
|
throw new Error(`sse expected text/event-stream, got: ${contentType || "unknown"}`);
|
|
33
|
+
}
|
|
15
34
|
const text = await response.text();
|
|
16
35
|
for (const line of text.split("\n")) {
|
|
17
|
-
if (line.startsWith("data:"))
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
36
|
+
if (!line.startsWith("data:"))
|
|
37
|
+
continue;
|
|
38
|
+
const raw = line.slice(5).trim();
|
|
39
|
+
if (!raw)
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
await onPayload(JSON.parse(raw));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
await onPayload({ message: raw });
|
|
27
46
|
}
|
|
28
47
|
}
|
|
29
|
-
|
|
48
|
+
if (active) {
|
|
49
|
+
await wait(watcher.intervalMs ?? 1000);
|
|
50
|
+
}
|
|
30
51
|
}
|
|
31
52
|
catch (err) {
|
|
53
|
+
if (!active && isAbortError(err))
|
|
54
|
+
return;
|
|
32
55
|
await onError(err);
|
|
33
56
|
return;
|
|
34
57
|
}
|
|
58
|
+
finally {
|
|
59
|
+
inFlightAbort = undefined;
|
|
60
|
+
}
|
|
35
61
|
}
|
|
36
62
|
};
|
|
37
63
|
void loop();
|
|
38
64
|
return async () => {
|
|
39
65
|
active = false;
|
|
66
|
+
inFlightAbort?.abort();
|
|
67
|
+
inFlightAbort = undefined;
|
|
68
|
+
if (sleepTimer) {
|
|
69
|
+
clearTimeout(sleepTimer);
|
|
70
|
+
sleepTimer = undefined;
|
|
71
|
+
}
|
|
40
72
|
};
|
|
41
73
|
};
|
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
|
|
2
3
|
export const websocketStrategy = async (watcher, onPayload, onError, callbacks) => {
|
|
3
4
|
let active = true;
|
|
4
5
|
let ws = null;
|
|
6
|
+
let connectTimer;
|
|
7
|
+
const clearConnectTimer = () => {
|
|
8
|
+
if (!connectTimer)
|
|
9
|
+
return;
|
|
10
|
+
clearTimeout(connectTimer);
|
|
11
|
+
connectTimer = undefined;
|
|
12
|
+
};
|
|
13
|
+
const connectTimeoutMs = Math.max(1, watcher.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
|
5
14
|
const connect = () => {
|
|
6
15
|
let pendingError = null;
|
|
7
16
|
let failureReported = false;
|
|
8
|
-
|
|
17
|
+
const reportFailure = (reason) => {
|
|
18
|
+
if (!active || failureReported)
|
|
19
|
+
return;
|
|
20
|
+
failureReported = true;
|
|
21
|
+
clearConnectTimer();
|
|
22
|
+
void onError(reason);
|
|
23
|
+
};
|
|
24
|
+
ws = new WebSocket(watcher.endpoint, {
|
|
25
|
+
headers: watcher.headers,
|
|
26
|
+
handshakeTimeout: connectTimeoutMs,
|
|
27
|
+
});
|
|
28
|
+
connectTimer = setTimeout(() => {
|
|
29
|
+
if (!active || !ws)
|
|
30
|
+
return;
|
|
31
|
+
if (ws.readyState === WebSocket.CONNECTING) {
|
|
32
|
+
pendingError = new Error(`websocket connect timeout after ${connectTimeoutMs}ms`);
|
|
33
|
+
ws.terminate();
|
|
34
|
+
}
|
|
35
|
+
}, connectTimeoutMs);
|
|
9
36
|
ws.on("open", () => {
|
|
10
37
|
if (!active)
|
|
11
38
|
return;
|
|
39
|
+
clearConnectTimer();
|
|
12
40
|
callbacks?.onConnect?.();
|
|
13
41
|
});
|
|
14
42
|
ws.on("message", async (data) => {
|
|
@@ -28,17 +56,24 @@ export const websocketStrategy = async (watcher, onPayload, onError, callbacks)
|
|
|
28
56
|
pendingError = err instanceof Error ? err : new Error(String(err));
|
|
29
57
|
});
|
|
30
58
|
ws.on("close", (code) => {
|
|
31
|
-
if (!active
|
|
59
|
+
if (!active)
|
|
32
60
|
return;
|
|
33
|
-
failureReported = true;
|
|
34
61
|
const reason = pendingError?.message ?? `websocket closed: ${code}`;
|
|
35
|
-
|
|
62
|
+
reportFailure(new Error(reason));
|
|
36
63
|
});
|
|
37
64
|
};
|
|
38
65
|
connect();
|
|
39
66
|
return async () => {
|
|
40
67
|
active = false;
|
|
41
|
-
|
|
68
|
+
clearConnectTimer();
|
|
69
|
+
if (!ws)
|
|
70
|
+
return;
|
|
71
|
+
if (ws.readyState === WebSocket.CONNECTING) {
|
|
72
|
+
ws.terminate();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
42
76
|
ws.close();
|
|
77
|
+
}
|
|
43
78
|
};
|
|
44
79
|
};
|
package/dist/template.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
+
import { getPath } from "./utils.js";
|
|
1
2
|
const placeholderPattern = /^\$\{(watcher\.(id|skillId)|event\.(name)|payload\.[a-zA-Z0-9_.-]+|timestamp)\}$/;
|
|
2
|
-
function getPath(obj, path) {
|
|
3
|
-
return path.split(".").reduce((acc, part) => acc?.[part], obj);
|
|
4
|
-
}
|
|
5
3
|
function renderValue(value, context) {
|
|
6
4
|
if (value === null || typeof value === "number" || typeof value === "boolean")
|
|
7
5
|
return value;
|
package/dist/toolSchema.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import { TemplateValueSchema } from "./templateValueSchema.js";
|
|
3
3
|
const TemplateValueRefSchema = Type.Ref(TemplateValueSchema);
|
|
4
|
+
const WATCHER_ID_PATTERN = "^[A-Za-z0-9_-]{1,128}$";
|
|
4
5
|
const ConditionSchema = Type.Object({
|
|
5
6
|
path: Type.String({ description: "JSONPath expression to evaluate against the response" }),
|
|
6
7
|
op: Type.Union([
|
|
@@ -58,7 +59,11 @@ const DeliveryTargetSchema = Type.Object({
|
|
|
58
59
|
accountId: Type.Optional(Type.String({ description: "Optional account id for multi-account channels" })),
|
|
59
60
|
}, { additionalProperties: false });
|
|
60
61
|
const WatcherSchema = Type.Object({
|
|
61
|
-
id: Type.String({
|
|
62
|
+
id: Type.String({
|
|
63
|
+
pattern: WATCHER_ID_PATTERN,
|
|
64
|
+
maxLength: 128,
|
|
65
|
+
description: "Unique watcher identifier (letters, numbers, hyphen, underscore)",
|
|
66
|
+
}),
|
|
62
67
|
skillId: Type.String({ description: "ID of the skill that owns this watcher" }),
|
|
63
68
|
enabled: Type.Boolean({ description: "Whether the watcher is actively polling" }),
|
|
64
69
|
strategy: Type.Union([
|
|
@@ -114,7 +119,11 @@ const CreateActionSchema = Type.Object({
|
|
|
114
119
|
}, { additionalProperties: false });
|
|
115
120
|
const IdActionSchema = Type.Object({
|
|
116
121
|
action: IdActionNameSchema,
|
|
117
|
-
id: Type.String({
|
|
122
|
+
id: Type.String({
|
|
123
|
+
pattern: WATCHER_ID_PATTERN,
|
|
124
|
+
maxLength: 128,
|
|
125
|
+
description: "Watcher ID for action target",
|
|
126
|
+
}),
|
|
118
127
|
}, { additionalProperties: false });
|
|
119
128
|
const ListActionSchema = Type.Object({
|
|
120
129
|
action: ListActionNameSchema,
|
|
@@ -127,7 +136,11 @@ export const SentinelToolValidationSchema = Type.Union([CreateActionSchema, IdAc
|
|
|
127
136
|
export const SentinelToolSchema = Type.Object({
|
|
128
137
|
action: AnyActionNameSchema,
|
|
129
138
|
watcher: Type.Optional(WatcherSchema),
|
|
130
|
-
id: Type.Optional(Type.String({
|
|
139
|
+
id: Type.Optional(Type.String({
|
|
140
|
+
pattern: WATCHER_ID_PATTERN,
|
|
141
|
+
maxLength: 128,
|
|
142
|
+
description: "Watcher ID for action target",
|
|
143
|
+
})),
|
|
131
144
|
}, {
|
|
132
145
|
additionalProperties: false,
|
|
133
146
|
$defs: {
|
package/dist/types.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface WatcherRuntimeState {
|
|
|
67
67
|
lastConnectAt?: string;
|
|
68
68
|
lastDisconnectAt?: string;
|
|
69
69
|
lastDisconnectReason?: string;
|
|
70
|
+
lastDispatchError?: string;
|
|
71
|
+
lastDispatchErrorAt?: string;
|
|
70
72
|
lastDelivery?: {
|
|
71
73
|
attemptedAt: string;
|
|
72
74
|
successCount: number;
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getPath(obj: unknown, path: string): unknown;
|
package/dist/utils.js
ADDED
package/dist/validator.js
CHANGED
|
@@ -5,6 +5,7 @@ import { DEFAULT_SENTINEL_WEBHOOK_PATH } from "./types.js";
|
|
|
5
5
|
const TemplateValueRefSchema = Type.Ref(TemplateValueSchema);
|
|
6
6
|
const codeyKeyPattern = /(script|code|eval|handler|function|import|require)/i;
|
|
7
7
|
const codeyValuePattern = /(=>|\bfunction\b|\bimport\s+|\brequire\s*\(|\beval\s*\()/i;
|
|
8
|
+
const WATCHER_ID_PATTERN = "^[A-Za-z0-9_-]{1,128}$";
|
|
8
9
|
const ConditionSchema = Type.Object({
|
|
9
10
|
path: Type.String({ minLength: 1 }),
|
|
10
11
|
op: Type.Union([
|
|
@@ -23,7 +24,7 @@ const ConditionSchema = Type.Object({
|
|
|
23
24
|
value: Type.Optional(Type.Unknown()),
|
|
24
25
|
}, { additionalProperties: false });
|
|
25
26
|
export const WatcherSchema = Type.Object({
|
|
26
|
-
id: Type.String({
|
|
27
|
+
id: Type.String({ pattern: WATCHER_ID_PATTERN, maxLength: 128 }),
|
|
27
28
|
skillId: Type.String({ minLength: 1 }),
|
|
28
29
|
enabled: Type.Boolean(),
|
|
29
30
|
strategy: Type.Union([
|
package/dist/watcherManager.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ export interface WatcherCreateContext {
|
|
|
6
6
|
export interface WatcherNotifier {
|
|
7
7
|
notify(target: DeliveryTarget, message: string): Promise<void>;
|
|
8
8
|
}
|
|
9
|
+
export interface WatcherLogger {
|
|
10
|
+
info?(message: string): void;
|
|
11
|
+
warn?(message: string): void;
|
|
12
|
+
error?(message: string): void;
|
|
13
|
+
}
|
|
9
14
|
export declare const backoff: (base: number, max: number, failures: number) => number;
|
|
10
15
|
export declare class WatcherManager {
|
|
11
16
|
private config;
|
|
@@ -16,6 +21,7 @@ export declare class WatcherManager {
|
|
|
16
21
|
private stops;
|
|
17
22
|
private retryTimers;
|
|
18
23
|
private statePath;
|
|
24
|
+
private logger?;
|
|
19
25
|
private webhookRegistration;
|
|
20
26
|
constructor(config: SentinelConfig, dispatcher: GatewayWebhookDispatcher, notifier?: WatcherNotifier | undefined);
|
|
21
27
|
init(): Promise<void>;
|
|
@@ -23,6 +29,7 @@ export declare class WatcherManager {
|
|
|
23
29
|
list(): WatcherDefinition[];
|
|
24
30
|
status(id: string): WatcherRuntimeState | undefined;
|
|
25
31
|
setNotifier(notifier: WatcherNotifier | undefined): void;
|
|
32
|
+
setLogger(logger: WatcherLogger | undefined): void;
|
|
26
33
|
setWebhookRegistrationStatus(status: "ok" | "error", message?: string, path?: string): void;
|
|
27
34
|
enable(id: string): Promise<void>;
|
|
28
35
|
disable(id: string): Promise<void>;
|
package/dist/watcherManager.js
CHANGED
|
@@ -50,6 +50,7 @@ export class WatcherManager {
|
|
|
50
50
|
stops = new Map();
|
|
51
51
|
retryTimers = new Map();
|
|
52
52
|
statePath;
|
|
53
|
+
logger;
|
|
53
54
|
webhookRegistration = {
|
|
54
55
|
path: DEFAULT_SENTINEL_WEBHOOK_PATH,
|
|
55
56
|
status: "pending",
|
|
@@ -81,6 +82,8 @@ export class WatcherManager {
|
|
|
81
82
|
lastEvaluated: prev?.lastEvaluated,
|
|
82
83
|
lastPayloadHash: prev?.lastPayloadHash,
|
|
83
84
|
lastPayload: prev?.lastPayload,
|
|
85
|
+
lastDispatchError: prev?.lastDispatchError,
|
|
86
|
+
lastDispatchErrorAt: prev?.lastDispatchErrorAt,
|
|
84
87
|
};
|
|
85
88
|
}
|
|
86
89
|
}
|
|
@@ -112,6 +115,9 @@ export class WatcherManager {
|
|
|
112
115
|
setNotifier(notifier) {
|
|
113
116
|
this.notifier = notifier;
|
|
114
117
|
}
|
|
118
|
+
setLogger(logger) {
|
|
119
|
+
this.logger = logger;
|
|
120
|
+
}
|
|
115
121
|
setWebhookRegistrationStatus(status, message, path) {
|
|
116
122
|
this.webhookRegistration = {
|
|
117
123
|
path: path ?? this.webhookRegistration.path,
|
|
@@ -213,39 +219,58 @@ export class WatcherManager {
|
|
|
213
219
|
matchedAt,
|
|
214
220
|
webhookPath,
|
|
215
221
|
});
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const message =
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
failures.push({
|
|
234
|
-
target,
|
|
235
|
-
error: String(err?.message ?? err),
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
}));
|
|
239
|
-
rt.lastDelivery = {
|
|
240
|
-
attemptedAt,
|
|
241
|
-
successCount,
|
|
242
|
-
failureCount: failures.length,
|
|
243
|
-
failures: failures.length > 0 ? failures : undefined,
|
|
244
|
-
};
|
|
222
|
+
let dispatchSucceeded = false;
|
|
223
|
+
try {
|
|
224
|
+
await this.dispatcher.dispatch(webhookPath, body);
|
|
225
|
+
dispatchSucceeded = true;
|
|
226
|
+
rt.lastDispatchError = undefined;
|
|
227
|
+
rt.lastDispatchErrorAt = undefined;
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
const message = String(err?.message ?? err);
|
|
231
|
+
const status = err?.status;
|
|
232
|
+
rt.lastDispatchError = message;
|
|
233
|
+
rt.lastDispatchErrorAt = new Date().toISOString();
|
|
234
|
+
rt.lastError = message;
|
|
235
|
+
this.logger?.warn?.(`[openclaw-sentinel] Dispatch failed for watcher=${watcher.id} webhookPath=${webhookPath}: ${message}`);
|
|
236
|
+
if (status === 401 || status === 403) {
|
|
237
|
+
this.logger?.warn?.("[openclaw-sentinel] Dispatch authorization rejected (401/403). dispatchAuthToken may be missing or invalid. Sentinel now auto-detects gateway auth token when possible; explicit config/env overrides still take precedence.");
|
|
238
|
+
}
|
|
245
239
|
}
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
240
|
+
if (dispatchSucceeded) {
|
|
241
|
+
const deliveryMode = resolveNotificationPayloadMode(this.config, watcher);
|
|
242
|
+
const isSentinelWebhook = webhookPath === DEFAULT_SENTINEL_WEBHOOK_PATH;
|
|
243
|
+
if (deliveryMode !== "none" &&
|
|
244
|
+
watcher.deliveryTargets?.length &&
|
|
245
|
+
this.notifier &&
|
|
246
|
+
!isSentinelWebhook) {
|
|
247
|
+
const attemptedAt = new Date().toISOString();
|
|
248
|
+
const message = buildDeliveryNotificationMessage(watcher, body, deliveryMode);
|
|
249
|
+
const failures = [];
|
|
250
|
+
let successCount = 0;
|
|
251
|
+
await Promise.all(watcher.deliveryTargets.map(async (target) => {
|
|
252
|
+
try {
|
|
253
|
+
await this.notifier?.notify(target, message);
|
|
254
|
+
successCount += 1;
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
failures.push({
|
|
258
|
+
target,
|
|
259
|
+
error: String(err?.message ?? err),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}));
|
|
263
|
+
rt.lastDelivery = {
|
|
264
|
+
attemptedAt,
|
|
265
|
+
successCount,
|
|
266
|
+
failureCount: failures.length,
|
|
267
|
+
failures: failures.length > 0 ? failures : undefined,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (watcher.fireOnce) {
|
|
271
|
+
watcher.enabled = false;
|
|
272
|
+
await this.stopWatcher(id);
|
|
273
|
+
}
|
|
249
274
|
}
|
|
250
275
|
}
|
|
251
276
|
await this.persist();
|
package/openclaw.plugin.json
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dispatchAuthToken": {
|
|
21
21
|
"type": "string",
|
|
22
|
-
"description": "
|
|
22
|
+
"description": "Optional bearer token override for webhook dispatch auth. Sentinel auto-detects gateway auth token when available."
|
|
23
23
|
},
|
|
24
24
|
"hookSessionKey": {
|
|
25
25
|
"type": "string",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"description": "Optional default session group key. When set, callbacks without explicit hookSessionGroup are routed to this group session."
|
|
36
36
|
},
|
|
37
37
|
"hookRelayDedupeWindowMs": {
|
|
38
|
-
"type": "
|
|
38
|
+
"type": "integer",
|
|
39
39
|
"minimum": 0,
|
|
40
40
|
"description": "Suppress duplicate relay messages for the same dedupe key within this window (milliseconds)",
|
|
41
41
|
"default": 120000
|
|
@@ -56,29 +56,29 @@
|
|
|
56
56
|
"description": "Resource limits for watcher creation",
|
|
57
57
|
"properties": {
|
|
58
58
|
"maxWatchersTotal": {
|
|
59
|
-
"type": "
|
|
59
|
+
"type": "integer",
|
|
60
60
|
"description": "Maximum total watchers across all skills",
|
|
61
61
|
"default": 200
|
|
62
62
|
},
|
|
63
63
|
"maxWatchersPerSkill": {
|
|
64
|
-
"type": "
|
|
64
|
+
"type": "integer",
|
|
65
65
|
"description": "Maximum watchers per skill",
|
|
66
66
|
"default": 20
|
|
67
67
|
},
|
|
68
68
|
"maxConditionsPerWatcher": {
|
|
69
|
-
"type": "
|
|
69
|
+
"type": "integer",
|
|
70
70
|
"description": "Maximum conditions per watcher definition",
|
|
71
71
|
"default": 25
|
|
72
72
|
},
|
|
73
73
|
"maxIntervalMsFloor": {
|
|
74
|
-
"type": "
|
|
74
|
+
"type": "integer",
|
|
75
75
|
"description": "Minimum allowed polling interval in milliseconds",
|
|
76
76
|
"default": 1000
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
80
|
"hookResponseTimeoutMs": {
|
|
81
|
-
"type": "
|
|
81
|
+
"type": "integer",
|
|
82
82
|
"minimum": 0,
|
|
83
83
|
"description": "Milliseconds to wait for an assistant-authored hook response before optional fallback relay",
|
|
84
84
|
"default": 30000
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"default": "concise"
|
|
91
91
|
},
|
|
92
92
|
"hookResponseDedupeWindowMs": {
|
|
93
|
-
"type": "
|
|
93
|
+
"type": "integer",
|
|
94
94
|
"minimum": 0,
|
|
95
95
|
"description": "Deduplicate hook response-delivery contracts by dedupe key within this window (milliseconds)",
|
|
96
96
|
"default": 120000
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
},
|
|
109
109
|
"dispatchAuthToken": {
|
|
110
110
|
"label": "Dispatch Auth Token",
|
|
111
|
-
"help": "
|
|
111
|
+
"help": "Optional override for webhook dispatch authentication. Sentinel auto-detects gateway auth token when available.",
|
|
112
112
|
"sensitive": true,
|
|
113
113
|
"placeholder": "sk-..."
|
|
114
114
|
},
|