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>
59 lines
2.5 KiB
TypeScript
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;
|
|
}
|