Files
fusion/packages/core/src/plugin-skill-paths.ts
gsxdsm 0c97c161ee 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/<name>/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/<name>/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) <noreply@runfusion.ai>
2026-07-12 11:52:24 -07:00

59 lines
2.5 KiB
TypeScript

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/<name>/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<PluginSkillContribution, "name" | "skillFiles">,
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;
}