Skip to content

Skills with invalid-YAML frontmatter are silently dropped after the first load in a server process (gray-matter cache poisoning) #51218

Closed

Description

@b00pka

Description

In a long-running opencode serve process, SKILL.md files whose frontmatter is invalid YAML are dropped silently — no error, no warning, no log line. The failure is also inconsistent per instance: the first Skill instance init loads them (via the sanitize() fallback in packages/core/src/config/markdown.ts), but every subsequent init of the same files in the same process drops them.

Observed in 1.18.32: 3 skills whose description: plain scalar contained an unquoted : (e.g. description: Use when the user needs to ... Keywords: crawl, scrape) were on disk. First instance init logged init { count: 22 }, second logged init { count: 19 }, and nothing else appeared in the log. The skills disappeared from available_skills with no diagnostic.

Root cause (all verified against v1.18.32 sources):

  1. gray-matter writes to its module-global content cache before parsing (gray-matter/index.js): matter.cache[file.content] = file runs before parseMatter(), so a failed parse leaves a cache entry with no usable data. Upstream: gray-matter#166 (open since 2023).
  2. packages/core/src/config/markdown.ts calls matter(content) without options → the 2nd+ load returns the poisoned entry without throwing → the sanitize() fallback never runs. (Note: sanitize() already rescues unquoted-colon frontmatter on the first load — the poisoning is what breaks subsequent loads.)
  3. packages/opencode/src/skill/index.ts (add): if (!isSkillFrontmatter(md.data)) return — silent, no log.

Suggested fix (not yet tested — happy to PR once verified):

  • markdown.ts: call matter(content, {}) — passing any options makes gray-matter skip the cache (read and write), so a failed parse can no longer poison later loads.
  • skill/index.ts: log a warning when isSkillFrontmatter(md.data) fails, so drops are visible.
  • Docs gap: the built-in customize-opencode skill (packages/core/src/plugin/skill/customize-opencode.md, ## Skills) recommends long plain-scalar descriptions without stating that frontmatter must be valid YAML — a : inside a plain scalar is invalid and kills the whole file. Suggest adding: quote the value or use a >- block scalar for multi-clause descriptions.

Related: #24181 (same symptom family via a different mechanism, agent frontmatter; closed as not_planned).

Steps to reproduce

  1. Minimal gray-matter repro (any Node with gray-matter):

    const matter = require("gray-matter")
    const c = "---\nname: x\ndescription: Use when: foo\n---\nbody"
    matter(c)   // throws YAMLException
    matter(c)   // returns { data: {}, ... } — no throw
  2. opencode-level:

    1. Create 3 SKILL.md files whose description: plain scalar contains : .
    2. Run opencode serve such that two Skill instances initialize in one process (e.g. two different working directories — each gets its own InstanceState).
    3. First init: INFO init { count: N+3 } — broken skills load via the sanitize() fallback.
    4. Second init: INFO init { count: N } — same files, no error/warning anywhere in the log.
    5. New sessions in the second instance are missing those skills.

Plugins

none

OpenCode version

1.18.32

Screenshot and/or share link

n/a (server logs: init { count: 22 } followed by init { count: 19 }, no other skill-related lines)

Operating System

Linux (headless opencode serve in a node:24-alpine container)

Terminal

headless (server + web UI)

Activity

  1. holny commented on Sep 25, 2026

    @holny
    Contributor

    I'll take this. Confirmed on current v2 — same mechanism, same silent drop: matter(content) in packages/core/src/config/markdown.ts:6 is called without options, so gray-matter uses its module-global content cache, which it writes before parsing (gray-matter/index.js:47). A failed YAML parse leaves a poisoned entry, and every later parse() with the same content returns it without throwing — so the sanitize() fallback at markdown.ts:8 never runs and the skill is dropped by the empty-data check upstream.

    Fix is one line: call matter(content, {}). Passing any options object bypasses the cache read and write (gray-matter only caches when called with no options), so every parse is deterministic and the fallback works on each load. Add a regression test that parses invalid frontmatter twice and asserts the second parse still throws (and the sanitized retry still recovers).

    Fixes #51218

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions