From 777b05fffb099083cfa8826d435b7d271dddc95d Mon Sep 17 00:00:00 2001 From: coffeepenbit <13698065+coffeepenbit@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:14:33 -0700 Subject: [PATCH 1/7] fix(opencode): avoid symlink-loop recursion during skill discovery --- packages/opencode/src/skill/index.ts | 61 ++++++++++++++++++---- packages/opencode/test/skill/skill.test.ts | 43 +++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 43a22219edc3..3a6d27db76f6 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -3,6 +3,7 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, ServiceMap } from "effect" +import { readdir, realpath, stat } from "fs/promises" import { NamedError } from "@opencode-ai/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -101,19 +102,57 @@ export namespace Skill { } } + function norm(file: string) { + return file.replaceAll(path.sep, "/") + } + const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { - return Glob.scan(pattern, { - cwd: root, - absolute: true, - include: "file", - symlink: true, - dot: opts?.dot, + if (!(await Filesystem.isDir(root))) return + + const seen = new Set() + + const walk = async (dir: string, rel: string) => { + const key = await realpath(dir).catch(() => dir) + if (seen.has(key)) return + seen.add(key) + + const list = await readdir(dir, { withFileTypes: true }) + for (const item of list) { + if (!opts?.dot && item.name.startsWith(".")) continue + + const abs = path.join(dir, item.name) + const next = rel ? path.join(rel, item.name) : item.name + + if (item.isFile()) { + if (Glob.match(pattern, norm(next))) await add(state, abs) + continue + } + + if (item.isDirectory()) { + await walk(abs, next) + continue + } + + if (!item.isSymbolicLink()) continue + + const info = await stat(abs).catch(() => undefined) + if (!info) continue + + if (info.isFile()) { + if (Glob.match(pattern, norm(next))) await add(state, abs) + continue + } + + if (info.isDirectory()) { + await walk(abs, next) + } + } + } + + return walk(root, "").catch((error) => { + if (!opts?.scope) throw error + log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) }) - .then((matches) => Promise.all(matches.map((match) => add(state, match)))) - .catch((error) => { - if (!opts?.scope) throw error - log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) - }) } // TODO: Migrate to Effect diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 12e16f86a1a3..df9798067e09 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -291,6 +291,49 @@ This skill is loaded from the global home directory. } }) +test("follows symlinked .agents skills without recursing through symlink loops", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const root = path.join(dir, ".codex", "skills") + const skill = path.join(root, "linked-skill") + const loop = path.join(root, ".mise", "state", "trusted-configs") + + await fs.mkdir(skill, { recursive: true }) + await fs.mkdir(path.join(dir, ".agents"), { recursive: true }) + await fs.mkdir(loop, { recursive: true }) + + await Bun.write( + path.join(skill, "SKILL.md"), + `--- +name: linked-skill +description: A skill discovered through a symlinked .agents root. +--- + +# Linked Skill +`, + ) + + await fs.symlink(path.join(dir, ".codex", "skills"), path.join(dir, ".agents", "skills")) + await fs.symlink(dir, path.join(loop, "home")) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const skills = (await Promise.race([ + Skill.all(), + new Promise((_, reject) => setTimeout(() => reject(new Error("skill scan timed out")), 1500)), + ])) as Awaited> + + expect(skills.length).toBe(1) + expect(skills[0].name).toBe("linked-skill") + expect(skills[0].location).toContain(path.join(".agents", "skills", "linked-skill", "SKILL.md")) + }, + }) +}) + test("discovers skills from both .claude/skills/ and .agents/skills/", async () => { await using tmp = await tmpdir({ git: true, From bcd4e172edd3188742977945d27248c18a7a27bc Mon Sep 17 00:00:00 2001 From: coffeepenbit <13698065+coffeepenbit@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:40:38 -0700 Subject: [PATCH 2/7] refactor(opencode): inline helper --- packages/opencode/src/skill/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 3a6d27db76f6..c653d97e4d5b 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -102,10 +102,6 @@ export namespace Skill { } } - function norm(file: string) { - return file.replaceAll(path.sep, "/") - } - const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { if (!(await Filesystem.isDir(root))) return @@ -124,7 +120,7 @@ export namespace Skill { const next = rel ? path.join(rel, item.name) : item.name if (item.isFile()) { - if (Glob.match(pattern, norm(next))) await add(state, abs) + if (Glob.match(pattern, next.replaceAll(path.sep, "/"))) await add(state, abs) continue } @@ -139,7 +135,7 @@ export namespace Skill { if (!info) continue if (info.isFile()) { - if (Glob.match(pattern, norm(next))) await add(state, abs) + if (Glob.match(pattern, next.replaceAll(path.sep, "/"))) await add(state, abs) continue } From 829ce9552165a4a5b183919e5a7ca16f2ca64467 Mon Sep 17 00:00:00 2001 From: coffeepenbit <13698065+coffeepenbit@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:46:57 -0700 Subject: [PATCH 3/7] docs(opencode): clarify skill scan intent --- packages/opencode/src/skill/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index c653d97e4d5b..ba5f2617a808 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -105,6 +105,9 @@ export namespace Skill { const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { if (!(await Filesystem.isDir(root))) return + // Skill roots may be symlinked into other tool-managed trees. Track the + // physical directory so we can follow valid symlinked roots without + // recursing forever when those trees contain symlink cycles. const seen = new Set() const walk = async (dir: string, rel: string) => { @@ -131,6 +134,8 @@ export namespace Skill { if (!item.isSymbolicLink()) continue + // Preserve symlinked skill roots and files, but decide based on the + // target type so symlink cycles are still stopped by the realpath set. const info = await stat(abs).catch(() => undefined) if (!info) continue From 71bbf4941fc1ac0d9bd8746aaa43639d40779c15 Mon Sep 17 00:00:00 2001 From: coffeepenbit <13698065+coffeepenbit@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:19:29 -0700 Subject: [PATCH 4/7] fix(opencode): restore original skill scan roots --- packages/opencode/src/skill/index.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index ba5f2617a808..1352c37eab95 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -103,7 +103,14 @@ export namespace Skill { } const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { - if (!(await Filesystem.isDir(root))) return + // Preserve the old glob scope so we do not walk unrelated siblings such as + // .opencode/node_modules just to find SKILL.md files under skill roots. + const roots = + pattern === EXTERNAL_SKILL_PATTERN + ? [path.join(root, "skills")] + : pattern === OPENCODE_SKILL_PATTERN + ? [path.join(root, "skill"), path.join(root, "skills")] + : [root] // Skill roots may be symlinked into other tool-managed trees. Track the // physical directory so we can follow valid symlinked roots without @@ -123,7 +130,7 @@ export namespace Skill { const next = rel ? path.join(rel, item.name) : item.name if (item.isFile()) { - if (Glob.match(pattern, next.replaceAll(path.sep, "/"))) await add(state, abs) + if (Glob.match(SKILL_PATTERN, next.replaceAll(path.sep, "/"))) await add(state, abs) continue } @@ -140,7 +147,7 @@ export namespace Skill { if (!info) continue if (info.isFile()) { - if (Glob.match(pattern, next.replaceAll(path.sep, "/"))) await add(state, abs) + if (Glob.match(SKILL_PATTERN, next.replaceAll(path.sep, "/"))) await add(state, abs) continue } @@ -150,10 +157,13 @@ export namespace Skill { } } - return walk(root, "").catch((error) => { - if (!opts?.scope) throw error - log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) - }) + for (const dir of roots) { + if (!(await Filesystem.isDir(dir))) continue + await walk(dir, "").catch((error) => { + if (!opts?.scope) throw error + log.error(`failed to scan ${opts.scope} skills`, { dir, error }) + }) + } } // TODO: Migrate to Effect From 1a01ad8d80a6aa1345b14666a1020cf934a035ac Mon Sep 17 00:00:00 2001 From: coffeepenbit <13698065+coffeepenbit@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:52:35 -0700 Subject: [PATCH 5/7] refactor(opencode): improve skill scan readability --- packages/opencode/src/skill/index.ts | 56 +++++++++++++++------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 1352c37eab95..48a96a4abf84 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -103,8 +103,6 @@ export namespace Skill { } const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { - // Preserve the old glob scope so we do not walk unrelated siblings such as - // .opencode/node_modules just to find SKILL.md files under skill roots. const roots = pattern === EXTERNAL_SKILL_PATTERN ? [path.join(root, "skills")] @@ -112,47 +110,53 @@ export namespace Skill { ? [path.join(root, "skill"), path.join(root, "skills")] : [root] - // Skill roots may be symlinked into other tool-managed trees. Track the - // physical directory so we can follow valid symlinked roots without - // recursing forever when those trees contain symlink cycles. - const seen = new Set() - const walk = async (dir: string, rel: string) => { - const key = await realpath(dir).catch(() => dir) - if (seen.has(key)) return - seen.add(key) + // Prevent symlink loops by tracking visited real directories. + const seenDirs = new Set() - const list = await readdir(dir, { withFileTypes: true }) - for (const item of list) { - if (!opts?.dot && item.name.startsWith(".")) continue + const walk = async (dir: string, relativePath: string) => { + const resolvedDir = await realpath(dir).catch(() => dir) + if (seenDirs.has(resolvedDir)) return + seenDirs.add(resolvedDir) - const abs = path.join(dir, item.name) - const next = rel ? path.join(rel, item.name) : item.name + const dirEntries = await readdir(dir, { withFileTypes: true }) + for (const entry of dirEntries) { + // Skip hidden paths + if (!opts?.dot && entry.name.startsWith(".")) continue - if (item.isFile()) { - if (Glob.match(SKILL_PATTERN, next.replaceAll(path.sep, "/"))) await add(state, abs) + const absoluteEntryPath = path.join(dir, entry.name) + const relativeEntryPath = relativePath ? path.join(relativePath, entry.name) : entry.name + + // Match regular files directly against the skill glob + if (entry.isFile()) { + if (Glob.match(SKILL_PATTERN, relativeEntryPath.replaceAll(path.sep, "/"))) await add(state, absoluteEntryPath) continue } - if (item.isDirectory()) { - await walk(abs, next) + // Recurse into subdirs + if (entry.isDirectory()) { + await walk(absoluteEntryPath, relativeEntryPath) continue } - if (!item.isSymbolicLink()) continue + // Symlinks need a second check so we can follow skill roots without + // blindly recursing into link cycles. + if (!entry.isSymbolicLink()) continue // Preserve symlinked skill roots and files, but decide based on the // target type so symlink cycles are still stopped by the realpath set. - const info = await stat(abs).catch(() => undefined) - if (!info) continue + const targetStat = await stat(absoluteEntryPath).catch(() => undefined) + if (!targetStat) continue - if (info.isFile()) { - if (Glob.match(SKILL_PATTERN, next.replaceAll(path.sep, "/"))) await add(state, abs) + // Add symlinked skill files + if (targetStat.isFile()) { + if (Glob.match(SKILL_PATTERN, relativeEntryPath.replaceAll(path.sep, "/"))) await add(state, absoluteEntryPath) continue } - if (info.isDirectory()) { - await walk(abs, next) + // Recurse into unseen symlinked subdirs + if (targetStat.isDirectory()) { + await walk(absoluteEntryPath, relativeEntryPath) } } } From 54687f6f84cfed7de1987384250db960d6a35953 Mon Sep 17 00:00:00 2001 From: coffeepenbit <13698065+coffeepenbit@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:58:45 -0700 Subject: [PATCH 6/7] refactor(opencode): simplify skill scan branching and remove line breaks --- packages/opencode/src/skill/index.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 48a96a4abf84..a93842a856bd 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -103,12 +103,15 @@ export namespace Skill { } const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { - const roots = - pattern === EXTERNAL_SKILL_PATTERN - ? [path.join(root, "skills")] - : pattern === OPENCODE_SKILL_PATTERN - ? [path.join(root, "skill"), path.join(root, "skills")] - : [root] + let roots = [root] + + if (pattern === EXTERNAL_SKILL_PATTERN) { + roots = [path.join(root, "skills")] + } + + if (pattern === OPENCODE_SKILL_PATTERN) { + roots = [path.join(root, "skill"), path.join(root, "skills")] + } // Prevent symlink loops by tracking visited real directories. From 36f292caa169ade1e4e541b91ed9c4c358f9d9e6 Mon Sep 17 00:00:00 2001 From: coffeepenbit <13698065+coffeepenbit@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:02:13 -0700 Subject: [PATCH 7/7] refactor(opencode): clarify skill scan fallback handling --- packages/opencode/src/skill/index.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index a93842a856bd..872d413baee6 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -1,10 +1,3 @@ -import os from "os" -import path from "path" -import { pathToFileURL } from "url" -import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" -import { readdir, realpath, stat } from "fs/promises" -import { NamedError } from "@opencode-ai/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" @@ -13,6 +6,13 @@ import { Flag } from "@/flag/flag" import { Global } from "@/global" import { Permission } from "@/permission" import { Filesystem } from "@/util/filesystem" +import { NamedError } from "@opencode-ai/util/error" +import { Effect, Layer, ServiceMap } from "effect" +import { readdir, realpath, stat } from "fs/promises" +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import z from "zod" import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { Glob } from "../util/glob" @@ -113,12 +113,14 @@ export namespace Skill { roots = [path.join(root, "skill"), path.join(root, "skills")] } - // Prevent symlink loops by tracking visited real directories. const seenDirs = new Set() const walk = async (dir: string, relativePath: string) => { - const resolvedDir = await realpath(dir).catch(() => dir) + const resolvedDir = await realpath(dir).catch(() => { + // Fall back to the original path if the directory disappears mid-scan. + return dir + }) if (seenDirs.has(resolvedDir)) return seenDirs.add(resolvedDir)