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.
- package/README.md +54 -1
- package/dist/api.d.ts +1 -0
- package/dist/api.js +5 -0
- package/dist/cli.js +12 -3
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +13 -3
- package/dist/openclaw/command-provider.d.ts +5 -1
- package/dist/openclaw/command-provider.js +272 -41
- package/dist/openclaw/factory.js +2 -2
- package/dist/openclaw/mock-provider.d.ts +1 -0
- package/dist/openclaw/mock-provider.js +3 -0
- package/dist/openclaw/provider.d.ts +6 -0
- package/dist/openclaw/remote-provider.d.ts +4 -1
- package/dist/openclaw/remote-provider.js +27 -1
- package/dist/orchestrator.js +177 -82
- package/dist/recipe.js +50 -1
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +3 -2
- package/dist/types.d.ts +3 -1
- package/package.json +1 -1
- package/src/api.ts +8 -0
- package/src/cli.ts +13 -3
- package/src/logger.ts +14 -3
- package/src/openclaw/command-provider.ts +309 -43
- package/src/openclaw/factory.ts +2 -2
- package/src/openclaw/mock-provider.ts +4 -0
- package/src/openclaw/provider.ts +7 -0
- package/src/openclaw/remote-provider.ts +31 -1
- package/src/orchestrator.ts +199 -94
- package/src/recipe.ts +61 -2
- package/src/schema.ts +3 -2
- package/src/types.ts +3 -1
|
@@ -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,
|
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
440
|
-
for (const
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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().
|
|
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().
|
|
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;
|