clawchef 0.1.11 → 0.1.13

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.
@@ -29,6 +29,17 @@ interface RemoteOperationResponse {
29
29
  const DEFAULT_TIMEOUT_MS = 60_000;
30
30
  const DEFAULT_OPERATION_PATH = "/v1/clawchef/operation";
31
31
 
32
+ function timestamp(): string {
33
+ const now = new Date();
34
+ const year = now.getFullYear();
35
+ const month = String(now.getMonth() + 1).padStart(2, "0");
36
+ const day = String(now.getDate()).padStart(2, "0");
37
+ const hours = String(now.getHours()).padStart(2, "0");
38
+ const minutes = String(now.getMinutes()).padStart(2, "0");
39
+ const seconds = String(now.getSeconds()).padStart(2, "0");
40
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
41
+ }
42
+
32
43
  function parseResponseBody(raw: string): RemoteOperationResponse {
33
44
  if (!raw.trim()) {
34
45
  return {};
@@ -84,9 +95,18 @@ function assertRemoteConfig(remote: Partial<OpenClawRemoteConfig>): OpenClawRemo
84
95
  export class RemoteOpenClawProvider implements OpenClawProvider {
85
96
  private readonly stagedMessages = new Map<string, StagedMessage[]>();
86
97
  private readonly remoteConfig: Partial<OpenClawRemoteConfig>;
98
+ private readonly verboseEnabled: boolean;
87
99
 
88
- constructor(remoteConfig: Partial<OpenClawRemoteConfig>) {
100
+ constructor(remoteConfig: Partial<OpenClawRemoteConfig>, verboseEnabled = false) {
89
101
  this.remoteConfig = remoteConfig;
102
+ this.verboseEnabled = verboseEnabled;
103
+ }
104
+
105
+ private debug(message: string): void {
106
+ if (!this.verboseEnabled) {
107
+ return;
108
+ }
109
+ process.stdout.write(`[${timestamp()}] [DEBUG] ${message}\n`);
90
110
  }
91
111
 
92
112
  private async perform(
@@ -96,6 +116,7 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
96
116
  dryRun: boolean,
97
117
  ): Promise<RemoteOperationResponse> {
98
118
  if (dryRun) {
119
+ this.debug(`REMOTE DRY-RUN op=${operation}`);
99
120
  return { ok: true };
100
121
  }
101
122
 
@@ -109,6 +130,8 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
109
130
  recipe_version: config.version,
110
131
  payload,
111
132
  };
133
+ const startedAt = Date.now();
134
+ this.debug(`REMOTE START op=${operation} url=${operationUrl(remote)}`);
112
135
 
113
136
  try {
114
137
  const response = await fetch(operationUrl(remote), {
@@ -130,8 +153,11 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
130
153
  throw new ClawChefError(`Remote operation failed for ${operation}: ${parsed.message ?? "unknown error"}`);
131
154
  }
132
155
 
156
+ this.debug(`REMOTE DONE op=${operation} status=${response.status} (${Date.now() - startedAt}ms)`);
157
+
133
158
  return parsed;
134
159
  } catch (err) {
160
+ this.debug(`REMOTE FAIL op=${operation} (${Date.now() - startedAt}ms)`);
135
161
  if (err instanceof ClawChefError) {
136
162
  throw err;
137
163
  }
@@ -192,6 +218,10 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
192
218
  await this.perform(config, "configure_channel", { channel }, dryRun);
193
219
  }
194
220
 
221
+ async applyConfigPatch(config: OpenClawSection, patch: Record<string, unknown>, dryRun: boolean): Promise<void> {
222
+ await this.perform(config, "apply_config_patch", { patch }, dryRun);
223
+ }
224
+
195
225
  async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
196
226
  await this.perform(
197
227
  config,
@@ -8,7 +8,7 @@ import { validateReply } from "./assertions.js";
8
8
  import { ClawChefError } from "./errors.js";
9
9
  import { Logger } from "./logger.js";
10
10
  import { createProvider } from "./openclaw/factory.js";
11
- import type { Recipe, RunOptions } from "./types.js";
11
+ import type { ChannelDef, Recipe, RunOptions } from "./types.js";
12
12
  import type { RecipeOrigin } from "./recipe.js";
13
13
 
14
14
  async function exists(filePath: string): Promise<boolean> {
@@ -27,6 +27,34 @@ function truncateForLog(text: string, maxLength = 500): string {
27
27
  return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
28
28
  }
29
29
 
30
+ function toPosixPath(value: string): string {
31
+ return value.replaceAll("\\", "/");
32
+ }
33
+
34
+ function wildcardToRegExp(pattern: string): RegExp {
35
+ const escaped = toPosixPath(pattern)
36
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
37
+ .replace(/\*\*/g, "___DOUBLE_STAR___")
38
+ .replace(/\*/g, "[^/]*")
39
+ .replace(/___DOUBLE_STAR___/g, ".*");
40
+ return new RegExp(`^${escaped}$`);
41
+ }
42
+
43
+ function matchesFilePatterns(patterns: string[], relativePath: string): boolean {
44
+ if (patterns.length === 0) {
45
+ return true;
46
+ }
47
+ const normalized = toPosixPath(relativePath);
48
+ return patterns.some((pattern) => wildcardToRegExp(pattern).test(normalized));
49
+ }
50
+
51
+ function matchesAnyFilePattern(patterns: string[], candidates: string[]): boolean {
52
+ if (patterns.length === 0) {
53
+ return true;
54
+ }
55
+ return candidates.some((candidate) => matchesFilePatterns(patterns, candidate));
56
+ }
57
+
30
58
  function renderTemplateString(input: string, vars: Record<string, string>, allowMissing: boolean): string {
31
59
  return input.replace(/\$\{([^}]+)\}/g, (_match, rawKey: string) => {
32
60
  const key = String(rawKey).trim();
@@ -84,6 +112,15 @@ function isHttpUrl(value: string): boolean {
84
112
  }
85
113
  }
86
114
 
115
+ function shouldAutoDisableTelegramChannel(channel: ChannelDef): boolean {
116
+ if (channel.channel !== "telegram") {
117
+ return false;
118
+ }
119
+ const emptyToken = channel.token !== undefined && channel.token.trim().length === 0;
120
+ const emptyBotToken = channel.bot_token !== undefined && channel.bot_token.trim().length === 0;
121
+ return emptyToken || emptyBotToken;
122
+ }
123
+
87
124
  function isNotFoundError(err: unknown): boolean {
88
125
  return typeof err === "object" && err !== null && "code" in err && (err as { code?: string }).code === "ENOENT";
89
126
  }
@@ -182,36 +219,42 @@ export async function runRecipe(
182
219
  ): Promise<void> {
183
220
  const provider = createProvider(options);
184
221
  const remoteMode = options.provider === "remote";
222
+ const filesOnlyScope = options.scope === "files";
223
+ const fileFilterEnabled = filesOnlyScope && options.filePatterns.length > 0;
185
224
  const workspacePaths = new Map<string, string>();
186
225
  const preserveExistingState = options.scope !== "full";
187
226
 
188
227
  logger.info(`Running recipe: ${recipe.name}`);
189
- const versionResult = await provider.ensureVersion(
190
- recipe.openclaw,
191
- options.dryRun,
192
- options.silent,
193
- preserveExistingState,
194
- );
195
- logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
196
-
197
- if (versionResult.installedThisRun) {
198
- logger.info("OpenClaw was installed in this run; skipping factory reset");
199
- } else if (preserveExistingState) {
200
- logger.info("Keeping existing OpenClaw state; skipping factory reset");
201
- } else {
202
- const confirmed = await confirmFactoryReset(options);
203
- if (!confirmed) {
204
- throw new ClawChefError("Aborted by user before factory reset");
228
+ if (!filesOnlyScope) {
229
+ const versionResult = await provider.ensureVersion(
230
+ recipe.openclaw,
231
+ options.dryRun,
232
+ options.silent,
233
+ preserveExistingState,
234
+ );
235
+ logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
236
+
237
+ if (versionResult.installedThisRun) {
238
+ logger.info("OpenClaw was installed in this run; skipping factory reset");
239
+ } else if (preserveExistingState) {
240
+ logger.info("Keeping existing OpenClaw state; skipping factory reset");
241
+ } else {
242
+ const confirmed = await confirmFactoryReset(options);
243
+ if (!confirmed) {
244
+ throw new ClawChefError("Aborted by user before factory reset");
245
+ }
246
+ await provider.factoryReset(recipe.openclaw, options.dryRun);
247
+ logger.info("Factory reset completed");
205
248
  }
206
- await provider.factoryReset(recipe.openclaw, options.dryRun);
207
- logger.info("Factory reset completed");
208
- }
209
249
 
210
- const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
211
- .filter((v) => v.length > 0);
212
- for (const pluginSpec of pluginSpecs) {
213
- await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
214
- logger.info(`Plugin preinstalled: ${pluginSpec}`);
250
+ const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
251
+ .filter((v) => v.length > 0);
252
+ for (const pluginSpec of pluginSpecs) {
253
+ await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
254
+ logger.info(`Plugin preinstalled: ${pluginSpec}`);
255
+ }
256
+ } else {
257
+ logger.info("Scope files: only syncing root/workspace assets and files");
215
258
  }
216
259
 
217
260
  const root = recipe.openclaw.root;
@@ -252,6 +295,11 @@ export async function runRecipe(
252
295
  } else {
253
296
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
254
297
  for (const assetFile of assetFiles) {
298
+ const patternCandidates = [assetFile.relativePath, `root/${assetFile.relativePath}`];
299
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
300
+ logger.debug(`Filtered root asset: ${assetFile.relativePath}`);
301
+ continue;
302
+ }
255
303
  const target = path.resolve(openclawRootPath, assetFile.relativePath);
256
304
  if (!options.dryRun) {
257
305
  await mkdir(path.dirname(target), { recursive: true });
@@ -263,6 +311,11 @@ export async function runRecipe(
263
311
  }
264
312
 
265
313
  for (const file of root.files ?? []) {
314
+ const patternCandidates = [file.path, `root/${file.path}`];
315
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
316
+ logger.debug(`Filtered root file: ${file.path}`);
317
+ continue;
318
+ }
266
319
  const target = path.resolve(openclawRootPath, file.path);
267
320
  const targetDir = path.dirname(target);
268
321
 
@@ -297,8 +350,10 @@ export async function runRecipe(
297
350
  if (!options.dryRun && !remoteMode) {
298
351
  await mkdir(absPath, { recursive: true });
299
352
  }
300
- await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
301
- logger.info(`Workspace created: ${ws.name}`);
353
+ if (!filesOnlyScope) {
354
+ await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
355
+ logger.info(`Workspace created: ${ws.name}`);
356
+ }
302
357
 
303
358
  if (!ws.assets?.trim()) {
304
359
  continue;
@@ -328,6 +383,15 @@ export async function runRecipe(
328
383
 
329
384
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
330
385
  for (const assetFile of assetFiles) {
386
+ const patternCandidates = [
387
+ assetFile.relativePath,
388
+ `${ws.name}/${assetFile.relativePath}`,
389
+ `workspace-${ws.name}/${assetFile.relativePath}`,
390
+ ];
391
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
392
+ logger.debug(`Filtered workspace asset: ${ws.name}/${assetFile.relativePath}`);
393
+ continue;
394
+ }
331
395
  if (provider.materializeFile) {
332
396
  const content = await readFile(assetFile.absolutePath, "utf8");
333
397
  await provider.materializeFile(
@@ -349,28 +413,58 @@ export async function runRecipe(
349
413
  }
350
414
  }
351
415
 
352
- for (const agent of recipe.agents ?? []) {
353
- const workspacePath = workspacePaths.get(agent.workspace);
354
- if (!workspacePath) {
355
- throw new ClawChefError(`Agent references missing workspace: ${agent.workspace}`);
416
+ if (!filesOnlyScope) {
417
+ const pendingChannelBindings: Array<{ channel: ChannelDef; agent: string }> = [];
418
+
419
+ for (const agent of recipe.agents ?? []) {
420
+ const workspacePath = workspacePaths.get(agent.workspace);
421
+ if (!workspacePath) {
422
+ throw new ClawChefError(`Agent references missing workspace: ${agent.workspace}`);
423
+ }
424
+ await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
425
+ logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
356
426
  }
357
- await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
358
- logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
359
- }
360
427
 
361
- for (const channel of recipe.channels ?? []) {
362
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
363
- ? { ...channel, account: channel.agent.trim() }
364
- : channel;
365
-
366
- await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
367
- logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
368
- if (effectiveChannel.agent?.trim()) {
369
- await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
370
- logger.info(
371
- `Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`,
372
- );
428
+ for (const channel of recipe.channels ?? []) {
429
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
430
+ ? { ...channel, account: channel.agent.trim() }
431
+ : channel;
432
+ const autoDisabledTelegram = shouldAutoDisableTelegramChannel(effectiveChannel);
433
+
434
+ await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
435
+ if (autoDisabledTelegram) {
436
+ logger.info(
437
+ `Telegram channel disabled due to empty bot token: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`,
438
+ );
439
+ continue;
440
+ }
441
+
442
+ logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
443
+ if (effectiveChannel.agent?.trim()) {
444
+ pendingChannelBindings.push({ channel: effectiveChannel, agent: effectiveChannel.agent });
445
+ }
373
446
  }
447
+
448
+ if (pendingChannelBindings.length > 0) {
449
+ if (provider.bindChannelAgents) {
450
+ await provider.bindChannelAgents(recipe.openclaw, pendingChannelBindings, options.dryRun);
451
+ } else {
452
+ for (const binding of pendingChannelBindings) {
453
+ await provider.bindChannelAgent(recipe.openclaw, binding.channel, binding.agent, options.dryRun);
454
+ }
455
+ }
456
+
457
+ for (const binding of pendingChannelBindings) {
458
+ logger.info(
459
+ `Channel bound to agent: ${binding.channel.channel}${binding.channel.account ? `/${binding.channel.account}` : ""} -> ${binding.agent}`,
460
+ );
461
+ }
462
+ }
463
+ }
464
+
465
+ if (!filesOnlyScope && recipe.openclaw.config_patch) {
466
+ await provider.applyConfigPatch(recipe.openclaw, recipe.openclaw.config_patch, options.dryRun);
467
+ logger.info("OpenClaw config patch applied");
374
468
  }
375
469
 
376
470
  for (const workspace of recipe.workspaces ?? []) {
@@ -380,6 +474,15 @@ export async function runRecipe(
380
474
  }
381
475
 
382
476
  for (const file of workspace.files ?? []) {
477
+ const patternCandidates = [
478
+ file.path,
479
+ `${workspace.name}/${file.path}`,
480
+ `workspace-${workspace.name}/${file.path}`,
481
+ ];
482
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
483
+ logger.debug(`Filtered workspace file: ${workspace.name}/${file.path}`);
484
+ continue;
485
+ }
383
486
  if (provider.materializeFile) {
384
487
  let content = file.content;
385
488
  if (content === undefined && file.content_from) {
@@ -436,64 +539,66 @@ export async function runRecipe(
436
539
  }
437
540
  }
438
541
 
439
- for (const agent of recipe.agents ?? []) {
440
- for (const skill of agent.skills ?? []) {
441
- await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
442
- logger.info(`Skill installed: ${agent.workspace}/${agent.name} -> ${skill}`);
542
+ if (!filesOnlyScope) {
543
+ for (const agent of recipe.agents ?? []) {
544
+ for (const skill of agent.skills ?? []) {
545
+ await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
546
+ logger.info(`Skill installed: ${agent.workspace}/${agent.name} -> ${skill}`);
547
+ }
443
548
  }
444
- }
445
549
 
446
- for (const conv of recipe.conversations ?? []) {
447
- for (const msg of conv.messages) {
448
- await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
550
+ for (const conv of recipe.conversations ?? []) {
551
+ for (const msg of conv.messages) {
552
+ await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
449
553
 
450
- const shouldRun = conv.run ?? Boolean(msg.expect);
451
- if (shouldRun) {
452
- if (options.dryRun) {
453
- logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
454
- continue;
455
- }
456
- const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
457
- if (msg.expect) {
458
- try {
459
- validateReply(reply, msg.expect);
460
- logger.info(`Output assertions passed: ${conv.workspace}/${conv.agent}`);
461
- } catch (err) {
462
- logger.warn(
463
- `Assertion failed reply (truncated): ${truncateForLog(reply)}`,
464
- );
465
- throw err;
554
+ const shouldRun = conv.run ?? Boolean(msg.expect);
555
+ if (shouldRun) {
556
+ if (options.dryRun) {
557
+ logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
558
+ continue;
466
559
  }
467
- } else {
468
- logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
560
+ const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
561
+ if (msg.expect) {
562
+ try {
563
+ validateReply(reply, msg.expect);
564
+ logger.info(`Output assertions passed: ${conv.workspace}/${conv.agent}`);
565
+ } catch (err) {
566
+ logger.warn(
567
+ `Assertion failed reply (truncated): ${truncateForLog(reply)}`,
568
+ );
569
+ throw err;
570
+ }
571
+ } else {
572
+ logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
573
+ }
574
+ logger.debug(`Agent output: ${reply}`);
469
575
  }
470
- logger.debug(`Agent output: ${reply}`);
471
576
  }
577
+ logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
472
578
  }
473
- logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
474
- }
475
579
 
476
- await provider.startGateway(recipe.openclaw, options.gatewayMode, options.dryRun);
477
- if (options.gatewayMode === "none") {
478
- logger.info("Gateway start skipped by gateway mode: none");
479
- } else {
480
- logger.info(`Gateway started (${options.gatewayMode})`);
481
- }
482
-
483
- for (const channel of recipe.channels ?? []) {
484
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
485
- ? { ...channel, account: channel.agent.trim() }
486
- : channel;
487
- if (!effectiveChannel.login) {
488
- continue;
580
+ await provider.startGateway(recipe.openclaw, options.gatewayMode, options.dryRun);
581
+ if (options.gatewayMode === "none") {
582
+ logger.info("Gateway start skipped by gateway mode: none");
583
+ } else {
584
+ logger.info(`Gateway started (${options.gatewayMode})`);
489
585
  }
490
- if (!options.dryRun && !input.isTTY) {
491
- throw new ClawChefError(
492
- `Channel login for ${effectiveChannel.channel} requires an interactive terminal session`,
493
- );
586
+
587
+ for (const channel of recipe.channels ?? []) {
588
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
589
+ ? { ...channel, account: channel.agent.trim() }
590
+ : channel;
591
+ if (!effectiveChannel.login) {
592
+ continue;
593
+ }
594
+ if (!options.dryRun && !input.isTTY) {
595
+ throw new ClawChefError(
596
+ `Channel login for ${effectiveChannel.channel} requires an interactive terminal session`,
597
+ );
598
+ }
599
+ await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
600
+ logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
494
601
  }
495
- await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
496
- logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
497
602
  }
498
603
 
499
604
  logger.info("Recipe execution completed");
package/src/recipe.ts CHANGED
@@ -7,7 +7,7 @@ import YAML from "js-yaml";
7
7
  import { recipeSchema } from "./schema.js";
8
8
  import { ClawChefError } from "./errors.js";
9
9
  import { deepResolveTemplates } from "./template.js";
10
- import type { Recipe, RunOptions } from "./types.js";
10
+ import type { ChannelDef, Recipe, RunOptions } from "./types.js";
11
11
 
12
12
  export type RecipeOrigin =
13
13
  | {
@@ -73,6 +73,42 @@ const CHANNEL_SECRET_FIELDS = ["token", "bot_token", "access_token", "app_token"
73
73
 
74
74
  const TEMPLATE_TOKEN_RE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
75
75
 
76
+ function assertNoInlineSecretsInObject(value: unknown, pathLabel: string): void {
77
+ if (Array.isArray(value)) {
78
+ for (let i = 0; i < value.length; i += 1) {
79
+ assertNoInlineSecretsInObject(value[i], `${pathLabel}[${i}]`);
80
+ }
81
+ return;
82
+ }
83
+ if (!value || typeof value !== "object") {
84
+ return;
85
+ }
86
+
87
+ for (const [key, nestedValue] of Object.entries(value)) {
88
+ const nextPath = `${pathLabel}.${key}`;
89
+ if (
90
+ typeof nestedValue === "string" &&
91
+ /(token|password|secret|api[_-]?key|webhook)/i.test(key) &&
92
+ nestedValue.trim().length > 0 &&
93
+ !TEMPLATE_TOKEN_RE.test(nestedValue)
94
+ ) {
95
+ throw new ClawChefError(
96
+ `Inline secret in ${nextPath} is not allowed. Use \${var} and pass it via --var or CLAWCHEF_VAR_*`,
97
+ );
98
+ }
99
+ assertNoInlineSecretsInObject(nestedValue, nextPath);
100
+ }
101
+ }
102
+
103
+ function hasExplicitEmptyTelegramToken(channel: ChannelDef): boolean {
104
+ if (channel.channel !== "telegram") {
105
+ return false;
106
+ }
107
+ const emptyToken = channel.token !== undefined && channel.token.trim().length === 0;
108
+ const emptyBotToken = channel.bot_token !== undefined && channel.bot_token.trim().length === 0;
109
+ return emptyToken || emptyBotToken;
110
+ }
111
+
76
112
  function assertNoInlineSecrets(recipe: Recipe): void {
77
113
  const bootstrap = recipe.openclaw.bootstrap;
78
114
  if (bootstrap) {
@@ -89,6 +125,10 @@ function assertNoInlineSecrets(recipe: Recipe): void {
89
125
  }
90
126
  }
91
127
 
128
+ if (recipe.openclaw.config_patch) {
129
+ assertNoInlineSecretsInObject(recipe.openclaw.config_patch, "openclaw.config_patch");
130
+ }
131
+
92
132
  for (const channel of recipe.channels ?? []) {
93
133
  for (const field of CHANNEL_SECRET_FIELDS) {
94
134
  const value = channel[field];
@@ -162,6 +202,21 @@ function collectVars(recipe: Recipe, cliVars: Record<string, string>, requiredKe
162
202
  }
163
203
 
164
204
  function projectRecipeForScope(recipe: Recipe, options: RunOptions): Recipe {
205
+ if (options.scope === "files") {
206
+ return {
207
+ ...recipe,
208
+ openclaw: {
209
+ ...recipe.openclaw,
210
+ version: "0.0.0",
211
+ bootstrap: undefined,
212
+ plugins: [],
213
+ },
214
+ channels: [],
215
+ agents: [],
216
+ conversations: [],
217
+ };
218
+ }
219
+
165
220
  if (options.scope !== "workspace") {
166
221
  return recipe;
167
222
  }
@@ -291,6 +346,10 @@ function semanticValidate(recipe: Recipe): void {
291
346
  return /(token|password|secret|api[_-]?key|webhook)/i.test(key) && String(value).trim().length > 0;
292
347
  });
293
348
 
349
+ if (hasExplicitEmptyTelegramToken(channel)) {
350
+ continue;
351
+ }
352
+
294
353
  if (!hasAuth) {
295
354
  throw new ClawChefError(
296
355
  `channels[] entry for ${channel.channel} requires at least one auth input (for example token/bot_token/access_token/token_file/use_env)`,
@@ -819,7 +878,7 @@ export async function loadRecipe(recipePath: string, options: RunOptions): Promi
819
878
 
820
879
  assertNoInlineSecrets(projected);
821
880
 
822
- const requiredKeys = options.scope === "workspace" ? new Set<string>() : undefined;
881
+ const requiredKeys = options.scope === "workspace" || options.scope === "files" ? new Set<string>() : undefined;
823
882
  const vars = collectVars(projected, options.vars, requiredKeys);
824
883
  const rendered = deepResolveTemplates(projected, vars, options.allowMissing);
825
884
  const secondParse = recipeSchema.safeParse(rendered);
package/src/schema.ts CHANGED
@@ -78,6 +78,7 @@ const openClawSchema = z
78
78
  install: z.enum(["auto", "always", "never"]).optional(),
79
79
  plugins: z.array(z.string().min(1)).optional(),
80
80
  root: openClawRootSchema.optional(),
81
+ config_patch: z.record(z.unknown()).optional(),
81
82
  bootstrap: openClawBootstrapSchema.optional(),
82
83
  commands: openClawCommandsSchema.optional(),
83
84
  })
@@ -117,10 +118,10 @@ const channelSchema = z
117
118
  login_mode: z.enum(["interactive"]).optional(),
118
119
  login_account: z.string().min(1).optional(),
119
120
  name: z.string().min(1).optional(),
120
- token: z.string().min(1).optional(),
121
+ token: z.string().optional(),
121
122
  token_file: z.string().min(1).optional(),
122
123
  use_env: z.boolean().optional(),
123
- bot_token: z.string().min(1).optional(),
124
+ bot_token: z.string().optional(),
124
125
  access_token: z.string().min(1).optional(),
125
126
  app_token: z.string().min(1).optional(),
126
127
  webhook_url: z.string().min(1).optional(),
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type InstallPolicy = "auto" | "always" | "never";
2
2
  export type OpenClawProvider = "command" | "mock" | "remote";
3
- export type RunScope = "full" | "files" | "workspace";
3
+ export type RunScope = "full" | "stateful" | "files" | "workspace";
4
4
  export type GatewayMode = "service" | "run" | "none";
5
5
 
6
6
  export interface OpenClawRemoteConfig {
@@ -64,6 +64,7 @@ export interface OpenClawSection {
64
64
  install?: InstallPolicy;
65
65
  plugins?: string[];
66
66
  root?: OpenClawRootDef;
67
+ config_patch?: Record<string, unknown>;
67
68
  bootstrap?: OpenClawBootstrap;
68
69
  commands?: OpenClawCommandOverrides;
69
70
  }
@@ -151,6 +152,7 @@ export interface Recipe {
151
152
  export interface RunOptions {
152
153
  vars: Record<string, string>;
153
154
  plugins: string[];
155
+ filePatterns: string[];
154
156
  scope: RunScope;
155
157
  workspaceName?: string;
156
158
  gatewayMode: GatewayMode;