anomalyco / anomalyco/opencode

Skills silently vanish on re-parse: gray-matter cache is poisoned when strict-YAML frontmatter parse throws

Open
#42,350 1 comment 0 reactions 1 assignee View on GitHub

@nexxeln is already working on this.

Since Aug 13, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Summary

Project skills (and potentially agents/commands parsed via ConfigMarkdown.parse) silently vanish whenever the same markdown content is parsed more than once in a single server process and its frontmatter is not strict-YAML-valid (e.g. an unquoted description: containing : ).

Root cause: gray-matter 4.0.3 registers its module-level cache entry before parsing. When strict YAML parsing throws, the never-parsed entry (data: {}) stays in matter.cache keyed by the file content. opencode's sanitize() fallback rescues the first parse per unique content per process — but every later matter(content) call hits the poisoned cache entry, returns data: {} without throwing (so the fallback never runs), and Skill.add() drops the file silently at the isSkillFrontmatter guard.

This looks like the actual root cause behind #41751 ("exactly N skills silently dropped in server mode") and plausibly #41213.

Minimal repro (gray-matter level)

import matter from "gray-matter"

const s = `---\nname: x\ndescription: Use this. Trigger keywords: "e2e", "test"\n---\nbody`

try { matter(s) } catch (e) { /* YAMLException — opencode falls back to sanitize() here */ }
console.log(matter(s).data) // => {}   <- poisoned cache: no throw, no data, skill silently dropped

packages/core/src/config/markdown.ts:

export function parse(content: string) {
  try {
    return matter(content)          // 1st call: throws AND poisons matter.cache[content]
  } catch {
    return matter(sanitize(content)) // rescues the 1st call only
  }
}

gray-matter 4.0.3, index.js:

let file = toFile(input);
const cached = matter.cache[file.content];
if (!options) {
  if (cached) { ...return shallow copy... }
  matter.cache[file.content] = file;   // <- cached BEFORE parseMatter() runs
}
return parseMatter(file, options);     // <- throws on strict-YAML errors, cache entry stays empty

Server-level repro (v1.18.16, also reproduced on 1.18.18 via bunx opencode-ai@1.18.18)

  1. A project with skills whose description: contains an unquoted : (very common — e.g. ... Trigger keywords: "foo", "bar").
  2. opencode serve --port 4097 (with or without OPENCODE_EXPERIMENTAL_WORKSPACES).
  3. curl -H "x-opencode-directory: <project>" :4097/skill → all skills present (first discovery: throw → sanitize fallback works).
  4. curl -X POST -H "x-opencode-directory: <project>" :4097/instance/dispose, then GET /skill again → all affected project skills gone (count drops from e.g. 66 to 42), reproducible 15/15 times. No error or warning in logs — init count=… simply shrinks.

Because the cache is keyed by content, the poisoning also crosses instances and directories:

  • Mirror layouts (.claude/skills / .agents/skills symlinked to .opencode/skills): the 3 scans see identical content; only the first-parsed path survives — this is also why no duplicate skill name warnings appear for project mirrors.
  • Sibling forks: once repo A's add-e2e-test/SKILL.md was parsed, repo B's byte-identical copy loads as data:{} — opening a second project in the same server silently loses its skills. We measured fresh instances loading 0/12, 3/15, 6/20, 1/13 of their skills depending on how much identical content earlier instances had already parsed (the survivors were exactly the files with unique content).
  • CLI/TUI single-shot runs are mostly unaffected (one discovery per process), matching the "works in CLI, breaks in server mode" observation in #41751.

Suggested fix

Bypass gray-matter's cache in ConfigMarkdown.parse — any truthy options object disables it:

export function parse(content: string) {
  try {
    return matter(content, {})           // no cache read/write -> no poisoning
  } catch {
    return matter(sanitize(content), {})
  }
}

(Or delete matter.cache[content] before the fallback.) Skill discovery re-parses at most a few hundred small files per instance creation, so losing the cache is negligible. Additionally, Skill.add()'s isSkillFrontmatter failure path could log a warning instead of silently returning — that would have made this diagnosable from logs.

Workaround for affected users

Make skill frontmatter strict-YAML valid, e.g. convert unquoted colon-containing descriptions to block scalars (description: |-). After that, the first parse never throws, nothing is poisoned, and server-mode discovery is stable across /instance/dispose (verified: 8/8 full loads on the same previously-affected server).

Environment

  • opencode v1.18.16 (homebrew) and v1.18.18 (bunx opencode-ai), macOS arm64, Bun 1.3.14
  • gray-matter 4.0.3 (as pinned by packages/core)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.