From 0c97c161ee50ac21c9005174625229bd993aae89 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 11:52:24 -0700 Subject: [PATCH] FN-7860: honor plugin skillFiles paths for skill body resolution Plugin skills declared with PluginSkillContribution.skillFiles were silently ignored by the host, forcing plugin authors into a flat skills//SKILL.md layout instead of category subdirectories. - Add packages/core/src/plugin-skill-paths.ts with resolvePluginSkillBodyPath (honors skillFiles[0] relative to plugin root, falls back to skills//SKILL.md, rejects path traversal) and resolvePluginRootFromEntryPath - Track per-plugin absolute roots in PluginLoader and expose pluginRoot alongside each getPluginSkills() contribution - Thread pluginRoot/skillFiles through PluginRunner, dashboard server/chat structural types, and skills-adapter so discovered plugin skill path/relativePath resolve via the new traversal-guarded resolver when a pluginRoot is available, keeping the old name-derived path for backward compatibility otherwise - Export resolvePluginSkillBodyPath/resolvePluginRootFromEntryPath/PluginSkillBodyPath from @fusion/core - Update docs/PLUGIN_AUTHORING.md and add unit tests covering the new resolver and updated plugin-loader/skills-adapter/plugin-runner behavior - Add changeset (@runfusion/fusion: minor, category: fix) Files changed: .changeset/fn-7860-plugin-skillfiles.md | 7 ++ docs/PLUGIN_AUTHORING.md | 4 +- packages/core/src/__tests__/plugin-loader.test.ts | 23 +++++++ .../core/src/__tests__/plugin-skill-paths.test.ts | 75 ++++++++++++++++++++++ packages/core/src/index.ts | 5 ++ packages/core/src/plugin-loader.ts | 20 +++++- packages/core/src/plugin-skill-paths.ts | 58 +++++++++++++++++ .../dashboard/src/__tests__/skills-adapter.test.ts | 75 +++++++++++++++++++++- packages/dashboard/src/chat.ts | 2 +- packages/dashboard/src/server.ts | 2 +- packages/dashboard/src/skills-adapter.ts | 19 ++++-- .../engine/src/__tests__/plugin-runner.test.ts | 2 +- packages/engine/src/plugin-runner.ts | 4 +- 13 files changed, 280 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-7860 Fusion-Task-Lineage: 720cf527-9c6f-4877-838e-5fb64bd86556 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7860-plugin-skillfiles.md | 7 ++ docs/PLUGIN_AUTHORING.md | 4 +- .../core/src/__tests__/plugin-loader.test.ts | 23 ++++++ .../src/__tests__/plugin-skill-paths.test.ts | 75 +++++++++++++++++++ packages/core/src/index.ts | 5 ++ packages/core/src/plugin-loader.ts | 20 ++++- packages/core/src/plugin-skill-paths.ts | 58 ++++++++++++++ .../src/__tests__/skills-adapter.test.ts | 75 ++++++++++++++++++- packages/dashboard/src/chat.ts | 2 +- packages/dashboard/src/server.ts | 2 +- packages/dashboard/src/skills-adapter.ts | 19 +++-- .../src/__tests__/plugin-runner.test.ts | 2 +- packages/engine/src/plugin-runner.ts | 4 +- 13 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 .changeset/fn-7860-plugin-skillfiles.md create mode 100644 packages/core/src/__tests__/plugin-skill-paths.test.ts create mode 100644 packages/core/src/plugin-skill-paths.ts diff --git a/.changeset/fn-7860-plugin-skillfiles.md b/.changeset/fn-7860-plugin-skillfiles.md new file mode 100644 index 0000000000..78aec5b9b7 --- /dev/null +++ b/.changeset/fn-7860-plugin-skillfiles.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Honor plugin skillFiles paths so plugin skills can live in category subdirectories. +category: fix +dev: Adds traversal-guarded plugin skill body path resolution and carries pluginRoot through skill discovery. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 72f24127a3..2f2f7d0e74 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1496,14 +1496,14 @@ const skills: PluginSkillContribution[] = [ skillId: "web-research", name: "Web Research", description: "Finds and summarizes web sources for a task", - skillFiles: ["skills/web-research/SKILL.md"], + skillFiles: ["skills/research/web-research/SKILL.md"], enabled: true, triggerPatterns: ["research", "search the web", "find sources"], }, ]; ``` -`skillFiles` are relative to the plugin root. `skillId` must be kebab-case. +`skillFiles` are relative to the plugin root. The first entry, `skillFiles[0]`, is the authoritative body file that Fusion resolves for the skill, so plugins can organize skill bodies in category subdirectories such as `skills/research/web-research/SKILL.md` while keeping a short `skillId`. When `skillFiles` is omitted or empty, Fusion falls back to the compatibility path `skills//SKILL.md`. `skillId` must be kebab-case. Plugin skills are discovered per requesting project: the Skills view and workflow editor surface `plugin:` skills only when that plugin is enabled for that project's plugin state, even if the daemon was started from a different directory. diff --git a/packages/core/src/__tests__/plugin-loader.test.ts b/packages/core/src/__tests__/plugin-loader.test.ts index a17726eaec..5544ca1c70 100644 --- a/packages/core/src/__tests__/plugin-loader.test.ts +++ b/packages/core/src/__tests__/plugin-loader.test.ts @@ -2756,15 +2756,38 @@ export default plugin; it("getPluginSkills returns skills with pluginId", async () => { await pluginStore.init(); loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + const pluginRoot = join(rootDir, "plugins", "skills-plugin"); (loader as any).plugins.set("skills-plugin", { manifest: makeManifest({ id: "skills-plugin" }), state: "started", hooks: {}, skills: [{ skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }], } as FusionPlugin); + (loader as any).pluginRoots.set("skills-plugin", pluginRoot); expect(loader.getPluginSkills()).toEqual([ { pluginId: "skills-plugin", + pluginRoot, + skill: { skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }, + }, + ]); + }); + + it("getPluginSkills carries the resolved absolute pluginRoot after load", async () => { + await pluginStore.init(); + loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + const pluginDir = join(rootDir, "plugins", "skills-plugin"); + const plugin = makePlugin(makeManifest({ id: "skills-plugin" })); + plugin.skills = [{ skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }]; + const pluginPath = await writePluginModule(pluginDir, "index.js", plugin); + await pluginStore.registerPlugin({ manifest: plugin.manifest, path: pluginPath }); + + await loader.loadPlugin("skills-plugin"); + + expect(loader.getPluginSkills()).toEqual([ + { + pluginId: "skills-plugin", + pluginRoot: pluginDir, skill: { skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }, }, ]); diff --git a/packages/core/src/__tests__/plugin-skill-paths.test.ts b/packages/core/src/__tests__/plugin-skill-paths.test.ts new file mode 100644 index 0000000000..fbc7cfc0f0 --- /dev/null +++ b/packages/core/src/__tests__/plugin-skill-paths.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { join, resolve } from "node:path"; +import { resolvePluginSkillBodyPath } from "../plugin-skill-paths.js"; +import type { PluginSkillContribution } from "../plugin-types.js"; + +function skill(overrides: Partial = {}): PluginSkillContribution { + return { + skillId: "entity-framework-core", + name: "entity-framework-core", + description: "EF Core guidance", + skillFiles: [], + ...overrides, + }; +} + +describe("resolvePluginSkillBodyPath", () => { + const pluginRoot = resolve("/tmp/fusion-plugin"); + + it("honors category-subdir skillFiles as the skill body path", () => { + const result = resolvePluginSkillBodyPath( + skill({ skillFiles: ["skills/data/entity-framework-core/SKILL.md"] }), + pluginRoot, + ); + + expect(result).toEqual({ + relativePath: "skills/data/entity-framework-core/SKILL.md", + absolutePath: join(pluginRoot, "skills/data/entity-framework-core/SKILL.md"), + }); + }); + + it("honors flat skillFiles without changing existing relative paths", () => { + const result = resolvePluginSkillBodyPath( + skill({ skillFiles: ["skills/entity-framework-core/SKILL.md"] }), + pluginRoot, + ); + + expect(result.relativePath).toBe("skills/entity-framework-core/SKILL.md"); + expect(result.absolutePath).toBe(join(pluginRoot, "skills/entity-framework-core/SKILL.md")); + }); + + it("falls back to the name-derived path when skillFiles is empty or absent", () => { + expect(resolvePluginSkillBodyPath(skill({ skillFiles: [] }), pluginRoot)).toEqual({ + relativePath: "skills/entity-framework-core/SKILL.md", + absolutePath: join(pluginRoot, "skills/entity-framework-core/SKILL.md"), + }); + expect(resolvePluginSkillBodyPath(skill({ skillFiles: undefined as unknown as string[] }), pluginRoot)).toEqual({ + relativePath: "skills/entity-framework-core/SKILL.md", + absolutePath: join(pluginRoot, "skills/entity-framework-core/SKILL.md"), + }); + }); + + it("guards traversal and falls back without resolving outside the plugin root", () => { + const result = resolvePluginSkillBodyPath( + skill({ skillFiles: ["../outside/SKILL.md"] }), + pluginRoot, + ); + + expect(result.relativePath).toBe("skills/entity-framework-core/SKILL.md"); + expect(result.absolutePath.startsWith(`${pluginRoot}/`)).toBe(true); + }); + + it("uses only skillFiles[0] as the authoritative body path", () => { + const result = resolvePluginSkillBodyPath( + skill({ + skillFiles: [ + "skills/first/SKILL.md", + "skills/second/SKILL.md", + ], + }), + pluginRoot, + ); + + expect(result.relativePath).toBe("skills/first/SKILL.md"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4af6b164ad..5a42aeaa8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,11 @@ export { resolvePluginSkillEnabled, } from "./skill-settings.js"; export type { SkillSettingState, SkillSettingsScope } from "./skill-settings.js"; +export { + resolvePluginRootFromEntryPath, + resolvePluginSkillBodyPath, +} from "./plugin-skill-paths.js"; +export type { PluginSkillBodyPath } from "./plugin-skill-paths.js"; export { redactSecrets } from "./redact-secrets.js"; export { evaluatePromptCondition, diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index ab2fdc415e..c06cc4db0c 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -45,6 +45,7 @@ import { normalizePluginUiContributionDefinition, validatePluginManifest } from import { createLogger } from "./logger.js"; import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js"; import { scanPluginSecurity } from "./plugin-security-scan.js"; +import { resolvePluginRootFromEntryPath } from "./plugin-skill-paths.js"; // Minimum Fusion version for plugin compatibility checks (can be expanded later) const MINIMUM_FUSION_VERSION = "0.1.0"; @@ -203,6 +204,9 @@ export class PluginLoader extends EventEmitter<{ /** Cache of dynamically imported modules */ private loadedModules: Map = new Map(); + /** Absolute plugin package roots keyed by plugin id. */ + private pluginRoots: Map = new Map(); + private readonly log = createLogger("plugin-loader"); constructor(private options: PluginLoaderOptions) { @@ -379,6 +383,7 @@ export class PluginLoader extends EventEmitter<{ // Update plugin state locally and store plugin.state = "started"; this.plugins.set(pluginId, plugin); + this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath)); // Call onLoad hook const ctx = await this.createContext(plugin); @@ -387,6 +392,7 @@ export class PluginLoader extends EventEmitter<{ } catch (loadErr) { // onLoad failed - clean up and propagate error this.plugins.delete(pluginId); + this.pluginRoots.delete(pluginId); const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr); await this.options.pluginStore.updatePluginState( pluginId, @@ -407,6 +413,7 @@ export class PluginLoader extends EventEmitter<{ // Ensure plugin is removed from loaded map on any failure // (it may have been added above before the onLoad hook) this.plugins.delete(pluginId); + this.pluginRoots.delete(pluginId); // Error isolation: set error state but don't crash const errorMsg = err instanceof Error ? err.message : String(err); @@ -600,6 +607,7 @@ export class PluginLoader extends EventEmitter<{ // Replace in plugins map this.plugins.set(pluginId, newPlugin); + this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath)); // Create fresh context and call onLoad const ctx = await this.createContext(newPlugin); @@ -626,6 +634,7 @@ export class PluginLoader extends EventEmitter<{ try { // Restore old plugin this.plugins.set(pluginId, snapshot); + this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath)); // Attempt to reactivate old plugin const ctx = await this.createContext(snapshot); @@ -647,6 +656,7 @@ export class PluginLoader extends EventEmitter<{ ); this.plugins.delete(pluginId); + this.pluginRoots.delete(pluginId); const originalError = err instanceof Error ? err.message : String(err); const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); @@ -866,6 +876,7 @@ export class PluginLoader extends EventEmitter<{ // Remove from loaded plugins this.plugins.delete(pluginId); + this.pluginRoots.delete(pluginId); // Invalidate module cache for clean re-import this.invalidateModuleCache(pluginPath); @@ -1310,13 +1321,16 @@ export class PluginLoader extends EventEmitter<{ /** * Get all skill contributions from loaded plugins. + * + * FNXC:PluginSkills 2026-07-12-00:00: + * Plugin skill body resolution must honor skillFiles relative to the plugin package, so each contribution exposes the absolute pluginRoot alongside the SDK skill data. This is additive for old consumers and lets dashboard/session callers use the shared traversal-guarded resolver instead of guessing from the skill name. */ - getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution }> { - const skills: Array<{ pluginId: string; skill: PluginSkillContribution }> = []; + getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> { + const skills: Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> = []; for (const [pluginId, plugin] of this.plugins) { if (plugin.skills) { for (const skill of plugin.skills) { - skills.push({ pluginId, skill }); + skills.push({ pluginId, skill, pluginRoot: this.pluginRoots.get(pluginId) }); } } } diff --git a/packages/core/src/plugin-skill-paths.ts b/packages/core/src/plugin-skill-paths.ts new file mode 100644 index 0000000000..ea05897625 --- /dev/null +++ b/packages/core/src/plugin-skill-paths.ts @@ -0,0 +1,58 @@ +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { PluginSkillContribution } from "./plugin-types.js"; + +export interface PluginSkillBodyPath { + absolutePath: string; + relativePath: string; +} + +function normalizeSkillRelativePath(path: string): string { + return path.trim().replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function isWithinRoot(root: string, candidate: string): boolean { + const rootPath = resolve(root); + const candidatePath = resolve(candidate); + const rel = relative(rootPath, candidatePath); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function resolveInsidePluginRoot(pluginRoot: string, relativePath: string): PluginSkillBodyPath | null { + const normalizedRoot = resolve(pluginRoot); + const normalizedRelativePath = normalizeSkillRelativePath(relativePath); + if (!normalizedRelativePath) return null; + const absolutePath = resolve(normalizedRoot, normalizedRelativePath); + if (!isWithinRoot(normalizedRoot, absolutePath)) return null; + return { + absolutePath, + relativePath: relative(normalizedRoot, absolutePath).split(sep).join("/"), + }; +} + +/** + * FNXC:PluginSkills 2026-07-12-00:00: + * PluginSkillContribution.skillFiles was declared in the public SDK but the host ignored it (GitHub #2018), which forced plugin authors to mirror skill names in a flat skills//SKILL.md layout. This resolver makes skillFiles[0] the authoritative plugin-root-relative body path, preserves the name-derived fallback for existing plugins, and rejects traversal so plugin skill bodies never resolve outside the plugin package. + */ +export function resolvePluginSkillBodyPath( + skill: Pick, + pluginRoot: string, +): PluginSkillBodyPath { + const declaredPath = skill.skillFiles?.[0]; + if (typeof declaredPath === "string" && declaredPath.trim().length > 0) { + const declared = resolveInsidePluginRoot(pluginRoot, declaredPath); + if (declared) return declared; + } + + const fallbackPath = `skills/${skill.name}/SKILL.md`; + const fallback = resolveInsidePluginRoot(pluginRoot, fallbackPath); + if (!fallback) { + throw new Error(`Plugin skill body path for "${skill.name}" escapes plugin root: ${fallbackPath}`); + } + return fallback; +} + +export function resolvePluginRootFromEntryPath(pluginEntryPath: string): string { + const entryDir = dirname(resolve(pluginEntryPath)); + const dirName = entryDir.split(sep).pop(); + return dirName && ["dist", "build", "lib", "src"].includes(dirName) ? dirname(entryDir) : entryDir; +} diff --git a/packages/dashboard/src/__tests__/skills-adapter.test.ts b/packages/dashboard/src/__tests__/skills-adapter.test.ts index 6ba3edc87f..d2c653268d 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createSkillsAdapter, extractSkillName, computeSkillId, bareSkillName } from "../skills-adapter.js"; import { resolvePluginSkillEnabled } from "@fusion/core"; import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises"; -import { join, dirname } from "node:path"; +import { join, dirname, resolve } from "node:path"; import { tmpdir } from "node:os"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; @@ -801,6 +801,79 @@ describe("createSkillsAdapter - plugin skill merge", () => { expect(byName.get("ce-plan")!.id).toContain("::"); }); + it("honors plugin skillFiles in category subdirectories when pluginRoot is present", async () => { + const pluginRoot = resolve("/tmp/fusion-plugin-compound-engineering"); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => "/tmp/does-not-exist-settings.json", + getPluginSkills: () => [ + { + pluginId: "fusion-plugin-compound-engineering", + pluginRoot, + skill: { + skillId: "entity-framework-core", + name: "entity-framework-core", + description: "EF Core guidance", + skillFiles: ["skills/data/entity-framework-core/SKILL.md"], + }, + }, + ], + }); + + const skills = await adapter.discoverSkills("/tmp/project"); + const skill = skills.find((entry) => entry.name === "entity-framework-core")!; + + expect(skill.relativePath).toBe("skills/data/entity-framework-core/SKILL.md"); + expect(skill.path).toBe(join(pluginRoot, "skills/data/entity-framework-core/SKILL.md")); + }); + + it("keeps CE-style flat skillFiles on the previous name-derived path and id", async () => { + const pluginRoot = resolve("/tmp/fusion-plugin-compound-engineering"); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => "/tmp/does-not-exist-settings.json", + getPluginSkills: () => [ + { + pluginId: "fusion-plugin-compound-engineering", + pluginRoot, + skill: { + skillId: "ce-plan", + name: "ce-plan", + description: "Plan work", + skillFiles: ["skills/ce-plan/SKILL.md"], + }, + }, + ], + }); + + const cePlan = (await adapter.discoverSkills("/tmp/project")).find((entry) => entry.name === "ce-plan")!; + + expect(cePlan.relativePath).toBe("skills/ce-plan/SKILL.md"); + expect(cePlan.id).toBe(computeSkillId("plugin:fusion-plugin-compound-engineering", "skills/ce-plan/SKILL.md")); + }); + + it("keeps the name-derived relative path when pluginRoot is missing", async () => { + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => "/tmp/does-not-exist-settings.json", + getPluginSkills: () => [ + { + pluginId: "legacy-plugin", + skill: { + skillId: "entity-framework-core", + name: "entity-framework-core", + skillFiles: ["skills/data/entity-framework-core/SKILL.md"], + }, + }, + ], + }); + + const skill = (await adapter.discoverSkills("/tmp/project")).find((entry) => entry.name === "entity-framework-core")!; + + expect(skill.relativePath).toBe("skills/entity-framework-core/SKILL.md"); + expect(skill.path).toBe("skills/entity-framework-core/SKILL.md"); + }); + it("passes the requesting project root into async plugin-skill discovery", async () => { const daemonRoot = "/tmp/daemon-root"; const projectRoot = "/tmp/managed-project"; diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 354916054b..28d4738bbb 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -1055,7 +1055,7 @@ export class ChatManager { FNXC:ChatSkills 2026-06-16-19:10: Agent chat receives the project plugin runner through this narrow structural type, so expose enabled plugin skill contributions here without requiring dashboard code to depend on the full engine runner class. */ - getPluginSkills?(): Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>; + getPluginSkills?(): Array<{ pluginId: string; pluginRoot?: string; skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] } }>; }, private getSettings?: () => Promise; + getPluginSkills?(): Array<{ pluginId: string; pluginRoot?: string; skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] } }>; reloadPlugin?(pluginId: string): Promise; checkPluginSetup?(pluginId: string): Promise; installPluginSetup?(pluginId: string): Promise; diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index d87bfa8c85..dd338de434 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -12,6 +12,7 @@ import { getSkillSettingState, normalizeStoredSkillPath, parseSkillId, + resolvePluginSkillBodyPath, resolvePluginSkillEnabled, superviseSpawn, } from "@fusion/core"; @@ -284,11 +285,13 @@ export function createSkillsAdapter(options: { getPluginSkills?: (rootDir: string) => | Array<{ pluginId: string; - skill: { name: string; description?: string; enabled?: boolean }; + pluginRoot?: string; + skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] }; }> | Promise>; /** Optional superviseSpawn seam for tests */ superviseSpawn?: typeof superviseSpawn; @@ -347,17 +350,23 @@ export function createSkillsAdapter(options: { /* * FNXC:PluginSkills 2026-07-10-00:00: * Skill discovery is project-scoped: plugin contributions must be resolved for the requesting rootDir's project_plugin_states, not the daemon startup directory. This keeps /api/skills/discovered from leaking daemon-root plugin skills into unrelated projects while still surfacing skills enabled only for the requested managed project. + * + * FNXC:PluginSkills 2026-07-12-00:00: + * Plugin skill body paths now come from skillFiles (GitHub #2018) through @fusion/core's traversal-guarded resolver when pluginRoot is available. Discovered plugin skill path is the absolute on-disk SKILL.md location for FN-7857 consumers, while missing pluginRoot keeps the old name-derived relative path for compatibility. */ const pluginSkills = await (options.getPluginSkills?.(rootDir) ?? []); if (pluginSkills.length > 0) { const seenBareNames = new Set(discoveredSkills.map((s) => bareSkillName(s.name))); - for (const { pluginId, skill } of pluginSkills) { + for (const { pluginId, pluginRoot, skill } of pluginSkills) { const name = skill.name?.trim(); if (!name) continue; const bare = bareSkillName(name); if (seenBareNames.has(bare)) continue; seenBareNames.add(bare); - const relativePath = `skills/${name}/SKILL.md`; + const resolvedBodyPath = pluginRoot + ? resolvePluginSkillBodyPath({ name, skillFiles: skill.skillFiles ?? [] }, pluginRoot) + : null; + const relativePath = resolvedBodyPath?.relativePath ?? `skills/${name}/SKILL.md`; const id = computeSkillId(`plugin:${pluginId}`, relativePath); const enabled = resolvePluginSkillEnabled( settings as Parameters[0], @@ -368,7 +377,7 @@ export function createSkillsAdapter(options: { discoveredSkills.push({ id, name, - path: relativePath, + path: resolvedBodyPath?.absolutePath ?? relativePath, relativePath, enabled, description: skill.description, diff --git a/packages/engine/src/__tests__/plugin-runner.test.ts b/packages/engine/src/__tests__/plugin-runner.test.ts index bd74c48582..a52c7d098a 100644 --- a/packages/engine/src/__tests__/plugin-runner.test.ts +++ b/packages/engine/src/__tests__/plugin-runner.test.ts @@ -894,7 +894,7 @@ describe("PluginRunner", () => { }); it("getPluginSkills returns cached skills after init", async () => { - const skills = [{ pluginId: "test-plugin", skill: { skillId: "s1", name: "Skill", description: "d", skillFiles: ["./skill.md"] } }]; + const skills = [{ pluginId: "test-plugin", pluginRoot: "/tmp/test-plugin", skill: { skillId: "s1", name: "Skill", description: "d", skillFiles: ["./skill.md"] } }]; mockPluginLoader.getPluginSkills.mockReturnValue(skills); await pluginRunner.init(); const first = pluginRunner.getPluginSkills(); diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index 483b114f21..bb5ebe7b97 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -124,7 +124,7 @@ interface CachedCliProviderContributions { } interface CachedSkills { - skills: Array<{ pluginId: string; skill: PluginSkillContribution }>; + skills: Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }>; version: number; } @@ -397,7 +397,7 @@ export class PluginRunner { return this.cachedCliProviderContributions.contributions; } - getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution }> { + getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> { if (!this.cachedSkills || this.cachedSkills.version !== this.skillsCacheVersion) { this.cachedSkills = { skills: this.options.pluginLoader.getPluginSkills(),