@jiggai/recipes 0.4.56 → 0.4.59

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.
@@ -2,7 +2,7 @@
2
2
  "id": "recipes",
3
3
  "name": "Recipes",
4
4
  "description": "Markdown recipes that scaffold agents and teams (workspace-local).",
5
- "version": "0.4.56",
5
+ "version": "0.4.59",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.4.56",
3
+ "version": "0.4.59",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -13,7 +13,7 @@
13
13
  "pluginApiRange": ">=2026.3"
14
14
  },
15
15
  "build": {
16
- "openclawVersion": "2026.3.28"
16
+ "openclawVersion": "2026.4.20"
17
17
  }
18
18
  },
19
19
  "publishConfig": {
@@ -542,6 +542,10 @@ templates:
542
542
  [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]]
543
543
  }
544
544
 
545
+ safe_ref() {
546
+ [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$ ]]
547
+ }
548
+
545
549
  cmd="${1:-}"
546
550
  shift || true
547
551
 
@@ -559,7 +563,7 @@ templates:
559
563
  task_id=""
560
564
  spec_text=""
561
565
  spec_file_in=""
562
- base_ref="${SWARM_BASE_REF}"
566
+ base_ref="${SWARM_BASE_REF:-}"
563
567
  branch=""
564
568
  tmux_session=""
565
569
  agent_kind="codex"
@@ -635,6 +639,14 @@ templates:
635
639
  echo "Worktree directory already exists: $worktree_dir" >&2
636
640
  exit 2
637
641
  fi
642
+ if ! safe_ref "$base_ref"; then
643
+ echo "invalid --base-ref: must match ^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$" >&2
644
+ exit 2
645
+ fi
646
+ if ! safe_ref "$branch"; then
647
+ echo "invalid --branch: must match ^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$" >&2
648
+ exit 2
649
+ fi
638
650
  git worktree add "$worktree_dir" -b "$branch" "$base_ref"
639
651
 
640
652
  # Start tmux session.
@@ -13,7 +13,7 @@ import {
13
13
  asRecord, asString,
14
14
  ensureDir, fileExists,
15
15
  moveRunTicket, appendRunLog, nodeLabel,
16
- loadNodeStatesFromRun, sanitizeDraftOnlyText, templateReplace,
16
+ loadNodeStatesFromRun, sanitizeDraftOnlyText, templateReplace, expandFileIncludes,
17
17
  } from './workflow-utils';
18
18
 
19
19
  export async function resolveApprovalBindingTarget(api: OpenClawPluginApi, bindingId: string): Promise<{ channel: string; target: string; accountId?: string }> {
@@ -180,7 +180,10 @@ export async function executeWorkflowNodes(opts: {
180
180
  }
181
181
  await ensureDir(path.dirname(nodeOutputAbs));
182
182
 
183
- const prompt = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
183
+ const promptRaw = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
184
+ // Inline `{{file:<relative-path>}}` contents so LLM nodes can see workspace files
185
+ // they cannot fetch themselves (llm-task is one-shot, no tool loop).
186
+ const prompt = await expandFileIncludes(promptRaw, teamDir);
184
187
  const task = [
185
188
  `You are executing a workflow run for teamId=${teamId}.`,
186
189
  `Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
@@ -158,6 +158,81 @@ export function templateReplace(input: string, vars: Record<string, string>) {
158
158
  return out;
159
159
  }
160
160
 
161
+ export const FILE_INCLUDE_MAX_BYTES_DEFAULT = 256 * 1024;
162
+
163
+ /**
164
+ * Expand `{{file:<relative-path>}}` markers by inlining the contents of files
165
+ * under the team workspace. Intended for LLM prompts where the model cannot
166
+ * call a read_file tool itself (workflow llm-task is one-shot, no tool loop).
167
+ *
168
+ * Safety:
169
+ * - paths are treated as RELATIVE TO teamDir
170
+ * - rejects absolute paths, paths with `..`, and paths that escape teamDir (incl. via symlink)
171
+ * - enforces a per-include byte cap (default 256KB)
172
+ *
173
+ * On any rejection the marker is replaced with a short `[[file-include …]]`
174
+ * note so the LLM sees the failure context instead of the pipeline blowing up.
175
+ */
176
+ export async function expandFileIncludes(
177
+ input: string,
178
+ teamDir: string,
179
+ opts: { maxBytes?: number } = {},
180
+ ): Promise<string> {
181
+ const text = String(input ?? '');
182
+ const pattern = /\{\{\s*file:([^}]+?)\s*\}\}/g;
183
+ const matches = Array.from(text.matchAll(pattern));
184
+ if (!matches.length) return text;
185
+
186
+ const maxBytes = opts.maxBytes ?? FILE_INCLUDE_MAX_BYTES_DEFAULT;
187
+ const teamDirResolvedRaw = path.resolve(teamDir);
188
+ // Resolve symlinks on teamDir too so comparisons below are apples-to-apples.
189
+ // On macOS, os.tmpdir() returns /var/... but realpath yields /private/var/... .
190
+ let teamDirResolved = teamDirResolvedRaw;
191
+ try { teamDirResolved = await fs.realpath(teamDirResolvedRaw); } catch { /* fall back to resolved */ }
192
+ const teamDirPrefix = teamDirResolved + path.sep;
193
+
194
+ const resolved = new Map<string, string>();
195
+ for (const m of matches) {
196
+ const rawPath = m[1].trim();
197
+ if (resolved.has(rawPath)) continue;
198
+
199
+ if (!rawPath || path.isAbsolute(rawPath) || rawPath.split('/').includes('..')) {
200
+ resolved.set(rawPath, `[[file-include rejected: unsafe path "${rawPath}"]]`);
201
+ continue;
202
+ }
203
+
204
+ const candidate = path.resolve(teamDirResolved, rawPath);
205
+ if (candidate !== teamDirResolved && !candidate.startsWith(teamDirPrefix)) {
206
+ resolved.set(rawPath, `[[file-include rejected: outside team workspace "${rawPath}"]]`);
207
+ continue;
208
+ }
209
+
210
+ try {
211
+ const real = await fs.realpath(candidate);
212
+ if (real !== teamDirResolved && !real.startsWith(teamDirPrefix)) {
213
+ resolved.set(rawPath, `[[file-include rejected: symlink escapes team workspace "${rawPath}"]]`);
214
+ continue;
215
+ }
216
+ const stat = await fs.stat(real);
217
+ if (!stat.isFile()) {
218
+ resolved.set(rawPath, `[[file-include rejected: not a regular file "${rawPath}"]]`);
219
+ continue;
220
+ }
221
+ if (stat.size > maxBytes) {
222
+ resolved.set(rawPath, `[[file-include rejected: "${rawPath}" size ${stat.size}B exceeds ${maxBytes}B cap]]`);
223
+ continue;
224
+ }
225
+ const content = await fs.readFile(real, 'utf8');
226
+ resolved.set(rawPath, content);
227
+ } catch (err) {
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ resolved.set(rawPath, `[[file-include failed: "${rawPath}" — ${msg}]]`);
230
+ }
231
+ }
232
+
233
+ return text.replace(pattern, (_full, raw) => resolved.get(String(raw).trim()) ?? '');
234
+ }
235
+
161
236
  export function sanitizeDraftOnlyText(text: string): string {
162
237
  // Back-compat: older workflow nodes mention 'draft only'.
163
238
  // New canonical sanitizer also strips other internal-only disclaimer lines.
@@ -22,7 +22,7 @@ import {
22
22
  moveRunTicket, appendRunLog, writeRunFile, loadRunFile,
23
23
  runFilePathFor, nodeLabel,
24
24
  loadNodeStatesFromRun, pickNextRunnableNodeIndex,
25
- sanitizeDraftOnlyText, templateReplace,
25
+ sanitizeDraftOnlyText, templateReplace, expandFileIncludes,
26
26
  } from './workflow-utils';
27
27
 
28
28
  /**
@@ -780,7 +780,10 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
780
780
  const promptRaw = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
781
781
 
782
782
  const vars = await buildTemplateVars(teamDir, runsDir, runId, workflowFile, workflow);
783
- const prompt = templateReplace(promptRaw, vars);
783
+ const promptVarsResolved = templateReplace(promptRaw, vars);
784
+ // Inline `{{file:<relative-path>}}` contents so LLM nodes can see workspace files
785
+ // they cannot fetch themselves (llm-task is one-shot, no tool loop).
786
+ const prompt = await expandFileIncludes(promptVarsResolved, teamDir);
784
787
 
785
788
  // Build output format instructions from outputFields when defined
786
789
  const nodeConfig = asRecord((node as unknown as Record<string, unknown>)['config']);
@@ -1240,7 +1243,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1240
1243
  const vars = await buildTemplateVars(teamDir, runsDir, task.runId, workflowFile, workflow);
1241
1244
  // Add node-level vars that templateReplace doesn't normally include
1242
1245
  vars['node.id'] = node.id;
1243
- const prompt = templateReplace(promptTemplateRaw, vars);
1246
+ const promptVarsResolved = templateReplace(promptTemplateRaw, vars);
1247
+ const prompt = await expandFileIncludes(promptVarsResolved, teamDir);
1244
1248
  const outputRelPath = templateReplace(outputPathRaw, vars);
1245
1249
 
1246
1250
  const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);