From 88c251fc14820561bf21bfb426710bfc37981913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Sun, 31 May 2026 04:12:11 +0000 Subject: [PATCH 1/5] feat: !include YAML tag and folder-based workflow layout - Add !include custom YAML tag for referencing external files (Fixes #582) - .md/.txt files included as strings - .json files parsed as JSON objects - .yaml/.yml files parsed as YAML objects - Paths resolved relative to the workflow YAML file - Support foo/index.yaml as alternative to foo.yaml (Fixes #583) - Updated discoverProjectWorkflows(), findWorkflowInDir() - Updated workflowNameFromPath() for index.yaml detection - Flat files take priority over folder layout - Added tests for both features --- .../src/__tests__/include-tag.test.ts | 60 +++++++++++++++++++ .../src/__tests__/workflow-resolution.test.ts | 37 ++++++++++++ packages/cli-workflow/src/commands/thread.ts | 11 +++- .../cli-workflow/src/commands/workflow.ts | 4 +- packages/cli-workflow/src/include.ts | 25 ++++++++ packages/cli-workflow/src/store.ts | 27 ++++++--- packages/cli-workflow/src/validate.ts | 10 ++-- 7 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/include-tag.test.ts create mode 100644 packages/cli-workflow/src/include.ts diff --git a/packages/cli-workflow/src/__tests__/include-tag.test.ts b/packages/cli-workflow/src/__tests__/include-tag.test.ts new file mode 100644 index 0000000..8c738ed --- /dev/null +++ b/packages/cli-workflow/src/__tests__/include-tag.test.ts @@ -0,0 +1,60 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { parse } from "yaml"; +import { createIncludeTag } from "../include.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "include-tag-test-")); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe("!include tag", () => { + test("includes .md file as string", async () => { + await writeFile(join(tmpDir, "prompt.md"), "You are an analyst."); + const yaml = "system: !include prompt.md"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.system).toBe("You are an analyst."); + }); + + test("includes .json file as parsed object", async () => { + await writeFile(join(tmpDir, "schema.json"), '{"type":"object","properties":{}}'); + const yaml = "outputSchema: !include schema.json"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.outputSchema).toEqual({ type: "object", properties: {} }); + }); + + test("includes .yaml file as parsed object", async () => { + await writeFile(join(tmpDir, "config.yaml"), "key: value\nlist:\n - a\n - b"); + const yaml = "config: !include config.yaml"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.config).toEqual({ key: "value", list: ["a", "b"] }); + }); + + test("resolves relative subdirectory paths", async () => { + const subdir = join(tmpDir, "roles"); + await mkdir(subdir, { recursive: true }); + await writeFile(join(subdir, "analyst.md"), "Analyze data."); + const yaml = "system: !include roles/analyst.md"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.system).toBe("Analyze data."); + }); + + test("throws on missing file", () => { + const yaml = "system: !include nonexistent.md"; + expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow(); + }); + + test("includes .txt file as string", async () => { + await writeFile(join(tmpDir, "note.txt"), "Hello world"); + const yaml = "note: !include note.txt"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.note).toBe("Hello world"); + }); +}); diff --git a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts index 9c38ae0..d8d8106 100644 --- a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts +++ b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts @@ -257,6 +257,43 @@ describe("Strategy 3: Local Discovery", () => { expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); }); + + test("should find workflow in folder-based layout (name/index.yaml)", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow", "solve-issue"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "index.yaml"), await createWorkflowYaml("solve-issue")); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).name).toBe("solve-issue"); + } + }); + + test("should prefer flat file over folder-based layout", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue", "flat")); + + const folderDir = join(workflowDir, "solve-issue"); + await mkdir(folderDir, { recursive: true }); + await writeFile(join(folderDir, "index.yaml"), await createWorkflowYaml("solve-issue", "folder")); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).description).toBe("Test workflow (flat)"); + } + }); }); // ── Strategy 4: Global Registry Fallback ────────────────────────────────────── diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 67dc39e..16a4c8c 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -27,6 +27,7 @@ import type { AdapterOutput } from "@uncaged/workflow-util-agent"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent"; import { config as loadDotenv } from "dotenv"; import { parse } from "yaml"; +import { createIncludeTag } from "../include.js"; import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js"; import { evaluate } from "../moderator/index.js"; import { @@ -118,6 +119,10 @@ async function findWorkflowInDir(dir: string, name: string): Promise { const dir = join(projectRoot, ".workflows"); - let entries: string[]; + let dirents: Dirent[]; try { - entries = await readdir(dir); + dirents = await readdir(dir, { withFileTypes: true }); } catch (e) { const err = e as NodeJS.ErrnoException; if (err.code === "ENOENT" || err.code === "ENOTDIR") { @@ -39,12 +40,22 @@ export async function discoverProjectWorkflows( } const result: ProjectWorkflowEntry[] = []; - for (const entry of entries) { - if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) { - continue; + for (const entry of dirents) { + if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) { + const stem = entry.name.endsWith(".yaml") ? entry.name.slice(0, -5) : entry.name.slice(0, -4); + result.push({ name: stem, filePath: join(dir, entry.name) }); + } else if (entry.isDirectory()) { + for (const indexName of ["index.yaml", "index.yml"]) { + const indexPath = join(dir, entry.name, indexName); + try { + await access(indexPath); + result.push({ name: entry.name, filePath: indexPath }); + break; + } catch { + // not found, try next + } + } } - const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4); - result.push({ name: stem, filePath: join(dir, entry) }); } return result; } diff --git a/packages/cli-workflow/src/validate.ts b/packages/cli-workflow/src/validate.ts index bf94022..76683c6 100644 --- a/packages/cli-workflow/src/validate.ts +++ b/packages/cli-workflow/src/validate.ts @@ -1,4 +1,4 @@ -import { basename } from "node:path"; +import { basename, dirname } from "node:path"; import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol"; const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/; @@ -68,9 +68,11 @@ function isGraph(value: unknown): boolean { */ export function workflowNameFromPath(filePath: string): string { const base = basename(filePath); - if (base.endsWith(".yaml")) return base.slice(0, -5); - if (base.endsWith(".yml")) return base.slice(0, -4); - return base; + const stem = base.endsWith(".yaml") ? base.slice(0, -5) : base.endsWith(".yml") ? base.slice(0, -4) : base; + if (stem === "index") { + return basename(dirname(filePath)); + } + return stem; } /** From da1678ffef2375ea027a9b3bad6c68fa4f62d7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Sun, 31 May 2026 04:26:54 +0000 Subject: [PATCH 2/5] fix: address review feedback on !include and folder workflow - Fix nested !include: pass customTags recursively, scoped to included file's dir - Add path traversal guard: !include paths must resolve within base directory - Fix discoverProjectWorkflows: scan both .workflow/ and .workflows/ (consistent with findWorkflowInDir) - Add tests: path traversal blocking, nested !include, absolute path rejection --- .../src/__tests__/include-tag.test.ts | 24 +++++++++++++++ packages/cli-workflow/src/include.ts | 18 ++++++++++-- packages/cli-workflow/src/store.ts | 29 +++++++++++++++---- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/cli-workflow/src/__tests__/include-tag.test.ts b/packages/cli-workflow/src/__tests__/include-tag.test.ts index 8c738ed..a061c06 100644 --- a/packages/cli-workflow/src/__tests__/include-tag.test.ts +++ b/packages/cli-workflow/src/__tests__/include-tag.test.ts @@ -57,4 +57,28 @@ describe("!include tag", () => { const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); expect(result.note).toBe("Hello world"); }); + + test("blocks path traversal with ../", async () => { + const yaml = "secret: !include ../../etc/passwd"; + expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow( + /path traversal blocked/, + ); + }); + + test("blocks absolute path traversal", async () => { + const yaml = "secret: !include /etc/passwd"; + expect(() => parse(yaml, { customTags: [createIncludeTag(tmpDir)] })).toThrow( + /path traversal blocked/, + ); + }); + + test("supports nested !include in yaml files", async () => { + const subdir = join(tmpDir, "parts"); + await mkdir(subdir, { recursive: true }); + await writeFile(join(subdir, "inner.md"), "nested content"); + await writeFile(join(tmpDir, "outer.yaml"), "value: !include parts/inner.md"); + const yaml = "config: !include outer.yaml"; + const result = parse(yaml, { customTags: [createIncludeTag(tmpDir)] }); + expect(result.config).toEqual({ value: "nested content" }); + }); }); diff --git a/packages/cli-workflow/src/include.ts b/packages/cli-workflow/src/include.ts index 4e4014f..b34bb01 100644 --- a/packages/cli-workflow/src/include.ts +++ b/packages/cli-workflow/src/include.ts @@ -1,23 +1,35 @@ import { readFileSync } from "node:fs"; -import { extname, resolve } from "node:path"; +import { dirname, extname, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; /** * Create a YAML customTags entry for !include that resolves file paths * relative to the given base directory. + * + * Security: resolved paths must stay within baseDir (path traversal prevention). + * Nested !include in .yaml/.yml files is supported (customTags passed recursively). */ export function createIncludeTag(baseDir: string) { + const resolvedBase = resolve(baseDir); return { tag: "!include", resolve(str: string) { - const filePath = resolve(baseDir, str); + const filePath = resolve(resolvedBase, str); + // Path traversal guard: resolved path must be inside baseDir + if (!filePath.startsWith(resolvedBase + "/") && filePath !== resolvedBase) { + throw new Error( + `!include path traversal blocked: "${str}" resolves outside base directory`, + ); + } const content = readFileSync(filePath, "utf8"); const ext = extname(filePath).toLowerCase(); if (ext === ".json") { return JSON.parse(content); } if (ext === ".yaml" || ext === ".yml") { - return parseYaml(content); + // Pass customTags recursively so nested !include works, + // scoped to the included file's directory + return parseYaml(content, { customTags: [createIncludeTag(dirname(filePath))] }); } return content; }, diff --git a/packages/cli-workflow/src/store.ts b/packages/cli-workflow/src/store.ts index 4abb41b..7b1f847 100644 --- a/packages/cli-workflow/src/store.ts +++ b/packages/cli-workflow/src/store.ts @@ -21,13 +21,10 @@ export type ProjectWorkflowEntry = { }; /** - * Scan `/.workflows/*.yaml` (non-recursive) and return discovered entries. - * Returns an empty array if the directory does not exist. + * Scan a single directory for workflow entries (flat YAML files + folder/index.yaml). + * Returns discovered entries. Returns empty array if directory does not exist. */ -export async function discoverProjectWorkflows( - projectRoot: string, -): Promise { - const dir = join(projectRoot, ".workflows"); +async function scanWorkflowDir(dir: string): Promise { let dirents: Dirent[]; try { dirents = await readdir(dir, { withFileTypes: true }); @@ -60,6 +57,26 @@ export async function discoverProjectWorkflows( return result; } +/** + * Scan `/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries. + * .workflow/ takes priority: if a name is found in both, .workflow/ wins. + * Returns an empty array if neither directory exists. + */ +export async function discoverProjectWorkflows( + projectRoot: string, +): Promise { + const primary = await scanWorkflowDir(join(projectRoot, ".workflow")); + const legacy = await scanWorkflowDir(join(projectRoot, ".workflows")); + const seen = new Set(primary.map((e) => e.name)); + const merged = [...primary]; + for (const entry of legacy) { + if (!seen.has(entry.name)) { + merged.push(entry); + } + } + return merged; +} + /** Default filesystem root for uwf data (`~/.uncaged/workflow`). */ export function getDefaultStorageRoot(): string { return join(homedir(), ".uncaged", "workflow"); From 806edb2750af1c729635220b428a63754fa4dcf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Sun, 31 May 2026 04:44:09 +0000 Subject: [PATCH 3/5] style: fix biome lint (import sorting, formatting) --- packages/cli-workflow/src/store.ts | 2 +- packages/cli-workflow/src/validate.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli-workflow/src/store.ts b/packages/cli-workflow/src/store.ts index 7b1f847..e8578ee 100644 --- a/packages/cli-workflow/src/store.ts +++ b/packages/cli-workflow/src/store.ts @@ -1,5 +1,5 @@ -import { access, appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import type { Dirent } from "node:fs"; +import { access, appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; diff --git a/packages/cli-workflow/src/validate.ts b/packages/cli-workflow/src/validate.ts index 76683c6..83a68aa 100644 --- a/packages/cli-workflow/src/validate.ts +++ b/packages/cli-workflow/src/validate.ts @@ -68,7 +68,11 @@ function isGraph(value: unknown): boolean { */ export function workflowNameFromPath(filePath: string): string { const base = basename(filePath); - const stem = base.endsWith(".yaml") ? base.slice(0, -5) : base.endsWith(".yml") ? base.slice(0, -4) : base; + const stem = base.endsWith(".yaml") + ? base.slice(0, -5) + : base.endsWith(".yml") + ? base.slice(0, -4) + : base; if (stem === "index") { return basename(dirname(filePath)); } From f8c06ada64ee0944ad5c2567b9dd5fc828f3d1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Sun, 31 May 2026 04:48:16 +0000 Subject: [PATCH 4/5] style: fix biome lint (template literal, import sorting) --- packages/cli-workflow/src/commands/thread.ts | 16 +++++++++++++--- packages/cli-workflow/src/include.ts | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 16a4c8c..dad5e31 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -27,8 +27,8 @@ import type { AdapterOutput } from "@uncaged/workflow-util-agent"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent"; import { config as loadDotenv } from "dotenv"; import { parse } from "yaml"; -import { createIncludeTag } from "../include.js"; import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js"; +import { createIncludeTag } from "../include.js"; import { evaluate } from "../moderator/index.js"; import { appendThreadHistory, @@ -121,7 +121,12 @@ async function findWorkflowInDir(dir: string, name: string): Promise Date: Sun, 31 May 2026 04:52:08 +0000 Subject: [PATCH 5/5] fix: biome 2.4.16 migration, reduce scanWorkflowDir complexity, fix formatting --- biome.json | 2 +- .../src/__tests__/workflow-resolution.test.ts | 10 ++++- .../cli-workflow/src/commands/workflow.ts | 4 +- packages/cli-workflow/src/store.ts | 39 +++++++++++++------ 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/biome.json b/biome.json index b1d0c18..4cc600d 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "files": { "includes": [ "**", diff --git a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts index d8d8106..664c265 100644 --- a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts +++ b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts @@ -279,11 +279,17 @@ describe("Strategy 3: Local Discovery", () => { await makeUwfStore(storageRoot); const workflowDir = join(projectRoot, ".workflow"); await mkdir(workflowDir, { recursive: true }); - await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue", "flat")); + await writeFile( + join(workflowDir, "solve-issue.yaml"), + await createWorkflowYaml("solve-issue", "flat"), + ); const folderDir = join(workflowDir, "solve-issue"); await mkdir(folderDir, { recursive: true }); - await writeFile(join(folderDir, "index.yaml"), await createWorkflowYaml("solve-issue", "folder")); + await writeFile( + join(folderDir, "index.yaml"), + await createWorkflowYaml("solve-issue", "folder"), + ); const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); diff --git a/packages/cli-workflow/src/commands/workflow.ts b/packages/cli-workflow/src/commands/workflow.ts index 45be273..8dc2b05 100644 --- a/packages/cli-workflow/src/commands/workflow.ts +++ b/packages/cli-workflow/src/commands/workflow.ts @@ -125,7 +125,9 @@ export async function cmdWorkflowAdd( let raw: unknown; try { - raw = parse(text, { customTags: [createIncludeTag(dirname(resolvePath(filePath)))] }) as unknown; + raw = parse(text, { + customTags: [createIncludeTag(dirname(resolvePath(filePath)))], + }) as unknown; } catch (e) { fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`); } diff --git a/packages/cli-workflow/src/store.ts b/packages/cli-workflow/src/store.ts index e8578ee..f491f71 100644 --- a/packages/cli-workflow/src/store.ts +++ b/packages/cli-workflow/src/store.ts @@ -20,6 +20,30 @@ export type ProjectWorkflowEntry = { filePath: string; }; +/** Extract workflow name from a YAML filename (strip .yaml/.yml extension). */ +function stemFromYaml(name: string): string { + if (name.endsWith(".yaml")) return name.slice(0, -5); + if (name.endsWith(".yml")) return name.slice(0, -4); + return name; +} + +/** Check if a directory contains an index.yaml or index.yml workflow file. */ +async function findIndexWorkflow( + dir: string, + dirName: string, +): Promise { + for (const indexName of ["index.yaml", "index.yml"]) { + const indexPath = join(dir, dirName, indexName); + try { + await access(indexPath); + return { name: dirName, filePath: indexPath }; + } catch { + // not found, try next + } + } + return null; +} + /** * Scan a single directory for workflow entries (flat YAML files + folder/index.yaml). * Returns discovered entries. Returns empty array if directory does not exist. @@ -39,18 +63,11 @@ async function scanWorkflowDir(dir: string): Promise { const result: ProjectWorkflowEntry[] = []; for (const entry of dirents) { if (entry.isFile() && (entry.name.endsWith(".yaml") || entry.name.endsWith(".yml"))) { - const stem = entry.name.endsWith(".yaml") ? entry.name.slice(0, -5) : entry.name.slice(0, -4); - result.push({ name: stem, filePath: join(dir, entry.name) }); + result.push({ name: stemFromYaml(entry.name), filePath: join(dir, entry.name) }); } else if (entry.isDirectory()) { - for (const indexName of ["index.yaml", "index.yml"]) { - const indexPath = join(dir, entry.name, indexName); - try { - await access(indexPath); - result.push({ name: entry.name, filePath: indexPath }); - break; - } catch { - // not found, try next - } + const found = await findIndexWorkflow(dir, entry.name); + if (found !== null) { + result.push(found); } } }