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>
This commit is contained in:
gsxdsm
2026-07-12 11:52:24 -07:00
parent 7a7c12847e
commit 0c97c161ee
13 changed files with 280 additions and 16 deletions

View File

@@ -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.

View File

@@ -1496,14 +1496,14 @@ const skills: PluginSkillContribution[] = [
skillId: "web-research", skillId: "web-research",
name: "Web Research", name: "Web Research",
description: "Finds and summarizes web sources for a task", description: "Finds and summarizes web sources for a task",
skillFiles: ["skills/web-research/SKILL.md"], skillFiles: ["skills/research/web-research/SKILL.md"],
enabled: true, enabled: true,
triggerPatterns: ["research", "search the web", "find sources"], 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/<name>/SKILL.md`. `skillId` must be kebab-case.
Plugin skills are discovered per requesting project: the Skills view and workflow editor surface `plugin:<id>` skills only when that plugin is enabled for that project's plugin state, even if the daemon was started from a different directory. Plugin skills are discovered per requesting project: the Skills view and workflow editor surface `plugin:<id>` skills only when that plugin is enabled for that project's plugin state, even if the daemon was started from a different directory.

View File

@@ -2756,15 +2756,38 @@ export default plugin;
it("getPluginSkills returns skills with pluginId", async () => { it("getPluginSkills returns skills with pluginId", async () => {
await pluginStore.init(); await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const pluginRoot = join(rootDir, "plugins", "skills-plugin");
(loader as any).plugins.set("skills-plugin", { (loader as any).plugins.set("skills-plugin", {
manifest: makeManifest({ id: "skills-plugin" }), manifest: makeManifest({ id: "skills-plugin" }),
state: "started", state: "started",
hooks: {}, hooks: {},
skills: [{ skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }], skills: [{ skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] }],
} as FusionPlugin); } as FusionPlugin);
(loader as any).pluginRoots.set("skills-plugin", pluginRoot);
expect(loader.getPluginSkills()).toEqual([ expect(loader.getPluginSkills()).toEqual([
{ {
pluginId: "skills-plugin", 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"] }, skill: { skillId: "browser", name: "Browser", description: "Web", skillFiles: ["./SKILL.md"] },
}, },
]); ]);

View File

@@ -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> = {}): 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");
});
});

View File

@@ -42,6 +42,11 @@ export {
resolvePluginSkillEnabled, resolvePluginSkillEnabled,
} from "./skill-settings.js"; } from "./skill-settings.js";
export type { SkillSettingState, SkillSettingsScope } 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 { redactSecrets } from "./redact-secrets.js";
export { export {
evaluatePromptCondition, evaluatePromptCondition,

View File

@@ -45,6 +45,7 @@ import { normalizePluginUiContributionDefinition, validatePluginManifest } from
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js"; import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js";
import { scanPluginSecurity } from "./plugin-security-scan.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) // Minimum Fusion version for plugin compatibility checks (can be expanded later)
const MINIMUM_FUSION_VERSION = "0.1.0"; const MINIMUM_FUSION_VERSION = "0.1.0";
@@ -203,6 +204,9 @@ export class PluginLoader extends EventEmitter<{
/** Cache of dynamically imported modules */ /** Cache of dynamically imported modules */
private loadedModules: Map<string, unknown> = new Map(); private loadedModules: Map<string, unknown> = new Map();
/** Absolute plugin package roots keyed by plugin id. */
private pluginRoots: Map<string, string> = new Map();
private readonly log = createLogger("plugin-loader"); private readonly log = createLogger("plugin-loader");
constructor(private options: PluginLoaderOptions) { constructor(private options: PluginLoaderOptions) {
@@ -379,6 +383,7 @@ export class PluginLoader extends EventEmitter<{
// Update plugin state locally and store // Update plugin state locally and store
plugin.state = "started"; plugin.state = "started";
this.plugins.set(pluginId, plugin); this.plugins.set(pluginId, plugin);
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
// Call onLoad hook // Call onLoad hook
const ctx = await this.createContext(plugin); const ctx = await this.createContext(plugin);
@@ -387,6 +392,7 @@ export class PluginLoader extends EventEmitter<{
} catch (loadErr) { } catch (loadErr) {
// onLoad failed - clean up and propagate error // onLoad failed - clean up and propagate error
this.plugins.delete(pluginId); this.plugins.delete(pluginId);
this.pluginRoots.delete(pluginId);
const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr); const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr);
await this.options.pluginStore.updatePluginState( await this.options.pluginStore.updatePluginState(
pluginId, pluginId,
@@ -407,6 +413,7 @@ export class PluginLoader extends EventEmitter<{
// Ensure plugin is removed from loaded map on any failure // Ensure plugin is removed from loaded map on any failure
// (it may have been added above before the onLoad hook) // (it may have been added above before the onLoad hook)
this.plugins.delete(pluginId); this.plugins.delete(pluginId);
this.pluginRoots.delete(pluginId);
// Error isolation: set error state but don't crash // Error isolation: set error state but don't crash
const errorMsg = err instanceof Error ? err.message : String(err); const errorMsg = err instanceof Error ? err.message : String(err);
@@ -600,6 +607,7 @@ export class PluginLoader extends EventEmitter<{
// Replace in plugins map // Replace in plugins map
this.plugins.set(pluginId, newPlugin); this.plugins.set(pluginId, newPlugin);
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
// Create fresh context and call onLoad // Create fresh context and call onLoad
const ctx = await this.createContext(newPlugin); const ctx = await this.createContext(newPlugin);
@@ -626,6 +634,7 @@ export class PluginLoader extends EventEmitter<{
try { try {
// Restore old plugin // Restore old plugin
this.plugins.set(pluginId, snapshot); this.plugins.set(pluginId, snapshot);
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
// Attempt to reactivate old plugin // Attempt to reactivate old plugin
const ctx = await this.createContext(snapshot); const ctx = await this.createContext(snapshot);
@@ -647,6 +656,7 @@ export class PluginLoader extends EventEmitter<{
); );
this.plugins.delete(pluginId); this.plugins.delete(pluginId);
this.pluginRoots.delete(pluginId);
const originalError = err instanceof Error ? err.message : String(err); const originalError = err instanceof Error ? err.message : String(err);
const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
@@ -866,6 +876,7 @@ export class PluginLoader extends EventEmitter<{
// Remove from loaded plugins // Remove from loaded plugins
this.plugins.delete(pluginId); this.plugins.delete(pluginId);
this.pluginRoots.delete(pluginId);
// Invalidate module cache for clean re-import // Invalidate module cache for clean re-import
this.invalidateModuleCache(pluginPath); this.invalidateModuleCache(pluginPath);
@@ -1310,13 +1321,16 @@ export class PluginLoader extends EventEmitter<{
/** /**
* Get all skill contributions from loaded plugins. * 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 }> { getPluginSkills(): Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> {
const skills: Array<{ pluginId: string; skill: PluginSkillContribution }> = []; const skills: Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }> = [];
for (const [pluginId, plugin] of this.plugins) { for (const [pluginId, plugin] of this.plugins) {
if (plugin.skills) { if (plugin.skills) {
for (const skill of plugin.skills) { for (const skill of plugin.skills) {
skills.push({ pluginId, skill }); skills.push({ pluginId, skill, pluginRoot: this.pluginRoots.get(pluginId) });
} }
} }
} }

View File

@@ -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/<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;
}

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createSkillsAdapter, extractSkillName, computeSkillId, bareSkillName } from "../skills-adapter.js"; import { createSkillsAdapter, extractSkillName, computeSkillId, bareSkillName } from "../skills-adapter.js";
import { resolvePluginSkillEnabled } from "@fusion/core"; import { resolvePluginSkillEnabled } from "@fusion/core";
import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises"; 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 { tmpdir } from "node:os";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream";
@@ -801,6 +801,79 @@ describe("createSkillsAdapter - plugin skill merge", () => {
expect(byName.get("ce-plan")!.id).toContain("::"); 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 () => { it("passes the requesting project root into async plugin-skill discovery", async () => {
const daemonRoot = "/tmp/daemon-root"; const daemonRoot = "/tmp/daemon-root";
const projectRoot = "/tmp/managed-project"; const projectRoot = "/tmp/managed-project";

View File

@@ -1055,7 +1055,7 @@ export class ChatManager {
FNXC:ChatSkills 2026-06-16-19:10: 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. 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<Pick<Settings, private getSettings?: () => Promise<Pick<Settings,
| "fallbackProvider" | "fallbackProvider"

View File

@@ -352,7 +352,7 @@ export interface ServerOptions {
FNXC:ChatSkills 2026-06-16-19:10: FNXC:ChatSkills 2026-06-16-19:10:
The dashboard passes this structural runner into ChatManager, which needs optional plugin skill discovery so chat can load enabled plugin skills such as ce-debug. The dashboard passes this structural runner into ChatManager, which needs optional plugin skill discovery so chat can load enabled plugin skills such as ce-debug.
*/ */
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[] } }>;
reloadPlugin?(pluginId: string): Promise<unknown>; reloadPlugin?(pluginId: string): Promise<unknown>;
checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>; checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>;
installPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>; installPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>;

View File

@@ -12,6 +12,7 @@ import {
getSkillSettingState, getSkillSettingState,
normalizeStoredSkillPath, normalizeStoredSkillPath,
parseSkillId, parseSkillId,
resolvePluginSkillBodyPath,
resolvePluginSkillEnabled, resolvePluginSkillEnabled,
superviseSpawn, superviseSpawn,
} from "@fusion/core"; } from "@fusion/core";
@@ -284,11 +285,13 @@ export function createSkillsAdapter(options: {
getPluginSkills?: (rootDir: string) => getPluginSkills?: (rootDir: string) =>
| Array<{ | Array<{
pluginId: string; pluginId: string;
skill: { name: string; description?: string; enabled?: boolean }; pluginRoot?: string;
skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] };
}> }>
| Promise<Array<{ | Promise<Array<{
pluginId: string; pluginId: string;
skill: { name: string; description?: string; enabled?: boolean }; pluginRoot?: string;
skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] };
}>>; }>>;
/** Optional superviseSpawn seam for tests */ /** Optional superviseSpawn seam for tests */
superviseSpawn?: typeof superviseSpawn; superviseSpawn?: typeof superviseSpawn;
@@ -347,17 +350,23 @@ export function createSkillsAdapter(options: {
/* /*
* FNXC:PluginSkills 2026-07-10-00:00: * 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. * 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) ?? []); const pluginSkills = await (options.getPluginSkills?.(rootDir) ?? []);
if (pluginSkills.length > 0) { if (pluginSkills.length > 0) {
const seenBareNames = new Set(discoveredSkills.map((s) => bareSkillName(s.name))); 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(); const name = skill.name?.trim();
if (!name) continue; if (!name) continue;
const bare = bareSkillName(name); const bare = bareSkillName(name);
if (seenBareNames.has(bare)) continue; if (seenBareNames.has(bare)) continue;
seenBareNames.add(bare); 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 id = computeSkillId(`plugin:${pluginId}`, relativePath);
const enabled = resolvePluginSkillEnabled( const enabled = resolvePluginSkillEnabled(
settings as Parameters<typeof resolvePluginSkillEnabled>[0], settings as Parameters<typeof resolvePluginSkillEnabled>[0],
@@ -368,7 +377,7 @@ export function createSkillsAdapter(options: {
discoveredSkills.push({ discoveredSkills.push({
id, id,
name, name,
path: relativePath, path: resolvedBodyPath?.absolutePath ?? relativePath,
relativePath, relativePath,
enabled, enabled,
description: skill.description, description: skill.description,

View File

@@ -894,7 +894,7 @@ describe("PluginRunner", () => {
}); });
it("getPluginSkills returns cached skills after init", async () => { 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); mockPluginLoader.getPluginSkills.mockReturnValue(skills);
await pluginRunner.init(); await pluginRunner.init();
const first = pluginRunner.getPluginSkills(); const first = pluginRunner.getPluginSkills();

View File

@@ -124,7 +124,7 @@ interface CachedCliProviderContributions {
} }
interface CachedSkills { interface CachedSkills {
skills: Array<{ pluginId: string; skill: PluginSkillContribution }>; skills: Array<{ pluginId: string; skill: PluginSkillContribution; pluginRoot?: string }>;
version: number; version: number;
} }
@@ -397,7 +397,7 @@ export class PluginRunner {
return this.cachedCliProviderContributions.contributions; 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) { if (!this.cachedSkills || this.cachedSkills.version !== this.skillsCacheVersion) {
this.cachedSkills = { this.cachedSkills = {
skills: this.options.pluginLoader.getPluginSkills(), skills: this.options.pluginLoader.getPluginSkills(),