From dbbab1044e5c587c81deb5a59bd5d84f9adefc2a Mon Sep 17 00:00:00 2001 From: NianJiu <3235467914@qq.com> Date: Wed, 8 Jul 2026 02:53:03 +0800 Subject: [PATCH] fix: require full frontmatter delimiter lines (#101795) Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com> --- .../markdown-core/src/frontmatter.test.ts | 44 ++++++++++++- packages/markdown-core/src/frontmatter.ts | 62 ++++++++++++++++--- src/agents/sessions/prompt-templates.test.ts | 22 +++++++ src/agents/utils/frontmatter.ts | 30 +++------ src/agents/workspace.ts | 13 +--- src/cli/gateway-cli/dev.ts | 10 +-- src/plugins/bundle-commands.test.ts | 15 +++++ src/plugins/bundle-commands.ts | 19 ++---- src/skills/loading/session.test.ts | 36 +++++++---- src/skills/workshop/frontmatter.test.ts | 23 +++++++ src/skills/workshop/frontmatter.ts | 43 +++---------- 11 files changed, 208 insertions(+), 109 deletions(-) create mode 100644 src/skills/workshop/frontmatter.test.ts diff --git a/packages/markdown-core/src/frontmatter.test.ts b/packages/markdown-core/src/frontmatter.test.ts index e4652a2c6f0c..698f4a720826 100644 --- a/packages/markdown-core/src/frontmatter.test.ts +++ b/packages/markdown-core/src/frontmatter.test.ts @@ -1,7 +1,7 @@ // Markdown Core tests cover frontmatter behavior. import JSON5 from "json5"; import { describe, expect, it } from "vitest"; -import { parseFrontmatterBlock } from "./frontmatter.js"; +import { parseFrontmatterBlock, stripFrontmatterBlock } from "./frontmatter.js"; describe("parseFrontmatterBlock", () => { it("parses YAML block scalars", () => { @@ -103,6 +103,30 @@ metadata: expect(parseFrontmatterBlock(content)).toStrictEqual({}); }); + it("ignores non-delimiter opening prefixes", () => { + for (const prefix of ["---not", "----", "--- name"]) { + const content = `${prefix} +name: nope +--- +Body text`; + expect(parseFrontmatterBlock(content)).toStrictEqual({}); + } + }); + + it("ignores non-delimiter closing prefixes", () => { + const content = `--- +name: nope +---not +Body text`; + expect(parseFrontmatterBlock(content)).toStrictEqual({}); + }); + + it("accepts delimiter lines with trailing whitespace", () => { + const content = ["--- ", "name: sample", "---\t", "Body text"].join("\n"); + expect(parseFrontmatterBlock(content)).toStrictEqual({ name: "sample" }); + expect(stripFrontmatterBlock(content)).toBe("Body text"); + }); + it("preserves prototype-named keys when YAML value is null", () => { const content = `--- title: Hello @@ -133,3 +157,21 @@ Body text`; expect(result.description).toBe("Written by PowerShell"); }); }); + +describe("stripFrontmatterBlock", () => { + it("removes a valid frontmatter block", () => { + const content = `--- +name: sample +--- +Body text`; + expect(stripFrontmatterBlock(content)).toBe("Body text"); + }); + + it("preserves Markdown that starts with a non-delimiter prefix", () => { + const content = `---not +name: nope +---not +Body text`; + expect(stripFrontmatterBlock(content)).toBe(content); + }); +}); diff --git a/packages/markdown-core/src/frontmatter.ts b/packages/markdown-core/src/frontmatter.ts index 954d778003fe..d316731563fd 100644 --- a/packages/markdown-core/src/frontmatter.ts +++ b/packages/markdown-core/src/frontmatter.ts @@ -181,24 +181,72 @@ function shouldPreferInlineLineValue(params: { return lineEntry.value.includes(":"); } -function extractFrontmatterBlock(content: string): string | undefined { - const normalized = content +export type ExtractedFrontmatterBlock = { + block: string; + body: string; +}; + +function normalizeFrontmatterContent(content: string): string { + return content .replace(/^\uFEFF/, "") .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); - if (!normalized.startsWith("---")) { +} + +function isFrontmatterDelimiterLine(line: string): boolean { + return line.trimEnd() === "---"; +} + +function extractFrontmatterBlockFromNormalized( + normalized: string, +): ExtractedFrontmatterBlock | undefined { + const firstLineEnd = normalized.indexOf("\n"); + const firstLine = firstLineEnd === -1 ? normalized : normalized.slice(0, firstLineEnd); + if (!isFrontmatterDelimiterLine(firstLine)) { return undefined; } - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { + if (firstLineEnd === -1) { return undefined; } - return normalized.slice(4, endIndex); + + const blockStart = firstLineEnd + 1; + let lineStart = blockStart; + while (lineStart <= normalized.length) { + const lineEnd = normalized.indexOf("\n", lineStart); + const currentLineEnd = lineEnd === -1 ? normalized.length : lineEnd; + const line = normalized.slice(lineStart, currentLineEnd); + if (isFrontmatterDelimiterLine(line)) { + const blockEnd = lineStart > blockStart ? lineStart - 1 : lineStart; + const bodyStart = lineEnd === -1 ? normalized.length : lineEnd + 1; + return { + block: normalized.slice(blockStart, blockEnd), + body: normalized.slice(bodyStart), + }; + } + if (lineEnd === -1) { + break; + } + lineStart = lineEnd + 1; + } + + return undefined; +} + +/** Splits a complete leading YAML frontmatter block from its Markdown body. */ +export function extractFrontmatterBlock(content: string): ExtractedFrontmatterBlock | undefined { + const normalized = normalizeFrontmatterContent(content); + return extractFrontmatterBlockFromNormalized(normalized); +} + +/** Removes a leading YAML frontmatter block and returns the remaining Markdown body. */ +export function stripFrontmatterBlock(content: string): string { + const normalized = normalizeFrontmatterContent(content); + return (extractFrontmatterBlockFromNormalized(normalized)?.body ?? normalized).trim(); } /** Parses leading YAML frontmatter into string values used by skill and metadata loaders. */ export function parseFrontmatterBlock(content: string): ParsedFrontmatter { - const block = extractFrontmatterBlock(content); + const block = extractFrontmatterBlock(content)?.block; if (!block) { return {}; } diff --git a/src/agents/sessions/prompt-templates.test.ts b/src/agents/sessions/prompt-templates.test.ts index 6618bccc625d..c3c208bfd19d 100644 --- a/src/agents/sessions/prompt-templates.test.ts +++ b/src/agents/sessions/prompt-templates.test.ts @@ -24,4 +24,26 @@ describe("loadPromptTemplates", () => { expect(templates).toHaveLength(1); expect(templates[0]?.description).toBe(`${"a".repeat(59)}...`); }); + + it("preserves dash-prefixed Markdown as prompt content", async () => { + const root = tempDirs.make("openclaw-prompt-templates-"); + const promptsDir = join(root, "prompts"); + await mkdir(promptsDir, { recursive: true }); + const content = "----\nname: bogus\ndescription: must remain Markdown\n---\n# Body\n"; + await writeFile(join(promptsDir, "dash-prefix.md"), content, "utf-8"); + + const templates = loadPromptTemplates({ + cwd: root, + agentDir: join(root, "agent"), + promptPaths: [promptsDir], + includeDefaults: false, + }); + + expect(templates).toHaveLength(1); + expect(templates[0]).toMatchObject({ + name: "dash-prefix", + description: "----", + content, + }); + }); }); diff --git a/src/agents/utils/frontmatter.ts b/src/agents/utils/frontmatter.ts index afe48fd0a533..d917c5f7efdf 100644 --- a/src/agents/utils/frontmatter.ts +++ b/src/agents/utils/frontmatter.ts @@ -5,6 +5,7 @@ * body while preserving normal content when no complete frontmatter fence exists. */ import { parse } from "yaml"; +import { extractFrontmatterBlock } from "../../../packages/markdown-core/src/frontmatter.js"; /** Parsed frontmatter metadata plus the remaining document body. */ type ParsedFrontmatter> = { @@ -15,34 +16,17 @@ type ParsedFrontmatter> = { const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { - const normalized = normalizeNewlines(content); - - if (!normalized.startsWith("---")) { - return { yamlString: null, body: normalized }; - } - - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { - return { yamlString: null, body: normalized }; - } - - return { - yamlString: normalized.slice(4, endIndex), - body: normalized.slice(endIndex + 4).trim(), - }; -}; - /** Parses optional YAML frontmatter from Markdown-like content. */ export const parseFrontmatter = = Record>( content: string, ): ParsedFrontmatter => { - const { yamlString, body } = extractFrontmatter(content); - if (!yamlString) { - return { frontmatter: {} as T, body }; + const normalized = normalizeNewlines(content); + const extracted = extractFrontmatterBlock(normalized); + if (!extracted) { + return { frontmatter: {} as T, body: normalized }; } - const parsed = parse(yamlString); - return { frontmatter: (parsed ?? {}) as T, body }; + const parsed = parse(extracted.block); + return { frontmatter: (parsed ?? {}) as T, body: extracted.body.trim() }; }; /** Removes YAML frontmatter from content when a complete frontmatter block exists. */ diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 9714654ed5e1..5a3ad60f97b3 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -8,6 +8,7 @@ import syncFs from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +import { extractFrontmatterBlock } from "../../packages/markdown-core/src/frontmatter.js"; import { resolveLegacyStateDirs, resolveStateDir } from "../config/paths.js"; import { openRootFile } from "../infra/boundary-file-read.js"; import { pathExists } from "../infra/fs-safe.js"; @@ -133,17 +134,7 @@ async function readWorkspaceFileWithGuards(params: { } function stripFrontMatter(content: string): string { - if (!content.startsWith("---")) { - return content; - } - const endIndex = content.indexOf("\n---", 3); - if (endIndex === -1) { - return content; - } - const start = endIndex + "\n---".length; - let trimmed = content.slice(start); - trimmed = trimmed.replace(/^\s+/, ""); - return trimmed; + return extractFrontmatterBlock(content)?.body.replace(/^\s+/, "") ?? content; } async function loadTemplate(name: string): Promise { diff --git a/src/cli/gateway-cli/dev.ts b/src/cli/gateway-cli/dev.ts index 3b352e35b65b..2ad6d17de31c 100644 --- a/src/cli/gateway-cli/dev.ts +++ b/src/cli/gateway-cli/dev.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { extractFrontmatterBlock } from "../../../packages/markdown-core/src/frontmatter.js"; import { resolveWorkspaceTemplateSearchDirs } from "../../agents/workspace-templates.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { handleReset } from "../../commands/onboard-helpers.js"; @@ -29,14 +30,7 @@ async function loadDevTemplate(name: string, fallback: string): Promise } throw error; } - if (!raw.startsWith("---")) { - return raw; - } - const endIndex = raw.indexOf("\n---", 3); - if (endIndex === -1) { - return raw; - } - return raw.slice(endIndex + "\n---".length).replace(/^\s+/, ""); + return extractFrontmatterBlock(raw)?.body.replace(/^\s+/, "") ?? raw; } } catch { return fallback; diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index c0ab849c3115..2b474c0752d9 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -132,6 +132,10 @@ describe("loadEnabledClaudeBundleCommands", () => { relativePath: "commands/disabled.md", contents: ["---", "disable-model-invocation: true", "---", "Do not load me."], }, + { + relativePath: "commands/not-frontmatter.md", + contents: ["---not", "name: nope", "---not", "Treat this as Markdown."], + }, ], }); @@ -145,6 +149,17 @@ describe("loadEnabledClaudeBundleCommands", () => { }); expectEnabledClaudeBundleCommands(commands, [ + { + pluginId: "compound-bundle", + rawName: "not-frontmatter", + description: "---not", + promptTemplate: "---not\nname: nope\n---not\nTreat this as Markdown.", + sourceFilePath: path.join( + resolveBundlePluginRoot(homeDir, "compound-bundle"), + "commands", + "not-frontmatter.md", + ), + }, { pluginId: "compound-bundle", rawName: "office-hours", diff --git a/src/plugins/bundle-commands.ts b/src/plugins/bundle-commands.ts index eac27b235825..79fb2be3db60 100644 --- a/src/plugins/bundle-commands.ts +++ b/src/plugins/bundle-commands.ts @@ -5,7 +5,10 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { parseFrontmatterBlock } from "../../packages/markdown-core/src/frontmatter.js"; +import { + parseFrontmatterBlock, + stripFrontmatterBlock, +} from "../../packages/markdown-core/src/frontmatter.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readRootJsonObjectSync } from "../infra/json-files.js"; import { isPathInsideWithRealpath } from "../security/scan-paths.js"; @@ -43,18 +46,6 @@ function parseFrontmatterBool(value: string | undefined, fallback: boolean): boo return fallback; } -function stripFrontmatter(content: string): string { - const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (!normalized.startsWith("---")) { - return normalized.trim(); - } - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { - return normalized.trim(); - } - return normalized.slice(endIndex + 4).trim(); -} - function readClaudeBundleManifest(rootDir: string): Record { const result = readRootJsonObjectSync({ rootDir, @@ -133,7 +124,7 @@ function loadBundleCommandsFromRoot(params: { if (parseFrontmatterBool(frontmatter["disable-model-invocation"], false)) { continue; } - const promptTemplate = stripFrontmatter(raw); + const promptTemplate = stripFrontmatterBlock(raw); if (!promptTemplate) { continue; } diff --git a/src/skills/loading/session.test.ts b/src/skills/loading/session.test.ts index 653a6598aea1..6d911a9ae0aa 100644 --- a/src/skills/loading/session.test.ts +++ b/src/skills/loading/session.test.ts @@ -1,21 +1,14 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { loadSkillsFromDir } from "./session.js"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + describe("loadSkillsFromDir", () => { - const tempPaths: string[] = []; - - afterEach(async () => { - await Promise.all( - tempPaths.splice(0).map((entry) => fs.rm(entry, { recursive: true, force: true })), - ); - }); - it("reports directory scan failures as diagnostics", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skill-scan-")); - tempPaths.push(tempDir); + const tempDir = tempDirs.make("openclaw-skill-scan-"); const regularFile = path.join(tempDir, "not-a-directory"); await fs.writeFile(regularFile, "not a skill directory"); @@ -26,4 +19,25 @@ describe("loadSkillsFromDir", () => { expect.objectContaining({ type: "warning", path: regularFile }), ]); }); + + it("does not load dash-prefixed Markdown as frontmatter", async () => { + const tempDir = tempDirs.make("openclaw-skill-scan-"); + const skillDir = path.join(tempDir, "dash-prefix"); + await fs.mkdir(skillDir); + const skillFile = path.join(skillDir, "SKILL.md"); + await fs.writeFile( + skillFile, + "----\nname: bogus\ndescription: must remain Markdown\n---\n# Body\n", + "utf-8", + ); + + const result = loadSkillsFromDir({ dir: tempDir, source: "test" }); + + expect(result.skills).toEqual([]); + expect(result.diagnostics).toContainEqual({ + type: "warning", + message: "description is required", + path: skillFile, + }); + }); }); diff --git a/src/skills/workshop/frontmatter.test.ts b/src/skills/workshop/frontmatter.test.ts new file mode 100644 index 000000000000..fdd6334defe1 --- /dev/null +++ b/src/skills/workshop/frontmatter.test.ts @@ -0,0 +1,23 @@ +// Workshop frontmatter tests cover proposal markdown extraction and stripping. +import { describe, expect, it } from "vitest"; +import { renderProposalMarkdown, stripProposalFrontmatterForSkill } from "./frontmatter.js"; + +describe("workshop proposal frontmatter", () => { + it("preserves dash-prefixed Markdown when rendering proposals", () => { + const rendered = renderProposalMarkdown({ + name: "dash-prefix", + description: "Preserve dash-prefixed Markdown", + content: "---not\nname: nope\n---not\n# Body\n", + date: "2026-07-07T00:00:00.000Z", + }); + + expect(rendered.startsWith('---\nname: "dash-prefix"')).toBe(true); + expect(rendered).toContain("\n---not\nname: nope\n---not\n# Body\n"); + }); + + it("does not strip dash-prefixed Markdown as proposal frontmatter", () => { + expect(stripProposalFrontmatterForSkill("---not\nname: nope\n---not\n# Body\n")).toBe( + "---not\nname: nope\n---not\n# Body\n", + ); + }); +}); diff --git a/src/skills/workshop/frontmatter.ts b/src/skills/workshop/frontmatter.ts index 3474d7cf038c..7ec7f6b836c7 100644 --- a/src/skills/workshop/frontmatter.ts +++ b/src/skills/workshop/frontmatter.ts @@ -1,4 +1,5 @@ // Workshop frontmatter helpers parse generated skill metadata before saving drafts. +import { extractFrontmatterBlock } from "../../../packages/markdown-core/src/frontmatter.js"; import { parseFrontmatter } from "../loading/frontmatter.js"; type ProposalFrontmatter = { @@ -21,9 +22,9 @@ export function renderProposalMarkdown(params: { date?: string; }): string { const originalFrontmatter = - extractFrontmatterBlock(params.content) ?? + extractFrontmatterBlock(params.content)?.block ?? (params.fallbackFrontmatterContent - ? extractFrontmatterBlock(params.fallbackFrontmatterContent) + ? extractFrontmatterBlock(params.fallbackFrontmatterContent)?.block : undefined); const keptFrontmatter = originalFrontmatter ? filterFrontmatterBlock(originalFrontmatter, [ @@ -34,7 +35,8 @@ export function renderProposalMarkdown(params: { "date", ]) : ""; - const body = stripFrontmatterBlock(params.content).trimStart(); + const extracted = extractFrontmatterBlock(params.content); + const body = (extracted?.body ?? normalizeNewlines(params.content)).trimStart(); const version = params.version ?? "v1"; const date = params.date ?? new Date().toISOString(); const frontmatter = [ @@ -64,18 +66,13 @@ export function readProposalFrontmatter(content: string): ProposalFrontmatter | export function stripProposalFrontmatterForSkill(content: string): string { const normalized = normalizeNewlines(content); - if (!normalized.startsWith("---")) { - return normalized.endsWith("\n") ? normalized : `${normalized}\n`; - } - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { + const extracted = extractFrontmatterBlock(normalized); + if (!extracted) { return normalized.endsWith("\n") ? normalized : `${normalized}\n`; } - const rawBlock = normalized.slice(4, endIndex); - const bodyStart = endIndex + "\n---".length; - const body = normalized.slice(bodyStart).replace(/^\n+/, ""); - const keptLines = rawBlock + const body = extracted.body.replace(/^\n+/, ""); + const keptLines = extracted.block .split("\n") .filter((line) => { const key = line.match(/^([\w-]+):/)?.[1]?.toLowerCase(); @@ -88,28 +85,6 @@ export function stripProposalFrontmatterForSkill(content: string): string { return result.endsWith("\n") ? result : `${result}\n`; } -function extractFrontmatterBlock(content: string): string | undefined { - const normalized = normalizeNewlines(content); - if (!normalized.startsWith("---")) { - return undefined; - } - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { - return undefined; - } - return normalized.slice(4, endIndex); -} - -function stripFrontmatterBlock(content: string): string { - const normalized = normalizeNewlines(content); - const block = extractFrontmatterBlock(normalized); - if (block === undefined) { - return normalized; - } - const endIndex = normalized.indexOf("\n---", 3); - return normalized.slice(endIndex + "\n---".length).replace(/^\n+/, ""); -} - function filterFrontmatterBlock(block: string, keysToDrop: readonly string[]): string { const drop = new Set(keysToDrop.map((key) => key.toLowerCase())); const lines = block.split("\n");