@2en/clawly-plugins 1.30.0-beta.3 → 1.30.0-beta.4
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/clawly-config-defaults.json5 +107 -3
- package/config-setup.ts +140 -324
- package/gateway/index.ts +0 -2
- package/gateway/offline-push.test.ts +39 -20
- package/gateway/offline-push.ts +28 -0
- package/package.json +2 -1
- package/types.ts +1 -1
- package/gateway/node-dangerous-allowlist.ts +0 -84
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
|
|
13
13
|
api: "openai-completions",
|
|
14
14
|
models: [
|
|
15
|
+
{
|
|
16
|
+
id: "auto",
|
|
17
|
+
name: "auto",
|
|
18
|
+
input: ["text", "image"],
|
|
19
|
+
contextWindow: 262144,
|
|
20
|
+
maxTokens: 65535,
|
|
21
|
+
},
|
|
15
22
|
{
|
|
16
23
|
id: "moonshotai/kimi-k2.5",
|
|
17
24
|
name: "moonshotai/kimi-k2.5",
|
|
@@ -33,6 +40,13 @@
|
|
|
33
40
|
contextWindow: 1048576,
|
|
34
41
|
maxTokens: 65536,
|
|
35
42
|
},
|
|
43
|
+
{
|
|
44
|
+
id: "google/gemini-3.1-pro-preview",
|
|
45
|
+
name: "google/gemini-3.1-pro-preview",
|
|
46
|
+
input: ["text", "image"],
|
|
47
|
+
contextWindow: 1048576,
|
|
48
|
+
maxTokens: 65536,
|
|
49
|
+
},
|
|
36
50
|
{
|
|
37
51
|
id: "anthropic/claude-sonnet-4.6",
|
|
38
52
|
name: "anthropic/claude-sonnet-4.6",
|
|
@@ -89,12 +103,12 @@
|
|
|
89
103
|
},
|
|
90
104
|
},
|
|
91
105
|
|
|
92
|
-
// agents.defaults
|
|
93
|
-
//
|
|
94
|
-
// and fallback lists.
|
|
106
|
+
// agents.defaults — model picker whitelist, display names, and tuning defaults.
|
|
107
|
+
// The whitelist constrains the UI model picker and fallback lists.
|
|
95
108
|
agents: {
|
|
96
109
|
defaults: {
|
|
97
110
|
thinkingDefault: "off",
|
|
111
|
+
verboseDefault: "on",
|
|
98
112
|
model: {
|
|
99
113
|
primary: "clawly-model-gateway/anthropic/claude-sonnet-4.6",
|
|
100
114
|
},
|
|
@@ -103,9 +117,11 @@
|
|
|
103
117
|
fallbacks: [],
|
|
104
118
|
},
|
|
105
119
|
models: {
|
|
120
|
+
"clawly-model-gateway/auto": { alias: "Auto" },
|
|
106
121
|
"clawly-model-gateway/moonshotai/kimi-k2.5": { alias: "Kimi K2.5" },
|
|
107
122
|
"clawly-model-gateway/google/gemini-2.5-pro": { alias: "Gemini 2.5 Pro" },
|
|
108
123
|
"clawly-model-gateway/google/gemini-3-pro-preview": { alias: "Gemini 3 Pro Preview" },
|
|
124
|
+
"clawly-model-gateway/google/gemini-3.1-pro-preview": { alias: "Gemini 3.1 Pro Preview" },
|
|
109
125
|
"clawly-model-gateway/anthropic/claude-sonnet-4.6": { alias: "Claude Sonnet 4.6" },
|
|
110
126
|
"clawly-model-gateway/anthropic/claude-opus-4.6": { alias: "Claude Opus 4.6" },
|
|
111
127
|
"clawly-model-gateway/openai/gpt-5.4": { alias: "GPT-5.4" },
|
|
@@ -114,6 +130,94 @@
|
|
|
114
130
|
"clawly-model-gateway/qwen/qwen3.5-plus-02-15": { alias: "Qwen 3.5 Plus" },
|
|
115
131
|
"clawly-model-gateway/z-ai/glm-5": { alias: "GLM-5" },
|
|
116
132
|
},
|
|
133
|
+
contextPruning: {
|
|
134
|
+
mode: "cache-ttl",
|
|
135
|
+
},
|
|
136
|
+
heartbeat: {
|
|
137
|
+
model: "clawly-model-gateway/moonshotai/kimi-k2.5",
|
|
138
|
+
lightContext: true,
|
|
139
|
+
},
|
|
140
|
+
memorySearch: {
|
|
141
|
+
provider: "openai",
|
|
142
|
+
model: "text-embedding-3-small",
|
|
143
|
+
remote: {
|
|
144
|
+
baseUrl: "${CLAWLY_MODEL_GATEWAY_BASE}/v1",
|
|
145
|
+
apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
|
|
146
|
+
batch: { enabled: false },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// messages.tts — text-to-speech credentials + tuning defaults
|
|
153
|
+
messages: {
|
|
154
|
+
tts: {
|
|
155
|
+
auto: "tagged",
|
|
156
|
+
mode: "final",
|
|
157
|
+
provider: "elevenlabs",
|
|
158
|
+
summaryModel: "clawly-model-gateway/anthropic/claude-sonnet-4.6",
|
|
159
|
+
maxTextLength: 4000,
|
|
160
|
+
timeoutMs: 30000,
|
|
161
|
+
prefsPath: "~/.openclaw/settings/tts.json",
|
|
162
|
+
modelOverrides: { enabled: true },
|
|
163
|
+
elevenlabs: {
|
|
164
|
+
baseUrl: "${CLAWLY_MODEL_GATEWAY_BASE}/v1/elevenlabs",
|
|
165
|
+
apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
|
|
166
|
+
voiceId: "DwwuoY7Uz8AP8zrY5TAo",
|
|
167
|
+
modelId: "eleven_multilingual_v2",
|
|
168
|
+
seed: 42,
|
|
169
|
+
applyTextNormalization: "auto",
|
|
170
|
+
languageCode: "en",
|
|
171
|
+
voiceSettings: {
|
|
172
|
+
stability: 0.5,
|
|
173
|
+
similarityBoost: 0.75,
|
|
174
|
+
style: 0.0,
|
|
175
|
+
useSpeakerBoost: true,
|
|
176
|
+
speed: 1.0,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// gateway.nodes — allowlist dangerous node commands so the AI agent can
|
|
183
|
+
// invoke them on connected Clawly nodes (browser, reminders, calendar, device).
|
|
184
|
+
gateway: {
|
|
185
|
+
nodes: {
|
|
186
|
+
allowCommands: [
|
|
187
|
+
// Browser commands (Mac nodes)
|
|
188
|
+
"browser.proxy",
|
|
189
|
+
"browser.navigate",
|
|
190
|
+
"browser.click",
|
|
191
|
+
"browser.type",
|
|
192
|
+
"browser.screenshot",
|
|
193
|
+
"browser.read",
|
|
194
|
+
"browser.tabs",
|
|
195
|
+
"browser.back",
|
|
196
|
+
"browser.scroll",
|
|
197
|
+
"browser.evaluate",
|
|
198
|
+
// Reminders + calendar (iOS nodes)
|
|
199
|
+
"reminders.add",
|
|
200
|
+
"calendar.add",
|
|
201
|
+
// Device permissions (iOS nodes)
|
|
202
|
+
"device.permissions",
|
|
203
|
+
"device.requestPermission",
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// tools.web.search — web search defaults
|
|
209
|
+
tools: {
|
|
210
|
+
web: {
|
|
211
|
+
search: {
|
|
212
|
+
provider: "perplexity",
|
|
213
|
+
perplexity: {
|
|
214
|
+
model: "perplexity/sonar-pro",
|
|
215
|
+
baseUrl: "${CLAWLY_MODEL_GATEWAY_BASE}/v1",
|
|
216
|
+
apiKey: "${CLAWLY_MODEL_GATEWAY_API_KEY}",
|
|
217
|
+
},
|
|
218
|
+
maxResults: 5,
|
|
219
|
+
timeoutSeconds: 30,
|
|
220
|
+
},
|
|
117
221
|
},
|
|
118
222
|
},
|
|
119
223
|
}
|
package/config-setup.ts
CHANGED
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import fs from 'node:fs'
|
|
19
19
|
import path from 'node:path'
|
|
20
|
-
|
|
21
|
-
import type {PluginApi} from './index'
|
|
20
|
+
import JSON5 from 'json5'
|
|
22
21
|
import {autoSanitizeSession} from './gateway/session-sanitize'
|
|
23
|
-
import {
|
|
22
|
+
import type {PluginApi} from './index'
|
|
24
23
|
import {
|
|
25
24
|
ENV_KEY_API_KEY,
|
|
26
25
|
ENV_KEY_BASE,
|
|
@@ -30,16 +29,14 @@ import {
|
|
|
30
29
|
stripPathname,
|
|
31
30
|
writeOpenclawConfig,
|
|
32
31
|
} from './model-gateway-setup'
|
|
32
|
+
import {resolveGatewayCredentials} from './resolve-gateway-credentials'
|
|
33
|
+
import type {OpenClawConfig} from './types/openclaw'
|
|
33
34
|
|
|
34
35
|
export interface ConfigPluginConfig {
|
|
35
36
|
instanceId?: string
|
|
36
37
|
agentId?: string
|
|
37
38
|
agentName?: string
|
|
38
39
|
workspaceDir?: string
|
|
39
|
-
defaultModel?: string
|
|
40
|
-
defaultImageModel?: string
|
|
41
|
-
defaultHeartbeatModel?: string
|
|
42
|
-
elevenlabsApiKey?: string
|
|
43
40
|
modelGatewayBaseUrl?: string
|
|
44
41
|
modelGatewayToken?: string
|
|
45
42
|
otelToken?: string
|
|
@@ -54,7 +51,7 @@ function asObj(value: unknown): Record<string, unknown> {
|
|
|
54
51
|
: {}
|
|
55
52
|
}
|
|
56
53
|
|
|
57
|
-
function readFilePluginConfig(config:
|
|
54
|
+
function readFilePluginConfig(config: OpenClawConfig): ConfigPluginConfig {
|
|
58
55
|
const plugins = asObj(config.plugins)
|
|
59
56
|
const entries = asObj(plugins.entries)
|
|
60
57
|
const raw = asObj(entries['clawly-plugins'])
|
|
@@ -77,25 +74,12 @@ function mergeDefinedPluginConfig(
|
|
|
77
74
|
return merged
|
|
78
75
|
}
|
|
79
76
|
|
|
80
|
-
function toPCMerged(api: PluginApi, config:
|
|
77
|
+
function toPCMerged(api: PluginApi, config: OpenClawConfig): ConfigPluginConfig {
|
|
81
78
|
const fileConfig = readFilePluginConfig(config)
|
|
82
79
|
const runtimeConfig = (api.pluginConfig ?? {}) as ConfigPluginConfig
|
|
83
80
|
return mergeDefinedPluginConfig(fileConfig, runtimeConfig)
|
|
84
81
|
}
|
|
85
82
|
|
|
86
|
-
const LEGACY_DEFAULT_MODEL = `${PROVIDER_NAME}/anthropic/claude-sonnet-4.6`
|
|
87
|
-
const DEFAULT_ELEVENLABS_VOICE_ID = 'DwwuoY7Uz8AP8zrY5TAo'
|
|
88
|
-
|
|
89
|
-
function toProviderModelId(model?: string): string {
|
|
90
|
-
const normalized = model?.trim() ?? ''
|
|
91
|
-
if (!normalized) return ''
|
|
92
|
-
return normalized.startsWith(`${PROVIDER_NAME}/`) ? normalized : `${PROVIDER_NAME}/${normalized}`
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function resolveDefaultModel(pc: ConfigPluginConfig): string {
|
|
96
|
-
return toProviderModelId(pc.defaultModel) || LEGACY_DEFAULT_MODEL
|
|
97
|
-
}
|
|
98
|
-
|
|
99
83
|
// ---------------------------------------------------------------------------
|
|
100
84
|
// Domain helpers — each returns true when config was mutated
|
|
101
85
|
//
|
|
@@ -104,7 +88,7 @@ function resolveDefaultModel(pc: ConfigPluginConfig): string {
|
|
|
104
88
|
// diagnostic UI stays in sync.
|
|
105
89
|
// ---------------------------------------------------------------------------
|
|
106
90
|
|
|
107
|
-
export function patchAgent(config:
|
|
91
|
+
export function patchAgent(config: OpenClawConfig, pc: ConfigPluginConfig): boolean {
|
|
108
92
|
if (!pc.agentId) return false
|
|
109
93
|
|
|
110
94
|
let dirty = false
|
|
@@ -138,22 +122,6 @@ export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConf
|
|
|
138
122
|
dirty = true
|
|
139
123
|
}
|
|
140
124
|
|
|
141
|
-
// model: set-if-missing
|
|
142
|
-
if (!defaults.model) {
|
|
143
|
-
defaults.model = {primary: resolveDefaultModel(pc)}
|
|
144
|
-
dirty = true
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// imageModel: set-if-missing
|
|
148
|
-
const defaultImageModel = toProviderModelId(pc.defaultImageModel)
|
|
149
|
-
if (defaultImageModel && !defaults.imageModel) {
|
|
150
|
-
defaults.imageModel = {
|
|
151
|
-
primary: defaultImageModel,
|
|
152
|
-
fallbacks: [],
|
|
153
|
-
}
|
|
154
|
-
dirty = true
|
|
155
|
-
}
|
|
156
|
-
|
|
157
125
|
// sandbox.mode: enforce (only set the field we care about)
|
|
158
126
|
const sandbox = (defaults.sandbox ?? {}) as Record<string, unknown>
|
|
159
127
|
if (sandbox.mode !== 'off') {
|
|
@@ -162,12 +130,6 @@ export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConf
|
|
|
162
130
|
dirty = true
|
|
163
131
|
}
|
|
164
132
|
|
|
165
|
-
// verboseDefault: set-if-missing
|
|
166
|
-
if (defaults.verboseDefault === undefined) {
|
|
167
|
-
defaults.verboseDefault = 'on'
|
|
168
|
-
dirty = true
|
|
169
|
-
}
|
|
170
|
-
|
|
171
133
|
if (dirty) {
|
|
172
134
|
agents.defaults = defaults
|
|
173
135
|
config.agents = agents
|
|
@@ -176,7 +138,7 @@ export function patchAgent(config: Record<string, unknown>, pc: ConfigPluginConf
|
|
|
176
138
|
return dirty
|
|
177
139
|
}
|
|
178
140
|
|
|
179
|
-
export function patchGateway(config:
|
|
141
|
+
export function patchGateway(config: OpenClawConfig): boolean {
|
|
180
142
|
let dirty = false
|
|
181
143
|
const gateway = (config.gateway ?? {}) as Record<string, unknown>
|
|
182
144
|
|
|
@@ -223,7 +185,7 @@ export function patchGateway(config: Record<string, unknown>): boolean {
|
|
|
223
185
|
return dirty
|
|
224
186
|
}
|
|
225
187
|
|
|
226
|
-
export function patchBrowser(config:
|
|
188
|
+
export function patchBrowser(config: OpenClawConfig): boolean {
|
|
227
189
|
let dirty = false
|
|
228
190
|
|
|
229
191
|
// browser: enforce individual fields only
|
|
@@ -250,7 +212,7 @@ export function patchBrowser(config: Record<string, unknown>): boolean {
|
|
|
250
212
|
dirty = true
|
|
251
213
|
}
|
|
252
214
|
|
|
253
|
-
//
|
|
215
|
+
// ownerAllowFrom: append-if-missing (can't use $include — arrays concatenate)
|
|
254
216
|
const ownerAllowFrom = Array.isArray(commands.ownerAllowFrom)
|
|
255
217
|
? (commands.ownerAllowFrom as string[])
|
|
256
218
|
: []
|
|
@@ -270,264 +232,10 @@ export function patchBrowser(config: Record<string, unknown>): boolean {
|
|
|
270
232
|
return dirty
|
|
271
233
|
}
|
|
272
234
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const elevenlabs = (tts.elevenlabs ?? {}) as Record<string, unknown>
|
|
278
|
-
|
|
279
|
-
// Credentials: enforce (only when credentials are available)
|
|
280
|
-
const useProxy = !!(pc.modelGatewayBaseUrl && pc.modelGatewayToken)
|
|
281
|
-
const hasDirectKey = !!pc.elevenlabsApiKey
|
|
282
|
-
if (useProxy || hasDirectKey) {
|
|
283
|
-
const targetApiKey = useProxy ? pc.modelGatewayToken! : pc.elevenlabsApiKey!
|
|
284
|
-
const targetBaseUrl = useProxy
|
|
285
|
-
? `${pc.modelGatewayBaseUrl}/elevenlabs`
|
|
286
|
-
: 'https://api.elevenlabs.io'
|
|
287
|
-
|
|
288
|
-
if (elevenlabs.apiKey !== targetApiKey) {
|
|
289
|
-
elevenlabs.apiKey = targetApiKey
|
|
290
|
-
dirty = true
|
|
291
|
-
}
|
|
292
|
-
if (elevenlabs.baseUrl !== targetBaseUrl) {
|
|
293
|
-
elevenlabs.baseUrl = targetBaseUrl
|
|
294
|
-
dirty = true
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// voiceId: set-if-missing (never overwrite user selection)
|
|
299
|
-
// Use falsy check — old plugin versions (< beta.3) enforced voiceId = ''
|
|
300
|
-
// which is not undefined but is still an empty/invalid value.
|
|
301
|
-
// null is also treated as missing here.
|
|
302
|
-
if (!elevenlabs.voiceId) {
|
|
303
|
-
elevenlabs.voiceId = DEFAULT_ELEVENLABS_VOICE_ID
|
|
304
|
-
dirty = true
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Tuning: set-if-missing
|
|
308
|
-
if (tts.auto === undefined) {
|
|
309
|
-
tts.auto = 'tagged'
|
|
310
|
-
dirty = true
|
|
311
|
-
}
|
|
312
|
-
if (tts.mode === undefined) {
|
|
313
|
-
tts.mode = 'final'
|
|
314
|
-
dirty = true
|
|
315
|
-
}
|
|
316
|
-
if (tts.provider === undefined) {
|
|
317
|
-
tts.provider = 'elevenlabs'
|
|
318
|
-
dirty = true
|
|
319
|
-
}
|
|
320
|
-
if (tts.summaryModel === undefined) {
|
|
321
|
-
tts.summaryModel = resolveDefaultModel(pc)
|
|
322
|
-
dirty = true
|
|
323
|
-
}
|
|
324
|
-
if (tts.modelOverrides === undefined) {
|
|
325
|
-
tts.modelOverrides = {enabled: true}
|
|
326
|
-
dirty = true
|
|
327
|
-
}
|
|
328
|
-
if (tts.maxTextLength === undefined) {
|
|
329
|
-
tts.maxTextLength = 4000
|
|
330
|
-
dirty = true
|
|
331
|
-
}
|
|
332
|
-
if (tts.timeoutMs === undefined) {
|
|
333
|
-
tts.timeoutMs = 30000
|
|
334
|
-
dirty = true
|
|
335
|
-
}
|
|
336
|
-
if (tts.prefsPath === undefined) {
|
|
337
|
-
tts.prefsPath = '~/.openclaw/settings/tts.json'
|
|
338
|
-
dirty = true
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// elevenlabs tuning: set-if-missing
|
|
342
|
-
if (elevenlabs.modelId === undefined) {
|
|
343
|
-
elevenlabs.modelId = 'eleven_multilingual_v2'
|
|
344
|
-
dirty = true
|
|
345
|
-
}
|
|
346
|
-
if (elevenlabs.seed === undefined) {
|
|
347
|
-
elevenlabs.seed = 42
|
|
348
|
-
dirty = true
|
|
349
|
-
}
|
|
350
|
-
if (elevenlabs.applyTextNormalization === undefined) {
|
|
351
|
-
elevenlabs.applyTextNormalization = 'auto'
|
|
352
|
-
dirty = true
|
|
353
|
-
}
|
|
354
|
-
if (elevenlabs.languageCode === undefined) {
|
|
355
|
-
elevenlabs.languageCode = 'en'
|
|
356
|
-
dirty = true
|
|
357
|
-
}
|
|
358
|
-
if (elevenlabs.voiceSettings === undefined) {
|
|
359
|
-
elevenlabs.voiceSettings = {
|
|
360
|
-
stability: 0.5,
|
|
361
|
-
similarityBoost: 0.75,
|
|
362
|
-
style: 0.0,
|
|
363
|
-
useSpeakerBoost: true,
|
|
364
|
-
speed: 1.0,
|
|
365
|
-
}
|
|
366
|
-
dirty = true
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (dirty) {
|
|
370
|
-
tts.elevenlabs = elevenlabs
|
|
371
|
-
messages.tts = tts
|
|
372
|
-
config.messages = messages
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return dirty
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
export function patchWebSearch(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
|
|
379
|
-
if (!pc.modelGatewayBaseUrl || !pc.modelGatewayToken) return false
|
|
380
|
-
|
|
381
|
-
let dirty = false
|
|
382
|
-
const tools = (config.tools ?? {}) as Record<string, unknown>
|
|
383
|
-
const web = (tools.web ?? {}) as Record<string, unknown>
|
|
384
|
-
const search = (web.search ?? {}) as Record<string, unknown>
|
|
385
|
-
const perplexity = (search.perplexity ?? {}) as Record<string, unknown>
|
|
386
|
-
|
|
387
|
-
// Provider: enforce
|
|
388
|
-
if (search.provider !== 'perplexity') {
|
|
389
|
-
search.provider = 'perplexity'
|
|
390
|
-
dirty = true
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Perplexity credentials: enforce (nested under search.perplexity)
|
|
394
|
-
if (perplexity.apiKey !== pc.modelGatewayToken) {
|
|
395
|
-
perplexity.apiKey = pc.modelGatewayToken
|
|
396
|
-
dirty = true
|
|
397
|
-
}
|
|
398
|
-
if (perplexity.baseUrl !== pc.modelGatewayBaseUrl) {
|
|
399
|
-
perplexity.baseUrl = pc.modelGatewayBaseUrl
|
|
400
|
-
dirty = true
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Perplexity model: set-if-missing
|
|
404
|
-
if (perplexity.model === undefined) {
|
|
405
|
-
perplexity.model = 'perplexity/sonar-pro'
|
|
406
|
-
dirty = true
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Tuning: set-if-missing
|
|
410
|
-
if (search.maxResults === undefined) {
|
|
411
|
-
search.maxResults = 5
|
|
412
|
-
dirty = true
|
|
413
|
-
}
|
|
414
|
-
if (search.timeoutSeconds === undefined) {
|
|
415
|
-
search.timeoutSeconds = 30
|
|
416
|
-
dirty = true
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
if (dirty) {
|
|
420
|
-
search.perplexity = perplexity
|
|
421
|
-
web.search = search
|
|
422
|
-
tools.web = web
|
|
423
|
-
config.tools = tools
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
return dirty
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
export function patchMemorySearch(
|
|
430
|
-
config: Record<string, unknown>,
|
|
431
|
-
pc: ConfigPluginConfig,
|
|
432
|
-
): boolean {
|
|
433
|
-
if (!pc.modelGatewayBaseUrl || !pc.modelGatewayToken) return false
|
|
434
|
-
|
|
435
|
-
let dirty = false
|
|
436
|
-
const agents = (config.agents ?? {}) as Record<string, unknown>
|
|
437
|
-
const defaults = (agents.defaults ?? {}) as Record<string, unknown>
|
|
438
|
-
const ms = (defaults.memorySearch ?? {}) as Record<string, unknown>
|
|
439
|
-
const remote = (ms.remote ?? {}) as Record<string, unknown>
|
|
440
|
-
const batch = (remote.batch ?? {}) as Record<string, unknown>
|
|
441
|
-
|
|
442
|
-
// Provider: enforce (proxy mimics OpenAI API)
|
|
443
|
-
if (ms.provider !== 'openai') {
|
|
444
|
-
ms.provider = 'openai'
|
|
445
|
-
dirty = true
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Model: set-if-missing
|
|
449
|
-
if (ms.model === undefined) {
|
|
450
|
-
ms.model = 'text-embedding-3-small'
|
|
451
|
-
dirty = true
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Remote credentials: enforce
|
|
455
|
-
if (remote.baseUrl !== pc.modelGatewayBaseUrl) {
|
|
456
|
-
remote.baseUrl = pc.modelGatewayBaseUrl
|
|
457
|
-
dirty = true
|
|
458
|
-
}
|
|
459
|
-
if (remote.apiKey !== pc.modelGatewayToken) {
|
|
460
|
-
remote.apiKey = pc.modelGatewayToken
|
|
461
|
-
dirty = true
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Batch API not supported through proxy: enforce disabled
|
|
465
|
-
if (batch.enabled !== false) {
|
|
466
|
-
batch.enabled = false
|
|
467
|
-
dirty = true
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (dirty) {
|
|
471
|
-
remote.batch = batch
|
|
472
|
-
ms.remote = remote
|
|
473
|
-
defaults.memorySearch = ms
|
|
474
|
-
agents.defaults = defaults
|
|
475
|
-
config.agents = agents
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
return dirty
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export function patchContextPruning(config: Record<string, unknown>): boolean {
|
|
482
|
-
const agents = (config.agents ?? {}) as Record<string, unknown>
|
|
483
|
-
const defaults = (agents.defaults ?? {}) as Record<string, unknown>
|
|
484
|
-
const cp = (defaults.contextPruning ?? {}) as Record<string, unknown>
|
|
485
|
-
|
|
486
|
-
// mode: set-if-missing — enable cache-ttl by default for cost savings,
|
|
487
|
-
// but don't override if an operator has explicitly configured it.
|
|
488
|
-
if (cp.mode === undefined) {
|
|
489
|
-
cp.mode = 'cache-ttl'
|
|
490
|
-
defaults.contextPruning = cp
|
|
491
|
-
agents.defaults = defaults
|
|
492
|
-
config.agents = agents
|
|
493
|
-
return true
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
return false
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const DEFAULT_HEARTBEAT_MODEL = `${PROVIDER_NAME}/moonshotai/kimi-k2.5`
|
|
500
|
-
|
|
501
|
-
function resolveDefaultHeartbeatModel(pc: ConfigPluginConfig): string {
|
|
502
|
-
return toProviderModelId(pc.defaultHeartbeatModel) || DEFAULT_HEARTBEAT_MODEL
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
export function patchHeartbeat(config: Record<string, unknown>, pc: ConfigPluginConfig): boolean {
|
|
506
|
-
let dirty = false
|
|
507
|
-
const agents = (config.agents ?? {}) as Record<string, unknown>
|
|
508
|
-
const defaults = (agents.defaults ?? {}) as Record<string, unknown>
|
|
509
|
-
const heartbeat = (defaults.heartbeat ?? {}) as Record<string, unknown>
|
|
510
|
-
|
|
511
|
-
// model: set-if-missing
|
|
512
|
-
if (!heartbeat.model) {
|
|
513
|
-
heartbeat.model = resolveDefaultHeartbeatModel(pc)
|
|
514
|
-
dirty = true
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// lightContext: set-if-missing
|
|
518
|
-
if (heartbeat.lightContext === undefined) {
|
|
519
|
-
heartbeat.lightContext = true
|
|
520
|
-
dirty = true
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
if (dirty) {
|
|
524
|
-
defaults.heartbeat = heartbeat
|
|
525
|
-
agents.defaults = defaults
|
|
526
|
-
config.agents = agents
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return dirty
|
|
530
|
-
}
|
|
235
|
+
// patchTts — migrated to clawly-config-defaults.json5
|
|
236
|
+
// patchWebSearch — migrated to clawly-config-defaults.json5
|
|
237
|
+
// patchMemorySearch — migrated to clawly-config-defaults.json5
|
|
238
|
+
// patchHeartbeat — migrated to clawly-config-defaults.json5
|
|
531
239
|
|
|
532
240
|
// TODO: Re-enable patchToolPolicy once rollback-safe (deny is sticky — rolling back
|
|
533
241
|
// the plugin leaves web_search permanently denied). Tracked in ENG-1493.
|
|
@@ -552,7 +260,7 @@ export function patchHeartbeat(config: Record<string, unknown>, pc: ConfigPlugin
|
|
|
552
260
|
// }
|
|
553
261
|
|
|
554
262
|
export function patchEnvVars(
|
|
555
|
-
config:
|
|
263
|
+
config: OpenClawConfig,
|
|
556
264
|
pc: ConfigPluginConfig,
|
|
557
265
|
api: PluginApi,
|
|
558
266
|
): boolean {
|
|
@@ -589,7 +297,7 @@ export function patchEnvVars(
|
|
|
589
297
|
return dirty
|
|
590
298
|
}
|
|
591
299
|
|
|
592
|
-
export function patchSession(config:
|
|
300
|
+
export function patchSession(config: OpenClawConfig): boolean {
|
|
593
301
|
let dirty = false
|
|
594
302
|
|
|
595
303
|
// Remove deprecated session.mainKey (OpenClaw v2026.1.5 hardcoded it to "main")
|
|
@@ -638,7 +346,7 @@ const INCLUDE_PATH = './extensions/clawly-plugins/clawly-config-defaults.json5'
|
|
|
638
346
|
* Ensures our defaults file is listed in the config's `$include` array.
|
|
639
347
|
* Returns true if `$include` was modified.
|
|
640
348
|
*/
|
|
641
|
-
export function ensureInclude(config: Record<string, unknown>): boolean {
|
|
349
|
+
export function ensureInclude(config: OpenClawConfig & Record<string, unknown>): boolean {
|
|
642
350
|
const existing = config.$include
|
|
643
351
|
let includes: string[]
|
|
644
352
|
|
|
@@ -685,7 +393,7 @@ const NPM_PKG_NAME = '@2en/clawly-plugins'
|
|
|
685
393
|
* in the provision write-plugins-config step. Without this record,
|
|
686
394
|
* `openclaw plugins update` cannot function.
|
|
687
395
|
*/
|
|
688
|
-
export function patchInstallRecord(config:
|
|
396
|
+
export function patchInstallRecord(config: OpenClawConfig, stateDir: string): boolean {
|
|
689
397
|
const plugins = asObj(config.plugins)
|
|
690
398
|
const installs = asObj(plugins.installs)
|
|
691
399
|
|
|
@@ -734,11 +442,7 @@ export function patchInstallRecord(config: Record<string, unknown>, stateDir: st
|
|
|
734
442
|
return true
|
|
735
443
|
}
|
|
736
444
|
|
|
737
|
-
function repairLegacyProvisionState(
|
|
738
|
-
api: PluginApi,
|
|
739
|
-
config: Record<string, unknown>,
|
|
740
|
-
stateDir: string,
|
|
741
|
-
) {
|
|
445
|
+
function repairLegacyProvisionState(api: PluginApi, config: OpenClawConfig, stateDir: string) {
|
|
742
446
|
const installRecordPatched = patchInstallRecord(config, stateDir)
|
|
743
447
|
if (installRecordPatched) {
|
|
744
448
|
api.logger.warn('plugins.installs.clawly-plugins was missing — self-healed from disk.')
|
|
@@ -746,10 +450,102 @@ function repairLegacyProvisionState(
|
|
|
746
450
|
return installRecordPatched
|
|
747
451
|
}
|
|
748
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Remove fields from config that are identical to the included defaults.
|
|
455
|
+
* Since `$include` deep-merges with main config winning, residual values
|
|
456
|
+
* from old JS set-if-missing code "shadow" the json5 defaults and prevent
|
|
457
|
+
* future default updates from taking effect.
|
|
458
|
+
*
|
|
459
|
+
* Only prunes leaf values that exactly match.
|
|
460
|
+
* Returns true if any field was deleted.
|
|
461
|
+
*/
|
|
462
|
+
function stableStringify(v: unknown): string {
|
|
463
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
464
|
+
const obj = v as Record<string, unknown>
|
|
465
|
+
return `{${Object.keys(obj)
|
|
466
|
+
.sort()
|
|
467
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
|
|
468
|
+
.join(',')}}`
|
|
469
|
+
}
|
|
470
|
+
return JSON.stringify(v)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function pruneIncludedDefaults(
|
|
474
|
+
config: Record<string, unknown>,
|
|
475
|
+
defaults: Record<string, unknown>,
|
|
476
|
+
): boolean {
|
|
477
|
+
let pruned = false
|
|
478
|
+
|
|
479
|
+
function walk(defaultsNode: Record<string, unknown>, configNode: Record<string, unknown>): void {
|
|
480
|
+
for (const key of Object.keys(defaultsNode)) {
|
|
481
|
+
const defaultVal = defaultsNode[key]
|
|
482
|
+
const configVal = configNode[key]
|
|
483
|
+
if (configVal === undefined) continue
|
|
484
|
+
|
|
485
|
+
// Both are plain objects → recurse
|
|
486
|
+
if (
|
|
487
|
+
defaultVal !== null &&
|
|
488
|
+
typeof defaultVal === 'object' &&
|
|
489
|
+
!Array.isArray(defaultVal) &&
|
|
490
|
+
configVal !== null &&
|
|
491
|
+
typeof configVal === 'object' &&
|
|
492
|
+
!Array.isArray(configVal)
|
|
493
|
+
) {
|
|
494
|
+
walk(defaultVal as Record<string, unknown>, configVal as Record<string, unknown>)
|
|
495
|
+
// Clean up empty parent objects left behind after pruning
|
|
496
|
+
if (Object.keys(configVal as Record<string, unknown>).length === 0) {
|
|
497
|
+
delete configNode[key]
|
|
498
|
+
pruned = true
|
|
499
|
+
}
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Both are non-empty arrays → remove config elements that exist in defaults
|
|
504
|
+
if (Array.isArray(defaultVal) && defaultVal.length > 0 && Array.isArray(configVal)) {
|
|
505
|
+
const defaultSet = new Set(defaultVal.map((v) => stableStringify(v)))
|
|
506
|
+
const filtered = configVal.filter((v) => !defaultSet.has(stableStringify(v)))
|
|
507
|
+
if (filtered.length !== configVal.length) {
|
|
508
|
+
if (filtered.length === 0) {
|
|
509
|
+
delete configNode[key]
|
|
510
|
+
} else {
|
|
511
|
+
configNode[key] = filtered
|
|
512
|
+
}
|
|
513
|
+
pruned = true
|
|
514
|
+
}
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Leaf comparison: use stableStringify for key-order-insensitive deep equality
|
|
519
|
+
if (stableStringify(configVal) === stableStringify(defaultVal)) {
|
|
520
|
+
delete configNode[key]
|
|
521
|
+
pruned = true
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
walk(defaults, config)
|
|
527
|
+
return pruned
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Load and parse clawly-config-defaults.json5 from the plugin install directory.
|
|
532
|
+
* Uses the bundled `json5` package (declared in package.json).
|
|
533
|
+
*/
|
|
534
|
+
function loadIncludedDefaults(stateDir: string): Record<string, unknown> | null {
|
|
535
|
+
const defaultsPath = path.join(stateDir, INCLUDE_PATH)
|
|
536
|
+
try {
|
|
537
|
+
const raw = fs.readFileSync(defaultsPath, 'utf-8')
|
|
538
|
+
return JSON5.parse(raw) as Record<string, unknown>
|
|
539
|
+
} catch {
|
|
540
|
+
return null
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
749
544
|
function reconcileRuntimeConfig(
|
|
750
545
|
api: PluginApi,
|
|
751
|
-
config:
|
|
546
|
+
config: OpenClawConfig,
|
|
752
547
|
pc: ConfigPluginConfig,
|
|
548
|
+
stateDir: string,
|
|
753
549
|
): boolean {
|
|
754
550
|
// Ensure gateway credentials are available to all patchers.
|
|
755
551
|
// After a force-update, runtime pluginConfig may be empty and on-disk
|
|
@@ -764,17 +560,20 @@ function reconcileRuntimeConfig(
|
|
|
764
560
|
}
|
|
765
561
|
|
|
766
562
|
let dirty = false
|
|
767
|
-
dirty = patchEnvVars(config
|
|
563
|
+
dirty = patchEnvVars(config, pc, api) || dirty
|
|
768
564
|
dirty = patchAgent(config, pc) || dirty
|
|
769
|
-
dirty = patchContextPruning(config) || dirty
|
|
770
|
-
dirty = patchHeartbeat(config, pc) || dirty
|
|
771
565
|
dirty = patchGateway(config) || dirty
|
|
772
566
|
dirty = patchBrowser(config) || dirty
|
|
773
567
|
dirty = patchSession(config) || dirty
|
|
774
|
-
dirty =
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
568
|
+
dirty = patchModelGateway(config, api) || dirty
|
|
569
|
+
|
|
570
|
+
const defaults = loadIncludedDefaults(stateDir)
|
|
571
|
+
if (defaults) {
|
|
572
|
+
dirty = pruneIncludedDefaults(config, defaults) || dirty
|
|
573
|
+
} else {
|
|
574
|
+
api.logger.warn('Config setup: failed to load included defaults, skipping dedup.')
|
|
575
|
+
}
|
|
576
|
+
|
|
778
577
|
return dirty
|
|
779
578
|
}
|
|
780
579
|
|
|
@@ -789,6 +588,15 @@ export function setupConfig(api: PluginApi): void {
|
|
|
789
588
|
return
|
|
790
589
|
}
|
|
791
590
|
|
|
591
|
+
// OpenClaw exposes three config layers:
|
|
592
|
+
// parsed — raw JSON from openclaw.json (no $include, no env expansion)
|
|
593
|
+
// resolved — parsed + $include deep-merge + ${ENV} interpolation
|
|
594
|
+
// runtime — resolved + SecretRef resolution, cached in-memory snapshot
|
|
595
|
+
//
|
|
596
|
+
// We need parsed: patchers mutate raw fields and write back via
|
|
597
|
+
// writeConfigFile, which diffs against the runtime snapshot and projects
|
|
598
|
+
// changes onto the source file. Using resolved/runtime would inline
|
|
599
|
+
// $include defaults into openclaw.json, defeating the json5 migration.
|
|
792
600
|
const configPath = path.join(stateDir, 'openclaw.json')
|
|
793
601
|
const config = readOpenclawConfig(configPath)
|
|
794
602
|
const pc = toPCMerged(api, config)
|
|
@@ -797,12 +605,20 @@ export function setupConfig(api: PluginApi): void {
|
|
|
797
605
|
dirty = ensureInclude(config) || dirty
|
|
798
606
|
hardenIncludePermissions(stateDir, api)
|
|
799
607
|
dirty = repairLegacyProvisionState(api, config, stateDir) || dirty
|
|
800
|
-
dirty = reconcileRuntimeConfig(api, config, pc) || dirty
|
|
608
|
+
dirty = reconcileRuntimeConfig(api, config, pc, stateDir) || dirty
|
|
801
609
|
|
|
802
610
|
if (dirty) {
|
|
803
611
|
try {
|
|
804
612
|
writeOpenclawConfig(configPath, config)
|
|
805
613
|
api.logger.info('Config setup: patched openclaw.json.')
|
|
614
|
+
// Refresh the gateway's in-memory runtime config snapshot.
|
|
615
|
+
// The sync write above updates the file on disk, but the gateway
|
|
616
|
+
// caches config via runtimeConfigSnapshot (set during startup by
|
|
617
|
+
// the secrets system). Without this refresh, loadConfig() keeps
|
|
618
|
+
// returning the stale pre-patch config until the next restart.
|
|
619
|
+
void api.runtime.config.writeConfigFile(config).catch((err) => {
|
|
620
|
+
api.logger.warn(`Config setup: runtime snapshot refresh failed: ${(err as Error).message}`)
|
|
621
|
+
})
|
|
806
622
|
} catch (err) {
|
|
807
623
|
api.logger.error(`Config setup failed: ${(err as Error).message}`)
|
|
808
624
|
}
|
package/gateway/index.ts
CHANGED
|
@@ -3,7 +3,6 @@ import {registerAgentSend} from './agent'
|
|
|
3
3
|
import {registerCalendarNative} from './calendar-native'
|
|
4
4
|
import {registerAnalytics} from './analytics'
|
|
5
5
|
import {registerAudit} from './audit'
|
|
6
|
-
import {registerNodeDangerousAllowlist} from './node-dangerous-allowlist'
|
|
7
6
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
8
7
|
import {registerConfigRepair} from './config-repair'
|
|
9
8
|
import {registerConfigTimezone} from './config-timezone'
|
|
@@ -64,6 +63,5 @@ export function registerGateway(api: PluginApi) {
|
|
|
64
63
|
registerPairing(api)
|
|
65
64
|
registerVersion(api)
|
|
66
65
|
registerAudit(api)
|
|
67
|
-
registerNodeDangerousAllowlist(api)
|
|
68
66
|
registerCalendarNative(api)
|
|
69
67
|
}
|
|
@@ -73,6 +73,11 @@ function createMockApi(): {
|
|
|
73
73
|
return {api, logs, handlers}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Event with a simple assistant message — needed since the skip-on-no-text guard. */
|
|
77
|
+
const eventWithReply = {
|
|
78
|
+
messages: [{role: 'assistant', content: 'Hello from the assistant'}],
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
// ── Tests ────────────────────────────────────────────────────────
|
|
77
82
|
|
|
78
83
|
beforeEach(() => {
|
|
@@ -89,7 +94,7 @@ describe('offline-push', () => {
|
|
|
89
94
|
registerOfflinePush(api)
|
|
90
95
|
|
|
91
96
|
const handler = handlers.get('agent_end')!
|
|
92
|
-
await handler(
|
|
97
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
93
98
|
|
|
94
99
|
expect(logs).toContainEqual({
|
|
95
100
|
level: 'info',
|
|
@@ -117,8 +122,8 @@ describe('offline-push', () => {
|
|
|
117
122
|
|
|
118
123
|
const handler = handlers.get('agent_end')!
|
|
119
124
|
|
|
120
|
-
await handler(
|
|
121
|
-
await handler(
|
|
125
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
126
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
122
127
|
|
|
123
128
|
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(2)
|
|
124
129
|
})
|
|
@@ -129,11 +134,11 @@ describe('offline-push', () => {
|
|
|
129
134
|
|
|
130
135
|
const handler = handlers.get('agent_end')!
|
|
131
136
|
|
|
132
|
-
await handler(
|
|
137
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
133
138
|
expect(lastPushExtras).toEqual({badge: 1})
|
|
134
139
|
expect(mockBadgeCount).toBe(1)
|
|
135
140
|
|
|
136
|
-
await handler(
|
|
141
|
+
await handler(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
137
142
|
expect(lastPushExtras).toEqual({badge: 2})
|
|
138
143
|
expect(mockBadgeCount).toBe(2)
|
|
139
144
|
})
|
|
@@ -143,7 +148,7 @@ describe('offline-push', () => {
|
|
|
143
148
|
const {api, handlers} = createMockApi()
|
|
144
149
|
registerOfflinePush(api)
|
|
145
150
|
|
|
146
|
-
await handlers.get('agent_end')!(
|
|
151
|
+
await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
147
152
|
|
|
148
153
|
expect(lastPushExtras).toEqual({badge: 1})
|
|
149
154
|
expect(mockBadgeCount).toBe(0) // incremented then decremented
|
|
@@ -153,7 +158,7 @@ describe('offline-push', () => {
|
|
|
153
158
|
const {api, logs, handlers} = createMockApi()
|
|
154
159
|
registerOfflinePush(api)
|
|
155
160
|
|
|
156
|
-
await handlers.get('agent_end')!(
|
|
161
|
+
await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:telegram:12345'})
|
|
157
162
|
|
|
158
163
|
expect(logs).toContainEqual({
|
|
159
164
|
level: 'info',
|
|
@@ -166,7 +171,9 @@ describe('offline-push', () => {
|
|
|
166
171
|
const {api, logs, handlers} = createMockApi()
|
|
167
172
|
registerOfflinePush(api)
|
|
168
173
|
|
|
169
|
-
await handlers.get('agent_end')!(
|
|
174
|
+
await handlers.get('agent_end')!(eventWithReply, {
|
|
175
|
+
sessionKey: 'agent:clawly:cron:weather-check:run:abc123',
|
|
176
|
+
})
|
|
170
177
|
|
|
171
178
|
expect(logs).toContainEqual({
|
|
172
179
|
level: 'info',
|
|
@@ -180,7 +187,7 @@ describe('offline-push', () => {
|
|
|
180
187
|
registerOfflinePush(api)
|
|
181
188
|
|
|
182
189
|
const handler = handlers.get('agent_end')!
|
|
183
|
-
await handler(
|
|
190
|
+
await handler(eventWithReply)
|
|
184
191
|
|
|
185
192
|
expect(logs).toContainEqual({
|
|
186
193
|
level: 'info',
|
|
@@ -192,7 +199,10 @@ describe('offline-push', () => {
|
|
|
192
199
|
const {api, handlers} = createMockApi()
|
|
193
200
|
registerOfflinePush(api)
|
|
194
201
|
|
|
195
|
-
await handlers.get('agent_end')!(
|
|
202
|
+
await handlers.get('agent_end')!(eventWithReply, {
|
|
203
|
+
sessionKey: 'agent:clawly:main',
|
|
204
|
+
agentId: 'luna',
|
|
205
|
+
})
|
|
196
206
|
|
|
197
207
|
expect(lastPushOpts?.agentId).toBe('luna')
|
|
198
208
|
})
|
|
@@ -201,8 +211,9 @@ describe('offline-push', () => {
|
|
|
201
211
|
const {api, handlers} = createMockApi()
|
|
202
212
|
registerOfflinePush(api)
|
|
203
213
|
|
|
204
|
-
await handlers.get('agent_end')!(
|
|
214
|
+
await handlers.get('agent_end')!(eventWithReply, {sessionKey: 'agent:clawly:main'})
|
|
205
215
|
|
|
216
|
+
expect(lastPushOpts).not.toBeNull()
|
|
206
217
|
expect(lastPushOpts?.title).toBeUndefined()
|
|
207
218
|
})
|
|
208
219
|
|
|
@@ -223,17 +234,21 @@ describe('offline-push', () => {
|
|
|
223
234
|
expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
|
|
224
235
|
})
|
|
225
236
|
|
|
226
|
-
test('
|
|
227
|
-
const {api, handlers} = createMockApi()
|
|
237
|
+
test('skips push when no messages (no extractable text)', async () => {
|
|
238
|
+
const {api, logs, handlers} = createMockApi()
|
|
228
239
|
registerOfflinePush(api)
|
|
229
240
|
|
|
230
241
|
await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
|
|
231
242
|
|
|
232
|
-
expect(lastPushOpts
|
|
243
|
+
expect(lastPushOpts).toBeNull()
|
|
244
|
+
expect(logs).toContainEqual({
|
|
245
|
+
level: 'warn',
|
|
246
|
+
msg: expect.stringContaining('skipped (no extractable assistant text)'),
|
|
247
|
+
})
|
|
233
248
|
})
|
|
234
249
|
|
|
235
|
-
test('
|
|
236
|
-
const {api, handlers} = createMockApi()
|
|
250
|
+
test('skips push when messages has no assistant role', async () => {
|
|
251
|
+
const {api, logs, handlers} = createMockApi()
|
|
237
252
|
registerOfflinePush(api)
|
|
238
253
|
|
|
239
254
|
await handlers.get('agent_end')!(
|
|
@@ -241,7 +256,11 @@ describe('offline-push', () => {
|
|
|
241
256
|
{sessionKey: 'agent:clawly:main'},
|
|
242
257
|
)
|
|
243
258
|
|
|
244
|
-
expect(lastPushOpts
|
|
259
|
+
expect(lastPushOpts).toBeNull()
|
|
260
|
+
expect(logs).toContainEqual({
|
|
261
|
+
level: 'warn',
|
|
262
|
+
msg: expect.stringContaining('skipped (no extractable assistant text)'),
|
|
263
|
+
})
|
|
245
264
|
})
|
|
246
265
|
|
|
247
266
|
test('body strips [[type:value]] placeholders', async () => {
|
|
@@ -580,15 +599,15 @@ describe('offline-push with filtered messages', () => {
|
|
|
580
599
|
})
|
|
581
600
|
})
|
|
582
601
|
|
|
583
|
-
test('
|
|
602
|
+
test('skips push when event has no messages (no extractable text)', async () => {
|
|
584
603
|
const {api, logs, handlers} = createMockApi()
|
|
585
604
|
registerOfflinePush(api)
|
|
586
605
|
|
|
587
606
|
await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
|
|
588
607
|
|
|
589
608
|
expect(logs).toContainEqual({
|
|
590
|
-
level: '
|
|
591
|
-
msg: expect.stringContaining('
|
|
609
|
+
level: 'warn',
|
|
610
|
+
msg: expect.stringContaining('skipped (no extractable assistant text)'),
|
|
592
611
|
})
|
|
593
612
|
})
|
|
594
613
|
})
|
package/gateway/offline-push.ts
CHANGED
|
@@ -226,6 +226,7 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
226
226
|
|
|
227
227
|
// Extract full assistant text for filtering and preview.
|
|
228
228
|
const fullText = getLastAssistantText(event.messages)
|
|
229
|
+
const triggerText = getTriggeringUserText(event.messages)
|
|
229
230
|
|
|
230
231
|
// Skip if the message would be filtered by the mobile UI.
|
|
231
232
|
if (fullText != null) {
|
|
@@ -270,6 +271,33 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
270
271
|
return
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
// Defensive: if we can't extract assistant text, sending a generic
|
|
275
|
+
// "Your response is ready" is never useful — the message likely wasn't
|
|
276
|
+
// persisted to the transcript either, so the user opens the app to nothing.
|
|
277
|
+
// Log the messages structure for debugging, then bail.
|
|
278
|
+
if (fullText == null || fullText === '') {
|
|
279
|
+
const msgCount = Array.isArray(event.messages) ? event.messages.length : 'n/a'
|
|
280
|
+
const lastRoles = Array.isArray(event.messages)
|
|
281
|
+
? event.messages
|
|
282
|
+
.slice(-5)
|
|
283
|
+
.map(
|
|
284
|
+
(m: any) =>
|
|
285
|
+
`${m?.role ?? '?'}(${typeof m?.content === 'string' ? 'str' : Array.isArray(m?.content) ? `parts:${m.content.length}` : typeof m?.content})`,
|
|
286
|
+
)
|
|
287
|
+
.join(', ')
|
|
288
|
+
: 'n/a'
|
|
289
|
+
api.logger.warn(
|
|
290
|
+
`offline-push: skipped (no extractable assistant text) msgCount=${msgCount} lastRoles=[${lastRoles}] triggerText=${triggerText ? `"${triggerText.slice(0, 80)}"` : 'null'}`,
|
|
291
|
+
)
|
|
292
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'no extractable text', false)
|
|
293
|
+
captureEvent('push.skipped', {
|
|
294
|
+
reason: 'no_extractable_text',
|
|
295
|
+
is_cron: isCron,
|
|
296
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
297
|
+
})
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
273
301
|
// Only send push for the main clawly mobile session and cron sessions —
|
|
274
302
|
// skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
|
|
275
303
|
if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main' && !isCron) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2en/clawly-plugins",
|
|
3
|
-
"version": "1.30.0-beta.
|
|
3
|
+
"version": "1.30.0-beta.4",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"@opentelemetry/resources": "^1.30.0",
|
|
15
15
|
"@opentelemetry/sdk-logs": "^0.57.0",
|
|
16
16
|
"file-type": "^21.3.0",
|
|
17
|
+
"json5": "^2.2.3",
|
|
17
18
|
"mime": "^4.1.0",
|
|
18
19
|
"posthog-node": "^5.28.0",
|
|
19
20
|
"zx": "npm:zx@8.8.5-lite"
|
package/types.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type PluginRuntimeCore = {
|
|
|
22
22
|
version: string
|
|
23
23
|
config: {
|
|
24
24
|
loadConfig: (...args: unknown[]) => unknown
|
|
25
|
-
writeConfigFile: (
|
|
25
|
+
writeConfigFile: (config: OpenClawConfig) => Promise<unknown>
|
|
26
26
|
}
|
|
27
27
|
system: {
|
|
28
28
|
enqueueSystemEvent: (...args: unknown[]) => void
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ensures all dangerous node commands are in gateway.nodes.allowCommands
|
|
3
|
-
* so the AI agent can invoke them on connected Clawly nodes.
|
|
4
|
-
*
|
|
5
|
-
* Dangerous commands are defined by OpenClaw's node-command-policy and
|
|
6
|
-
* must be explicitly allowlisted. Safe commands work without allowlisting.
|
|
7
|
-
*
|
|
8
|
-
* Runs on gateway_start. Writes directly to openclaw.json via
|
|
9
|
-
* writeOpenclawConfig (no restart triggered — gateway.* changes are
|
|
10
|
-
* classified as "none" by the config watcher).
|
|
11
|
-
*/
|
|
12
|
-
import path from 'node:path'
|
|
13
|
-
|
|
14
|
-
import type {PluginApi} from '../types'
|
|
15
|
-
import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
|
|
16
|
-
|
|
17
|
-
const DANGEROUS_COMMANDS = [
|
|
18
|
-
// Browser commands (Mac nodes)
|
|
19
|
-
'browser.proxy',
|
|
20
|
-
'browser.navigate',
|
|
21
|
-
'browser.click',
|
|
22
|
-
'browser.type',
|
|
23
|
-
'browser.screenshot',
|
|
24
|
-
'browser.read',
|
|
25
|
-
'browser.tabs',
|
|
26
|
-
'browser.back',
|
|
27
|
-
'browser.scroll',
|
|
28
|
-
'browser.evaluate',
|
|
29
|
-
// Reminders + calendar (iOS nodes)
|
|
30
|
-
'reminders.add',
|
|
31
|
-
'calendar.add',
|
|
32
|
-
// Device permissions (iOS nodes) — not in OpenClaw's iOS defaults
|
|
33
|
-
'device.permissions',
|
|
34
|
-
'device.requestPermission',
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
export function registerNodeDangerousAllowlist(api: PluginApi) {
|
|
38
|
-
api.on('gateway_start', async () => {
|
|
39
|
-
let stateDir: string
|
|
40
|
-
try {
|
|
41
|
-
stateDir = api.runtime.state.resolveStateDir()
|
|
42
|
-
} catch {
|
|
43
|
-
api.logger.warn('node-dangerous-allowlist: cannot resolve state dir, skipping')
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const configPath = path.join(stateDir, 'openclaw.json')
|
|
48
|
-
let config: Record<string, unknown>
|
|
49
|
-
try {
|
|
50
|
-
config = readOpenclawConfig(configPath)
|
|
51
|
-
} catch {
|
|
52
|
-
api.logger.warn('node-dangerous-allowlist: failed to read config, skipping')
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const gateway = (config.gateway as Record<string, unknown>) ?? {}
|
|
57
|
-
const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
|
|
58
|
-
const existing: string[] = Array.isArray(nodes.allowCommands)
|
|
59
|
-
? (nodes.allowCommands as string[])
|
|
60
|
-
: []
|
|
61
|
-
|
|
62
|
-
const existingSet = new Set(existing)
|
|
63
|
-
const toAdd = DANGEROUS_COMMANDS.filter((cmd) => !existingSet.has(cmd))
|
|
64
|
-
|
|
65
|
-
if (toAdd.length === 0) {
|
|
66
|
-
api.logger.info('node-dangerous-allowlist: all commands already allowlisted')
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
nodes.allowCommands = [...existing, ...toAdd]
|
|
71
|
-
gateway.nodes = nodes
|
|
72
|
-
config.gateway = gateway
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
writeOpenclawConfig(configPath, config)
|
|
76
|
-
} catch {
|
|
77
|
-
api.logger.warn('node-dangerous-allowlist: failed to write config')
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
api.logger.info(
|
|
81
|
-
`node-dangerous-allowlist: added ${toAdd.length} commands to allowlist: ${toAdd.join(', ')}`,
|
|
82
|
-
)
|
|
83
|
-
})
|
|
84
|
-
}
|