## Why
An AI agent autonomously deleted a live task (FN-8609) via
`fn_task_delete`. Investigating that led to two audits, which found the
gate machinery is well-built and its *enforcement* well-tested — but the
*decision boundary* was unguarded and the tools that matter never
reached the gate at all.
The headline defect: all ~76 tools in `packages/cli/src/extension.ts`
are delivered through `hostExtensionPaths` → `DefaultResourceLoader`,
which never passes through `wrapToolsWithActionGate`. `fn_task_delete`
*was* classified as gated in `gating-classifications.ts` — that
classification was dead code. Roughly 15 destructive tools were ungated
even for an operator who had locked their policy all the way down.
Second: `POST /api/approvals/:id/decision` took the approver identity
from the request body, validated for shape only, with no check that the
decider was human or differed from the requester. Combined with a bash
tool whose command string was never inspected (only `params.cwd`), an
agent could read the daemon token from `~/.fusion/settings.json`, find
its own pending request, and approve it.
## Scope
This does **not** tighten defaults. The `unrestricted` preset is
byte-for-byte unchanged — `git diff` on `agent-permission-policy.ts` is
empty — and regression tests assert that an out-of-the-box install
behaves exactly as before. The bug was never "the default is too
permissive"; it was "strict policy doesn't enforce." This makes turning
security up actually work.
The one deliberate exception: the containment that stops an agent
escalating its *own* privileges (reading the daemon token / credentials,
calling the approvals API to self-approve) applies at every preset
including `unrestricted`. That is a privilege-escalation boundary rather
than a permission preference — if it only engaged under strict policy it
would not have prevented the incident that prompted this.
## What changed
8 bisectable commits:
- **Approval lifecycle** — self-approval blocked via server-derived
deciders; same-verdict replay 409s; decide re-reads and re-validates
inside the transaction; expiry TTLs; `markCompleted` ownership check;
session identity registry in core.
- **Engine gates enforce for real** — unclassified tools resolve to a
policy-governed category instead of hardcoded `allow`; missing-policy
fail-open closed; bash containment floor + exact-command approval
binding.
- **Dashboard decision routes** — stop trusting client-supplied actors
(decision, bypass-review, worktrunk → 403 on forged actors).
- **`fn serve` authenticated by default** — auto-mints a token following
the existing `fn dashboard` precedent; `--no-auth` opts out.
- **Sibling entry points closed** — user-sourced hard-cancel moves, ACP
execute-once approvals, plugin task-store gating.
- **pi-extension principal resolution** — the extension resolves the
acting principal and can withhold or policy-gate the previously ungated
destructive tools.
- **Root-cause bonus fix** — `findLatestByDedupeKey` was broken in
PostgreSQL backend mode (already-parsed jsonb fed through a string-only
parser), so approved-grant redemption **never matched in production**,
minting duplicate requests. This explains the live DB state of 17
approved / 0 completed. *(Also cherry-picked to `main` as `a9b30013bb`,
since it is an active production defect on its own.)*
- **Review follow-ups** (`627f1b1fa8`) — operator-configured
provisioning privilege and a configurable grant TTL; see below.
## Review follow-ups
**Provisioning privilege is operator-configured, not role-derived.**
`isCallerPrivileged` had gone from `caller.reportsTo == null` (every
top-level agent privileged — permanent escalation by creating a
manager-less agent) to `caller.role === "ceo"`, which swapped an
implicit rule for a magic string: any agent config can claim that role,
while an operator who genuinely wants a privileged agent had no
supported way to say so. Privilege now derives solely from
`agentProvisioning.trustedAgentIds` / `trustedRoles` and fails closed
when settings are unresolvable.
It is also no longer forwarded to `resolveAgentProvisioningPolicy` as
`isPrivileged`, because that flag short-circuits ahead of
`alwaysApproveDelete` — a trusted caller was bypassing delete approval
entirely. The policy applies the same trusted rules itself, in the right
order. The function now governs only the org-chart escape hatch (acting
outside your own direct reports).
**Grant TTL defaults to 1 hour and is configurable.** Approval →
redemption is not instantaneous: an operator approving from their phone,
an engine restart, a queued lane, or a task waiting on a worktree all
routinely exceeded 15 minutes, after which the grant expired and the
agent silently re-requested. One hour remains far short of the
"redeemable forever" hazard the TTL exists to bound. Override via
`FUSION_APPROVAL_GRANT_TTL_MS` or `configureApprovalRequestTtls()`;
invalid overrides are ignored rather than widening the window to
infinity or collapsing it to zero.
## Behavior changes requiring operator review before rollout
1. `fn serve` requires a bearer token by default (`--no-auth` opts out);
unauthenticated clients get 401.
2. Agents can no longer run withheld destructive tools
(`fn_task_delete`, `fn_task_bypass_review`,
mission/milestone/slice/feature/workflow deletes, `experiment_finalize`,
`skills_install`). Operators keep them via CLI/dashboard. **This is the
incident fix.**
3. Agents get provisioning privilege only when the operator lists them
in `agentProvisioning.trustedAgentIds` / `trustedRoles`; the
provisioning gate is now live in production. Previously-implicit
privilege (top-level position, or a `ceo` role) no longer grants
anything on its own.
4. Decision replay 409s (was 200); pending approvals expire after 24h,
approved grants after 1h (configurable); bash approvals bind per exact
command.
5. Forged/body actors on decision, bypass-review, worktrunk routes →
403; `archive-all-done` requires `{confirm:true}` (external scripts
affected).
6. `fn_secret_get` approvals grant exactly one reveal (previously
granted nothing and looped forever); ACP approvals are execute-once
(previously infinite reuse).
7. Bash containment denies token/credential/approvals-API commands in
all agent sessions at every preset.
## Verification
Independently re-run against the branch, not just self-reported:
- 5 typechecks (core, engine, cli, dashboard `tsconfig.json` +
`tsconfig.app.json`) — clean
- `pnpm lint` — clean
- `pnpm test:gate` — 379 passed
- `pnpm build --force` — green (a plain `pnpm build` skips packages as
unchanged and does **not** compile the branch)
- `pnpm check:changesets` — clean
- ~650 file-scoped tests including new negative-path suites for the
decision boundary, which previously had **zero** test coverage
`packages/engine/src/__tests__/plugin-runner.test.ts` fails 56/80 —
**verified pre-existing**, reproducing identically at base commit
`93a403af67` on `main`. Not in the merge gate.
### A mutation check that failed to fail
Worth recording, because it nearly shipped an untested security fix. The
first mutation check on the provisioning change reintroduced the `ceo`
hardcode and **all 17 tests still passed** — the tests asserted through
the policy path, which can no longer observe `isCallerPrivileged` at
all, precisely because `isPrivileged` is no longer forwarded there.
Org-chart cases that do exercise the function were added; the hardcode
now fails exactly 1 of 19, and restoring is green. A green mutation run
is only meaningful if the test can actually see the code under test.
## Known limitations (stated, not papered over)
- The bash containment floor is string-matching: a cost-raiser, not a
sandbox. Quoting, encoding, `$HOME`, symlinks, or an interpreter
one-liner can evade it. The durable protection is the decision route
refusing agent-originated deciders — the filter is the belt, not the
braces.
- Approval expiry is lazy (evaluated at decide/complete/redeem), not
swept, so an expired pending row stays visible in lists until touched.
- The extension's require-approval path returns a pending message but
cannot suspend a pi session mid-turn; engine-side pause hooks cover
engine lanes only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Security**
* Hardened approval and permission gating with server-side decider
attribution, self-approval blocking, ownership checks, replay/race
protection, and status/TTL enforcement.
* Added fail-closed behavior for sensitive/unclassified tools and
sandbox provisioning approvals.
* Blocked credential/approval access via bash containment; plugin
destructive task operations now require explicit permission.
* **New Features**
* `fn serve` now defaults to bearer-token auth, with `--no-auth` as the
explicit opt-out.
* **Bug Fixes**
* Improved task move-source attribution (`moveSource: "user"`) and
tightened dashboard archive/bypass confirmation and operator attribution
behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1730 lines
60 KiB
TypeScript
1730 lines
60 KiB
TypeScript
/**
|
|
* PluginLoader - Dynamic plugin loading and lifecycle management.
|
|
*
|
|
* Handles:
|
|
* - Dynamic import of plugins from file paths or npm packages
|
|
* - Plugin lifecycle (load, start, stop)
|
|
* - Dependency resolution via topological sort
|
|
* - Hook invocation across all loaded plugins
|
|
* - Error isolation (plugin crashes don't crash the loader)
|
|
*/
|
|
|
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
import { copyFile, readFile, stat } from "node:fs/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import { EventEmitter } from "node:events";
|
|
import type { TaskStore } from "./store.js";
|
|
import type { WorkflowStepTemplate } from "./types.js";
|
|
import { PluginStore } from "./plugin-store.js";
|
|
import type {
|
|
FusionPlugin,
|
|
PluginContext,
|
|
PluginLogger,
|
|
PluginToolDefinition,
|
|
PluginRouteDefinition,
|
|
PluginUiSlotDefinition,
|
|
PluginUiContributionDefinition,
|
|
PluginDashboardViewDefinition,
|
|
PluginRuntimeRegistration,
|
|
CliProviderContribution,
|
|
PluginInstallation,
|
|
PluginManifest,
|
|
PluginSkillContribution,
|
|
PluginMcpServerContribution,
|
|
PluginWorkflowStepContribution,
|
|
PluginTraitContribution,
|
|
PluginPromptContribution,
|
|
PluginPromptContributions,
|
|
PluginSetupManifest,
|
|
PluginSetupHooks,
|
|
PluginSetupCheckResult,
|
|
} from "./plugin-types.js";
|
|
import type { LoadedPluginSchemaContract } from "./postgres/plugin-schema-hook.js";
|
|
import type { WorkflowExtensionContribution } from "./workflow-extension-types.js";
|
|
import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js";
|
|
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";
|
|
import { createPluginGatedTaskStore } from "./plugin-task-store-gate.js";
|
|
|
|
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
|
|
const MINIMUM_FUSION_VERSION = "0.1.0";
|
|
let moduleImportVersion = 0;
|
|
const PLUGIN_MANIFEST_PARENT_DIR_NAMES = new Set(["dist", "build", "lib", "src"]);
|
|
type CurrentManifestDashboardViewsResult =
|
|
| { found: true; dashboardViews: PluginDashboardViewDefinition[] }
|
|
| { found: false };
|
|
|
|
/**
|
|
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
|
|
* does not allow directory imports, so the registered plugin path must be the
|
|
* explicit file the loader will dynamic-import. Resolution keeps ./bundled.js
|
|
* unconditional because production npm tarballs ship that esbuild-bundled entry.
|
|
* In dev/worktree contexts where no bundle exists, ./dist/index.js remains the
|
|
* prebuilt fallback unless any file under ./src/ is newer than dist/index.js;
|
|
* then ./src/index.ts wins so stale gitignored dist output cannot mask a source
|
|
* fix (FN-6615/FN-6596).
|
|
*
|
|
* FNXC:PluginLoader 2026-06-17-19:20:
|
|
* Prefer fresher src over stale dist only when bundled.js is absent. This keeps
|
|
* production tarballs on their bundled entry while preventing dev/worktree runs
|
|
* from silently loading old gitignored build output after a source fix.
|
|
*
|
|
* Returns null when the directory exists but none of the loadable entry files
|
|
* are present. Callers must treat that as a missing/unloadable plugin rather
|
|
* than persisting a directory path that Node cannot import.
|
|
*
|
|
* Keep in sync with resolvePluginEntryPath in the CLI's
|
|
* bundled-plugin-install.ts, which keeps a local copy so its fs mocks work.
|
|
*/
|
|
function newestSourceMtimeMs(srcDir: string): number | null {
|
|
let newest = Number.NEGATIVE_INFINITY;
|
|
|
|
function visit(dir: string): boolean {
|
|
const entries = (() => {
|
|
try {
|
|
return readdirSync(dir, { withFileTypes: true, encoding: "utf8" });
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
if (!entries) return false;
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = join(dir, entry.name);
|
|
let entryStat: ReturnType<typeof statSync>;
|
|
try {
|
|
entryStat = statSync(entryPath);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
if (entryStat.isDirectory()) {
|
|
if (!visit(entryPath)) return false;
|
|
continue;
|
|
}
|
|
|
|
if (entryStat.mtimeMs > newest) {
|
|
newest = entryStat.mtimeMs;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return visit(srcDir) && newest !== Number.NEGATIVE_INFINITY ? newest : null;
|
|
}
|
|
|
|
function isSourceNewerThanDist(srcDir: string, distIndexPath: string): boolean {
|
|
try {
|
|
const distMtimeMs = statSync(distIndexPath).mtimeMs;
|
|
const srcMtimeMs = newestSourceMtimeMs(srcDir);
|
|
return srcMtimeMs !== null && srcMtimeMs > distMtimeMs;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function resolvePluginEntryPath(pluginDir: string): string | null {
|
|
const bundledPath = join(pluginDir, "bundled.js");
|
|
if (existsSync(bundledPath)) {
|
|
return bundledPath;
|
|
}
|
|
|
|
const distIndexPath = join(pluginDir, "dist", "index.js");
|
|
const srcDir = join(pluginDir, "src");
|
|
const srcIndexPath = join(srcDir, "index.ts");
|
|
const hasDist = existsSync(distIndexPath);
|
|
const hasSrc = existsSync(srcIndexPath);
|
|
|
|
if (hasDist && hasSrc) {
|
|
return isSourceNewerThanDist(srcDir, distIndexPath) ? srcIndexPath : distIndexPath;
|
|
}
|
|
if (hasDist) {
|
|
return distIndexPath;
|
|
}
|
|
if (hasSrc) {
|
|
return srcIndexPath;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface PluginLoaderOptions {
|
|
/** Plugin store for persistence */
|
|
pluginStore: PluginStore;
|
|
/** Task store for plugin context */
|
|
taskStore: TaskStore;
|
|
/** Additional directories to scan for plugins */
|
|
pluginDirs?: string[];
|
|
/** npm prefix for resolving packages */
|
|
npmPrefix?: string;
|
|
/** Persist started/stopped/error runtime state transitions (default true). */
|
|
persistRuntimeState?: boolean;
|
|
/**
|
|
* FNXC:PluginLoader 2026-07-23-12:00:
|
|
* FN-8596 discovery loaders may read another project's contributions, but
|
|
* must never join, mutate, or tear down the daemon-wide lifecycle registry
|
|
* or persist runtime state. Isolated loaders own only private instances.
|
|
*/
|
|
lifecycleScope?: "shared" | "isolated";
|
|
}
|
|
|
|
/**
|
|
* Event emitted when a plugin is loaded and started.
|
|
*/
|
|
export interface PluginLoadedEvent {
|
|
pluginId: string;
|
|
plugin: FusionPlugin;
|
|
}
|
|
|
|
/**
|
|
* Event emitted when a plugin is unloaded (stopped).
|
|
*/
|
|
export interface PluginUnloadedEvent {
|
|
pluginId: string;
|
|
}
|
|
|
|
/**
|
|
* Event emitted when a plugin is reloaded with a new version.
|
|
*/
|
|
export interface PluginReloadedEvent {
|
|
pluginId: string;
|
|
plugin: FusionPlugin;
|
|
}
|
|
|
|
/**
|
|
* Event emitted when a plugin encounters an error.
|
|
*/
|
|
export interface PluginErrorEvent {
|
|
pluginId: string;
|
|
error: Error;
|
|
}
|
|
|
|
interface ProcessPluginLifecycle {
|
|
promise: Promise<FusionPlugin>;
|
|
owner: PluginLoader;
|
|
participants: Set<PluginLoader>;
|
|
}
|
|
|
|
export class PluginLoader extends EventEmitter<{
|
|
"plugin:loaded": [PluginLoadedEvent];
|
|
"plugin:unloaded": [PluginUnloadedEvent];
|
|
"plugin:reloaded": [PluginReloadedEvent];
|
|
"plugin:error": [PluginErrorEvent];
|
|
"plugin:stopped": [string]; // Kept for backward compatibility
|
|
}> {
|
|
/*
|
|
FNXC:PluginTaskStoreGate 2026-07-26-12:20:
|
|
Re-exposed here (in addition to plugin-task-store-gate.ts) because the engine's
|
|
PluginRunner builds its own PluginContexts and must apply the same gate; the
|
|
core barrel (index.ts) is edit-frozen, so the already-exported PluginLoader
|
|
class is the cross-package access point.
|
|
*/
|
|
static readonly createGatedTaskStore = createPluginGatedTaskStore;
|
|
|
|
/** Loaded plugin instances keyed by plugin id */
|
|
private plugins: Map<string, FusionPlugin> = new Map();
|
|
|
|
/** Cache of dynamically imported modules */
|
|
private loadedModules: Map<string, unknown> = new Map();
|
|
|
|
/** Absolute plugin package roots keyed by plugin id. */
|
|
private pluginRoots: Map<string, string> = new Map();
|
|
private pluginSchemaContracts: Map<string, LoadedPluginSchemaContract> = new Map();
|
|
|
|
/*
|
|
FNXC:PluginLoader 2026-07-22-10:15:
|
|
Dashboard/serve/daemon boot a host PluginLoader while InProcessRuntime boots a
|
|
second loader for the same project. `plugins.has()` only protects one loader
|
|
after publication, so this process-wide lifecycle registry coalesces the
|
|
import and onLoad work before either loader can publish. Successful entries
|
|
intentionally persist until stop/reload begins a fresh lifecycle; rejected
|
|
entries are removed so an intentional retry is never poisoned.
|
|
*/
|
|
private static readonly processPluginLifecycles = new Map<string, ProcessPluginLifecycle>();
|
|
private static readonly processPluginLifecycleTails = new Map<string, Promise<void>>();
|
|
|
|
private readonly log = createLogger("plugin-loader");
|
|
|
|
constructor(private options: PluginLoaderOptions) {
|
|
super();
|
|
}
|
|
|
|
private async updatePluginState(
|
|
pluginId: string,
|
|
state: PluginInstallation["state"],
|
|
error?: string,
|
|
): Promise<void> {
|
|
if (this.options.persistRuntimeState === false || this.options.lifecycleScope === "isolated") return;
|
|
await this.options.pluginStore.updatePluginState(pluginId, state, error);
|
|
}
|
|
|
|
private getProjectRoot(): string {
|
|
// Lightweight loader harnesses historically omit this TaskStore accessor.
|
|
// Production stores always provide it; cwd preserves their single-project semantics.
|
|
return this.options.taskStore.getRootDir?.() ?? process.cwd();
|
|
}
|
|
|
|
// ── Context Creation ───────────────────────────────────────────────
|
|
|
|
private async createContext(plugin: FusionPlugin): Promise<PluginContext> {
|
|
return this.createRouteContext(plugin.manifest.id);
|
|
}
|
|
|
|
async createRouteContext(
|
|
pluginId: string,
|
|
overrides?: Partial<Pick<PluginContext, "taskStore" | "settings" | "resolveProjectTaskStore" | "emitEvent">>,
|
|
): Promise<PluginContext> {
|
|
const createAiSession = await getCreateAiSessionFactory();
|
|
const createInteractiveAiSession = await getCreateInteractiveAiSessionFactory();
|
|
if (process.env.DEBUG?.includes("plugins")) {
|
|
this.log.log(
|
|
createAiSession
|
|
? `[plugin:${pluginId}] createAiSession available`
|
|
: `[plugin:${pluginId}] createAiSession unavailable`,
|
|
);
|
|
}
|
|
|
|
/*
|
|
FNXC:PluginTaskStoreGate 2026-07-26-12:20:
|
|
Every context handed to a plugin carries a gated TaskStore: destructive methods
|
|
throw unless the manifest declares permissions.destructiveTaskOps. The gate also
|
|
wraps override stores and project stores resolved via resolveProjectTaskStore so
|
|
a plugin cannot escape the gate through a project-scoped handle.
|
|
*/
|
|
const permissions = this.getPlugin(pluginId)?.manifest.permissions;
|
|
const rawTaskStore = overrides?.taskStore ?? this.options.taskStore;
|
|
const rawResolveProjectTaskStore = overrides?.resolveProjectTaskStore;
|
|
return {
|
|
pluginId,
|
|
taskStore: createPluginGatedTaskStore(rawTaskStore, { pluginId, permissions }),
|
|
settings: overrides?.settings ?? await this.getPluginSettings(pluginId),
|
|
logger: this.createLogger(pluginId),
|
|
createAiSession,
|
|
createInteractiveAiSession,
|
|
resolveProjectTaskStore: rawResolveProjectTaskStore
|
|
? async (projectId: string) =>
|
|
createPluginGatedTaskStore(await rawResolveProjectTaskStore(projectId), { pluginId, permissions })
|
|
: undefined,
|
|
// The host (dashboard) may supply a real publisher that forwards custom
|
|
// plugin events to connected SSE clients. Absent an override, fall back to
|
|
// logging (the historical no-op behavior) so non-dashboard hosts and tests
|
|
// keep working.
|
|
emitEvent: overrides?.emitEvent ?? ((event: string, data: unknown) => {
|
|
this.log.log(`[plugin:${pluginId}] Custom event: ${event}`, data);
|
|
}),
|
|
};
|
|
}
|
|
|
|
private createLogger(pluginId: string): PluginLogger {
|
|
const pluginLog = createLogger(`plugin:${pluginId}`);
|
|
return {
|
|
info: (message: string, ...args: unknown[]) => pluginLog.log(message, ...args),
|
|
warn: (message: string, ...args: unknown[]) => pluginLog.warn(message, ...args),
|
|
error: (message: string, ...args: unknown[]) => pluginLog.error(message, ...args),
|
|
debug: (message: string, ...args: unknown[]) => {
|
|
if (process.env.DEBUG?.includes("plugins")) {
|
|
pluginLog.log(message, ...args);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
private async getPluginSettings(pluginId: string): Promise<Record<string, unknown>> {
|
|
try {
|
|
const plugin = await this.options.pluginStore.getPlugin(pluginId);
|
|
return plugin.settings;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a successful plugin or workflow-extension activation without letting analytics persistence change loader behavior.
|
|
*
|
|
* FNXC:CommandCenterEcosystem 2026-06-19-08:00:
|
|
* Command Center Ecosystem plugin-activation counts must be backed by real project-scoped load/reload events. Analytics writes are fail-soft so a DB problem never prevents a plugin or extension from activating.
|
|
*/
|
|
private recordActivationEvent(pluginId: string, plugin: FusionPlugin): void {
|
|
try {
|
|
this.options.taskStore.recordPluginActivation({
|
|
pluginId,
|
|
source: this.resolveActivationSource(plugin),
|
|
pluginVersion: plugin.manifest.version,
|
|
});
|
|
} catch (error) {
|
|
this.log.warn(`Failed to record plugin activation for ${pluginId}:`, error);
|
|
}
|
|
}
|
|
|
|
private resolveActivationSource(plugin: FusionPlugin): "plugin" | "extension" {
|
|
const hasWorkflowExtensions =
|
|
(plugin.workflowExtensions?.length ?? 0) > 0 ||
|
|
(plugin.manifest.workflowExtensions?.length ?? 0) > 0;
|
|
return hasWorkflowExtensions ? "extension" : "plugin";
|
|
}
|
|
|
|
// ── Plugin Loading ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Load and start a single plugin.
|
|
*/
|
|
async loadPlugin(pluginId: string): Promise<FusionPlugin> {
|
|
// Get plugin installation record
|
|
let installation: PluginInstallation;
|
|
try {
|
|
installation = await this.options.pluginStore.getPlugin(pluginId);
|
|
} catch (err) {
|
|
throw new Error(`Plugin "${pluginId}" not found in store: ${(err as Error).message}`);
|
|
}
|
|
|
|
// Skip disabled plugins
|
|
if (!installation.enabled) {
|
|
this.log.log(`Skipping disabled plugin: ${pluginId}`);
|
|
throw Object.assign(new Error(`Plugin "${pluginId}" is disabled`), {
|
|
code: "PLUGIN_DISABLED",
|
|
});
|
|
}
|
|
|
|
// Skip already loaded plugins
|
|
if (this.plugins.has(pluginId)) {
|
|
this.log.log(`Plugin already loaded: ${pluginId}`);
|
|
return this.plugins.get(pluginId)!;
|
|
}
|
|
|
|
// Resolve plugin path. Discovery instances deliberately bypass the shared
|
|
// registry so their teardown can affect only their private plugin maps.
|
|
const pluginPath = this.resolvePluginPath(installation.path);
|
|
if (this.options.lifecycleScope === "isolated") {
|
|
return await this.loadPluginFresh(pluginId, installation, pluginPath);
|
|
}
|
|
const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath);
|
|
const existingLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
|
if (existingLifecycle) {
|
|
existingLifecycle.participants.add(this);
|
|
const plugin = await existingLifecycle.promise;
|
|
return this.adoptProcessLoadedPlugin(pluginId, pluginPath, plugin);
|
|
}
|
|
|
|
const lifecycle = this.loadPluginFresh(pluginId, installation, pluginPath);
|
|
const processLifecycle: ProcessPluginLifecycle = {
|
|
promise: lifecycle,
|
|
owner: this,
|
|
participants: new Set([this]),
|
|
};
|
|
PluginLoader.processPluginLifecycles.set(lifecycleKey, processLifecycle);
|
|
try {
|
|
return await lifecycle;
|
|
} catch (error) {
|
|
// Only remove our own rejected promise; a later retry may already own this key.
|
|
if (PluginLoader.processPluginLifecycles.get(lifecycleKey) === processLifecycle) {
|
|
PluginLoader.processPluginLifecycles.delete(lifecycleKey);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private getProcessLifecycleKey(pluginId: string, pluginPath: string): string {
|
|
return `${resolve(this.getProjectRoot())}\u0000${pluginId}\u0000${resolve(pluginPath)}`;
|
|
}
|
|
|
|
private adoptProcessLoadedPlugin(pluginId: string, pluginPath: string, plugin: FusionPlugin): FusionPlugin {
|
|
plugin.state = "started";
|
|
this.plugins.set(pluginId, plugin);
|
|
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
|
this.emit("plugin:loaded", { pluginId, plugin });
|
|
return plugin;
|
|
}
|
|
|
|
private async loadPluginFresh(
|
|
pluginId: string,
|
|
installation: PluginInstallation,
|
|
pluginPath: string,
|
|
): Promise<FusionPlugin> {
|
|
try {
|
|
if (installation.aiScanOnLoad) {
|
|
const scanResult = await scanPluginSecurity({ pluginId, pluginPath });
|
|
await this.options.pluginStore.updatePlugin(pluginId, { lastSecurityScan: scanResult });
|
|
|
|
if (["blocked", "error", "unavailable"].includes(scanResult.verdict)) {
|
|
const errorMessage = `Security scan ${scanResult.verdict}: ${scanResult.summary}`;
|
|
await this.updatePluginState(pluginId, "error", errorMessage);
|
|
this.emit("plugin:error", { pluginId, error: new Error(errorMessage) });
|
|
throw new Error(errorMessage);
|
|
}
|
|
}
|
|
|
|
// Dynamic import the plugin - always bypass cache to get fresh code
|
|
// Our loadedModules cache is cleared on stop, but Node.js ESM cache persists
|
|
const mod = await this.importPluginModule(pluginPath, true);
|
|
const plugin = this.extractPluginFromModule(mod);
|
|
|
|
// Validate manifest
|
|
const manifestValidation = validatePluginManifest(plugin.manifest);
|
|
if (!manifestValidation.valid) {
|
|
throw new Error(
|
|
`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
await this.refreshPersistedManifestMetadata(installation, plugin.manifest);
|
|
|
|
// Check version compatibility
|
|
if (plugin.manifest.fusionVersion) {
|
|
const compatible = this.checkVersionCompatibility(
|
|
plugin.manifest.fusionVersion,
|
|
);
|
|
if (!compatible) {
|
|
this.log.warn(
|
|
`Plugin ${pluginId} requires Fusion ${plugin.manifest.fusionVersion}, minimum is ${MINIMUM_FUSION_VERSION}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Resolve dependencies
|
|
await this.resolveDependencies(plugin);
|
|
|
|
/*
|
|
FNXC:PluginPostgresContract 2026-07-14-18:32:
|
|
Schema compatibility and DDL must finish before started state, map
|
|
publication, or onLoad. A SQLite-only third-party plugin therefore fails
|
|
without leaving subscriptions, timers, or other onLoad side effects.
|
|
*/
|
|
const schemaContract = this.options.taskStore.preflightPluginSchema(pluginId, plugin.hooks);
|
|
if (schemaContract) await this.options.taskStore.runPluginSchemaInits([schemaContract]);
|
|
|
|
// Update state to started
|
|
await this.updatePluginState(pluginId, "started");
|
|
|
|
// Update plugin state locally and store
|
|
plugin.state = "started";
|
|
this.plugins.set(pluginId, plugin);
|
|
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
|
if (schemaContract) this.pluginSchemaContracts.set(pluginId, schemaContract);
|
|
|
|
// Call onLoad hook
|
|
const ctx = await this.createContext(plugin);
|
|
try {
|
|
await this.safeCallHook(plugin, "onLoad", [ctx]);
|
|
} catch (loadErr) {
|
|
// onLoad failed - clean up and propagate error
|
|
this.plugins.delete(pluginId);
|
|
this.pluginRoots.delete(pluginId);
|
|
this.pluginSchemaContracts.delete(pluginId);
|
|
const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr);
|
|
await this.updatePluginState(
|
|
pluginId,
|
|
"error",
|
|
`onLoad failed: ${errorMsg}`,
|
|
);
|
|
this.emit("plugin:error", {
|
|
pluginId,
|
|
error: loadErr instanceof Error ? loadErr : new Error(errorMsg),
|
|
});
|
|
throw loadErr;
|
|
}
|
|
|
|
this.recordActivationEvent(pluginId, plugin);
|
|
this.emit("plugin:loaded", { pluginId, plugin });
|
|
return plugin;
|
|
} catch (err) {
|
|
// 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);
|
|
this.pluginSchemaContracts.delete(pluginId);
|
|
|
|
// Error isolation: set error state but don't crash
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
await this.updatePluginState(
|
|
pluginId,
|
|
"error",
|
|
errorMsg,
|
|
);
|
|
|
|
this.emit("plugin:error", {
|
|
pluginId,
|
|
error: err instanceof Error ? err : new Error(errorMsg),
|
|
});
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
private async refreshPersistedManifestMetadata(
|
|
installation: PluginInstallation,
|
|
manifest: PluginManifest,
|
|
): Promise<void> {
|
|
const versionChanged = installation.version !== manifest.version;
|
|
const settingsSchemaChanged =
|
|
this.stableManifestMetadataJson(installation.settingsSchema ?? null) !==
|
|
this.stableManifestMetadataJson(manifest.settingsSchema ?? null);
|
|
|
|
if (!versionChanged && !settingsSchemaChanged) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
/*
|
|
FNXC:Plugins 2026-07-12-10:59:
|
|
FN-7855 requires each fresh module re-import to reconcile persisted manifest metadata for path-registered plugins, because rebuilt code can change version/settingsSchema without re-registration.
|
|
Keep this update metadata-only so per-project enablement and saved setting values survive reload/restart; bundled plugins may already be current from ensureBundledPluginInstalled, making this idempotent.
|
|
*/
|
|
await this.options.pluginStore.updatePlugin(installation.id, {
|
|
...(versionChanged ? { version: manifest.version } : {}),
|
|
...(settingsSchemaChanged ? { settingsSchema: manifest.settingsSchema ?? null } : {}),
|
|
});
|
|
} catch (err) {
|
|
this.log.warn(
|
|
`Failed to refresh persisted manifest metadata for plugin ${installation.id}:`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
private stableManifestMetadataJson(value: unknown): string {
|
|
if (value === null || value === undefined) {
|
|
return "null";
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((entry) => this.stableManifestMetadataJson(entry)).join(",")}]`;
|
|
}
|
|
if (typeof value === "object") {
|
|
const record = value as Record<string, unknown>;
|
|
return `{${Object.keys(record)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${this.stableManifestMetadataJson(record[key])}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
private resolvePluginPath(path: string): string {
|
|
// If already absolute, use as-is
|
|
if (isAbsolute(path)) {
|
|
return path;
|
|
}
|
|
|
|
// Check if it's an npm package (contains / or starts with @)
|
|
if (path.startsWith("@") || path.includes("/")) {
|
|
// For npm packages, we'd use require.resolve in a real implementation
|
|
// For now, assume it's a local path relative to project root
|
|
return resolve(this.getProjectRoot(), path);
|
|
}
|
|
|
|
// Default: resolve relative to project root
|
|
return resolve(this.getProjectRoot(), path);
|
|
}
|
|
|
|
private async importPluginModule(path: string, bypassCache = false): Promise<unknown> {
|
|
// Check cache first (unless bypassing cache for reload)
|
|
if (!bypassCache && this.loadedModules.has(path)) {
|
|
return this.loadedModules.get(path)!;
|
|
}
|
|
|
|
let pathStats;
|
|
try {
|
|
pathStats = await stat(path);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Plugin entry does not exist: ${path} (${errorMessage})`);
|
|
}
|
|
|
|
if (pathStats.isDirectory()) {
|
|
throw new Error(`Plugin entry must be a file, got directory: ${path}`);
|
|
}
|
|
|
|
// Dynamic import - normalize to file URL so query params are honored
|
|
// consistently across Node + Vitest environments.
|
|
const moduleUrl = pathToFileURL(path).href;
|
|
let mod: unknown;
|
|
|
|
if (bypassCache) {
|
|
moduleImportVersion += 1;
|
|
const ext = extname(path);
|
|
const baseName = basename(path, ext);
|
|
const reloadedPath = resolve(dirname(path), `.${baseName}.reload-${moduleImportVersion}${ext}`);
|
|
await copyFile(path, reloadedPath);
|
|
mod = await import(pathToFileURL(reloadedPath).href);
|
|
} else {
|
|
mod = await import(moduleUrl);
|
|
}
|
|
this.loadedModules.set(path, mod);
|
|
return mod;
|
|
}
|
|
|
|
/**
|
|
* Invalidate the module cache for a plugin path.
|
|
* This ensures a fresh import when the plugin is loaded again.
|
|
*/
|
|
private invalidateModuleCache(path: string): void {
|
|
this.loadedModules.delete(path);
|
|
this.log.log(`Module cache invalidated for: ${path}`);
|
|
}
|
|
|
|
/**
|
|
* Reload a plugin: stop the old instance, re-import, and start the new one.
|
|
* On failure, roll back to the old instance.
|
|
*
|
|
* @param pluginId - The plugin to reload
|
|
* @param options - Options including timeout for onUnload/onLoad hooks
|
|
*/
|
|
async reloadPlugin(
|
|
pluginId: string,
|
|
options?: { timeoutMs?: number },
|
|
): Promise<FusionPlugin> {
|
|
const installation = await this.options.pluginStore.getPlugin(pluginId);
|
|
const pluginPath = this.resolvePluginPath(installation.path);
|
|
/*
|
|
FNXC:PluginLoader 2026-07-23-12:00:
|
|
FN-8596 requires explicit private reload semantics for isolated discovery
|
|
loaders. Never synchronize an inspection instance into shared participants.
|
|
*/
|
|
if (this.options.lifecycleScope === "isolated") {
|
|
return await this.reloadPluginFresh(pluginId, installation, pluginPath, options);
|
|
}
|
|
const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath);
|
|
const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
|
const precedingLifecycle = processLifecycle?.promise;
|
|
|
|
const reload = this.enqueueProcessLifecycleOperation(lifecycleKey, async () => {
|
|
await precedingLifecycle;
|
|
const owner = processLifecycle?.owner ?? this;
|
|
const plugin = await owner.reloadPluginFresh(pluginId, installation, pluginPath, options);
|
|
if (processLifecycle) {
|
|
this.synchronizeProcessPlugin(processLifecycle, pluginId, pluginPath, plugin);
|
|
}
|
|
return plugin;
|
|
});
|
|
if (processLifecycle) processLifecycle.promise = reload;
|
|
try {
|
|
return await reload;
|
|
} catch (error) {
|
|
if (processLifecycle && PluginLoader.processPluginLifecycles.get(lifecycleKey) === processLifecycle) {
|
|
// reloadPluginFresh restores the canonical owner when rollback succeeds.
|
|
const restored = processLifecycle.owner.plugins.get(pluginId);
|
|
if (restored) {
|
|
processLifecycle.promise = Promise.resolve(restored);
|
|
this.synchronizeProcessPlugin(processLifecycle, pluginId, pluginPath, restored);
|
|
} else {
|
|
PluginLoader.processPluginLifecycles.delete(lifecycleKey);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/*
|
|
FNXC:PluginLoader 2026-07-22-16:20:
|
|
A process lifecycle is shared by host and engine loaders, not merely its
|
|
initial onLoad promise. Stop and reload must update every adopter so no
|
|
loader retains an old active instance after another surface changes it.
|
|
*/
|
|
private synchronizeProcessPlugin(
|
|
lifecycle: ProcessPluginLifecycle,
|
|
pluginId: string,
|
|
pluginPath: string,
|
|
plugin: FusionPlugin,
|
|
): void {
|
|
for (const loader of lifecycle.participants) {
|
|
if (loader === lifecycle.owner) continue;
|
|
loader.plugins.set(pluginId, plugin);
|
|
loader.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
|
loader.pluginSchemaContracts.delete(pluginId);
|
|
}
|
|
}
|
|
|
|
private async enqueueProcessLifecycleOperation<T>(
|
|
lifecycleKey: string,
|
|
operation: () => Promise<T>,
|
|
): Promise<T> {
|
|
const predecessor = PluginLoader.processPluginLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
|
|
const queued = predecessor.catch(() => undefined).then(operation);
|
|
const tail = queued.then(() => undefined, () => undefined);
|
|
PluginLoader.processPluginLifecycleTails.set(lifecycleKey, tail);
|
|
void tail.finally(() => {
|
|
if (PluginLoader.processPluginLifecycleTails.get(lifecycleKey) === tail) {
|
|
PluginLoader.processPluginLifecycleTails.delete(lifecycleKey);
|
|
}
|
|
});
|
|
return await queued;
|
|
}
|
|
|
|
private async reloadPluginFresh(
|
|
pluginId: string,
|
|
installation: PluginInstallation,
|
|
pluginPath: string,
|
|
options?: { timeoutMs?: number },
|
|
): Promise<FusionPlugin> {
|
|
const timeoutMs = options?.timeoutMs ?? 5000;
|
|
|
|
// A concurrent startup load may have completed on another loader.
|
|
const oldPlugin = this.plugins.get(pluginId);
|
|
if (!oldPlugin) {
|
|
throw Object.assign(new Error(`Plugin "${pluginId}" is not loaded`), {
|
|
code: "PLUGIN_NOT_LOADED",
|
|
});
|
|
}
|
|
|
|
this.log.log(`Reloading plugin: ${pluginId}`);
|
|
|
|
// Call onUnload with timeout
|
|
try {
|
|
const ctx = await this.createContext(oldPlugin);
|
|
await this.withTimeout(
|
|
this.safeCallHook(oldPlugin, "onUnload", [ctx]),
|
|
timeoutMs,
|
|
`onUnload timeout for ${pluginId}`,
|
|
);
|
|
} catch (err) {
|
|
this.log.warn(`onUnload for ${pluginId} timed out or failed:`, err);
|
|
// Continue with reload despite onUnload issues
|
|
}
|
|
|
|
// Remove old module from cache
|
|
this.invalidateModuleCache(pluginPath);
|
|
|
|
// Snapshot old plugin for rollback
|
|
const snapshot = { ...oldPlugin };
|
|
const oldSchemaContract = this.pluginSchemaContracts.get(pluginId);
|
|
|
|
try {
|
|
// Re-import the plugin module
|
|
const mod = await this.importPluginModule(pluginPath, true);
|
|
const newPlugin = this.extractPluginFromModule(mod);
|
|
|
|
// Validate manifest
|
|
const manifestValidation = validatePluginManifest(newPlugin.manifest);
|
|
if (!manifestValidation.valid) {
|
|
throw new Error(
|
|
`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`,
|
|
);
|
|
}
|
|
|
|
// Update plugin state
|
|
const schemaContract = this.options.taskStore.preflightPluginSchema(pluginId, newPlugin.hooks);
|
|
if (schemaContract) await this.options.taskStore.runPluginSchemaInits([schemaContract]);
|
|
newPlugin.state = "started";
|
|
|
|
// Replace in plugins map
|
|
this.plugins.set(pluginId, newPlugin);
|
|
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
|
if (schemaContract) this.pluginSchemaContracts.set(pluginId, schemaContract);
|
|
else this.pluginSchemaContracts.delete(pluginId);
|
|
|
|
// Create fresh context and call onLoad
|
|
const ctx = await this.createContext(newPlugin);
|
|
await this.withTimeout(
|
|
this.safeCallHook(newPlugin, "onLoad", [ctx]),
|
|
timeoutMs,
|
|
`onLoad timeout for ${pluginId}`,
|
|
);
|
|
|
|
await this.refreshPersistedManifestMetadata(installation, newPlugin.manifest);
|
|
|
|
// State is already "started", no need to update store
|
|
// (avoiding started -> started transition which is disallowed)
|
|
|
|
this.log.log(`Plugin ${pluginId} reloaded successfully`);
|
|
|
|
this.recordActivationEvent(pluginId, newPlugin);
|
|
this.emit("plugin:reloaded", { pluginId, plugin: newPlugin });
|
|
return newPlugin;
|
|
} catch (err) {
|
|
// Rollback: restore old plugin
|
|
this.log.error(`Reload failed for ${pluginId}, rolling back:`, err);
|
|
|
|
try {
|
|
// Restore old plugin
|
|
this.plugins.set(pluginId, snapshot);
|
|
this.pluginRoots.set(pluginId, resolvePluginRootFromEntryPath(pluginPath));
|
|
if (oldSchemaContract) this.pluginSchemaContracts.set(pluginId, oldSchemaContract);
|
|
else this.pluginSchemaContracts.delete(pluginId);
|
|
|
|
// Attempt to reactivate old plugin
|
|
const ctx = await this.createContext(snapshot);
|
|
await this.withTimeout(
|
|
this.safeCallHook(snapshot, "onLoad", [ctx]),
|
|
timeoutMs,
|
|
`Rollback onLoad timeout for ${pluginId}`,
|
|
);
|
|
|
|
// Update store state back to started
|
|
await this.updatePluginState(pluginId, "started");
|
|
|
|
this.log.warn(`Rollback successful for ${pluginId}`);
|
|
} catch (rollbackErr) {
|
|
// Rollback also failed - remove plugin and set error state
|
|
this.log.error(
|
|
`Rollback failed for ${pluginId}, removing plugin:`,
|
|
rollbackErr,
|
|
);
|
|
|
|
this.plugins.delete(pluginId);
|
|
this.pluginRoots.delete(pluginId);
|
|
this.pluginSchemaContracts.delete(pluginId);
|
|
|
|
const originalError = err instanceof Error ? err.message : String(err);
|
|
const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
const combinedError = `Reload failed and rollback failed: ${originalError}; ${rollbackError}`;
|
|
|
|
await this.updatePluginState(
|
|
pluginId,
|
|
"error",
|
|
combinedError,
|
|
);
|
|
|
|
this.emit("plugin:error", {
|
|
pluginId,
|
|
error: new Error(combinedError),
|
|
});
|
|
|
|
throw err; // Throw original error
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a promise with a timeout.
|
|
*/
|
|
private withTimeout<T>(
|
|
promise: Promise<T>,
|
|
ms: number,
|
|
timeoutMessage: string,
|
|
): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
reject(new Error(timeoutMessage));
|
|
}, ms);
|
|
|
|
promise
|
|
.then((result) => {
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
})
|
|
.catch((err) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
private extractPluginFromModule(mod: unknown): FusionPlugin {
|
|
if (!mod || typeof mod !== "object") {
|
|
throw new Error("Plugin module must export an object");
|
|
}
|
|
|
|
const obj = mod as Record<string, unknown>;
|
|
|
|
// Look for default export first, then named export
|
|
const pluginExport = obj.default ?? obj.plugin;
|
|
|
|
if (!pluginExport || typeof pluginExport !== "object") {
|
|
throw new Error(
|
|
"Plugin module must export a default 'FusionPlugin' or have a 'plugin' export",
|
|
);
|
|
}
|
|
|
|
const plugin = pluginExport as FusionPlugin;
|
|
|
|
// Basic validation
|
|
if (!plugin.manifest?.id) {
|
|
throw new Error("Plugin must have a manifest with id");
|
|
}
|
|
|
|
return plugin;
|
|
}
|
|
|
|
private checkVersionCompatibility(requiredVersion: string): boolean {
|
|
// Simple version comparison for now
|
|
// In a real implementation, use a proper semver library
|
|
const required = this.parseVersion(requiredVersion);
|
|
const minimum = this.parseVersion(MINIMUM_FUSION_VERSION);
|
|
|
|
if (required.major > minimum.major) return false;
|
|
if (required.major < minimum.major) return true;
|
|
if (required.minor > minimum.minor) return false;
|
|
if (required.minor < minimum.minor) return true;
|
|
return required.patch <= minimum.patch;
|
|
}
|
|
|
|
private parseVersion(version: string): { major: number; minor: number; patch: number } {
|
|
const parts = version.split(".").map(Number);
|
|
return {
|
|
major: parts[0] || 0,
|
|
minor: parts[1] || 0,
|
|
patch: parts[2] || 0,
|
|
};
|
|
}
|
|
|
|
private async resolveDependencies(plugin: FusionPlugin): Promise<void> {
|
|
if (!plugin.manifest.dependencies?.length) return;
|
|
|
|
for (const depId of plugin.manifest.dependencies) {
|
|
if (!this.plugins.has(depId)) {
|
|
throw new Error(
|
|
`Plugin ${plugin.manifest.id} depends on ${depId}, which is not loaded`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Load All ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Load all plugins that are enabled for this PluginStore's project scope in dependency order.
|
|
*/
|
|
async loadAllPlugins(): Promise<{ loaded: number; errors: number }> {
|
|
const plugins = await this.options.pluginStore.listPlugins();
|
|
for (const installation of plugins) {
|
|
if (!installation.enabled) {
|
|
/*
|
|
* FNXC:PluginSkills 2026-07-10-00:00:
|
|
* Disabled-at-load plugins must be visible in normal daemon logs. Issue #1981 was expensive to diagnose because loadAllPlugins silently omitted disabled plugins, hiding that the loader was using the wrong project enablement scope.
|
|
*/
|
|
this.log.warn(`Skipped disabled plugin during loadAllPlugins: ${installation.id}`);
|
|
}
|
|
}
|
|
const sorted = this.resolveLoadOrder(plugins.filter((plugin) => plugin.enabled));
|
|
|
|
let loaded = 0;
|
|
let errors = 0;
|
|
|
|
for (const installation of sorted) {
|
|
try {
|
|
await this.loadPlugin(installation.id);
|
|
loaded++;
|
|
} catch (err) {
|
|
if ((err as { code?: string }).code === "PLUGIN_DISABLED") {
|
|
this.log.warn(`Skipped disabled plugin during loadAllPlugins: ${installation.id}`);
|
|
continue;
|
|
}
|
|
errors++;
|
|
this.log.error(
|
|
`Failed to load plugin ${installation.id}:`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { loaded, errors };
|
|
}
|
|
|
|
/**
|
|
* Topological sort for load order.
|
|
*/
|
|
resolveLoadOrder(plugins: PluginInstallation[]): PluginInstallation[] {
|
|
const pluginMap = new Map(plugins.map((p) => [p.id, p]));
|
|
const visited = new Set<string>();
|
|
const result: PluginInstallation[] = [];
|
|
const visiting = new Set<string>();
|
|
|
|
const visit = (id: string) => {
|
|
if (visited.has(id)) return;
|
|
if (visiting.has(id)) {
|
|
throw new Error(`Circular dependency detected: ${id}`);
|
|
}
|
|
|
|
const plugin = pluginMap.get(id);
|
|
if (!plugin) return; // Skip plugins not in our list
|
|
|
|
visiting.add(id);
|
|
|
|
// Visit dependencies first
|
|
for (const depId of plugin.dependencies || []) {
|
|
visit(depId);
|
|
}
|
|
|
|
visiting.delete(id);
|
|
visited.add(id);
|
|
result.push(plugin);
|
|
};
|
|
|
|
for (const plugin of plugins) {
|
|
visit(plugin.id);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── Plugin Stopping ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Stop and unload a single plugin.
|
|
*/
|
|
async stopPlugin(pluginId: string): Promise<void> {
|
|
let installation: PluginInstallation;
|
|
try {
|
|
installation = await this.options.pluginStore.getPlugin(pluginId);
|
|
} catch {
|
|
this.log.log(`Plugin not loaded: ${pluginId}`);
|
|
return;
|
|
}
|
|
const pluginPath = this.resolvePluginPath(installation.path);
|
|
if (this.options.lifecycleScope === "isolated") {
|
|
await this.stopPluginFresh(pluginId, pluginPath);
|
|
return;
|
|
}
|
|
const lifecycleKey = this.getProcessLifecycleKey(pluginId, pluginPath);
|
|
const processLifecycle = PluginLoader.processPluginLifecycles.get(lifecycleKey);
|
|
if (!processLifecycle) {
|
|
await this.stopPluginFresh(pluginId, pluginPath);
|
|
return;
|
|
}
|
|
|
|
await this.enqueueProcessLifecycleOperation(lifecycleKey, async () => {
|
|
try {
|
|
await processLifecycle.promise;
|
|
} catch {
|
|
// A rejected load has already cleaned its local state.
|
|
}
|
|
if (processLifecycle.owner !== this) {
|
|
// Participants adopted the owner's instance; stopping one only detaches
|
|
// that view and must not unload or persist state for the shared owner.
|
|
processLifecycle.participants.delete(this);
|
|
this.discardProcessPlugin(pluginId, pluginPath);
|
|
return;
|
|
}
|
|
await processLifecycle.owner.stopPluginFresh(pluginId, pluginPath);
|
|
for (const loader of processLifecycle.participants) {
|
|
if (loader === processLifecycle.owner) continue;
|
|
loader.discardProcessPlugin(pluginId, pluginPath);
|
|
}
|
|
if (PluginLoader.processPluginLifecycles.get(lifecycleKey) === processLifecycle) {
|
|
PluginLoader.processPluginLifecycles.delete(lifecycleKey);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async stopPluginFresh(pluginId: string, pluginPath: string): Promise<void> {
|
|
const plugin = this.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
this.log.log(`Plugin not loaded: ${pluginId}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const ctx = await this.createContext(plugin);
|
|
await this.withTimeout(
|
|
this.safeCallHook(plugin, "onUnload", [ctx]),
|
|
5000,
|
|
`onUnload timeout for ${pluginId}`,
|
|
);
|
|
} catch (err) {
|
|
this.log.error(`Error in onUnload for ${pluginId}:`, err);
|
|
}
|
|
|
|
await this.updatePluginState(pluginId, "stopped");
|
|
this.discardProcessPlugin(pluginId, pluginPath);
|
|
this.emit("plugin:unloaded", { pluginId });
|
|
this.emit("plugin:stopped", pluginId);
|
|
}
|
|
|
|
private discardProcessPlugin(pluginId: string, pluginPath: string): void {
|
|
this.plugins.delete(pluginId);
|
|
this.pluginRoots.delete(pluginId);
|
|
this.pluginSchemaContracts.delete(pluginId);
|
|
this.invalidateModuleCache(pluginPath);
|
|
}
|
|
|
|
/**
|
|
* Stop all loaded plugins in reverse dependency order.
|
|
*/
|
|
async stopAllPlugins(): Promise<void> {
|
|
// Get plugins in reverse topological order
|
|
const loadedPlugins = Array.from(this.plugins.values());
|
|
const sorted = this.resolveLoadOrder(
|
|
loadedPlugins.map((p) => ({
|
|
id: p.manifest.id,
|
|
name: p.manifest.name,
|
|
version: p.manifest.version,
|
|
description: p.manifest.description,
|
|
author: p.manifest.author,
|
|
homepage: p.manifest.homepage,
|
|
path: "",
|
|
enabled: true,
|
|
state: p.state,
|
|
settings: {},
|
|
dependencies: p.manifest.dependencies,
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
})),
|
|
);
|
|
|
|
// Stop in reverse order
|
|
for (const plugin of sorted.reverse()) {
|
|
try {
|
|
await this.stopPlugin(plugin.id);
|
|
} catch (err) {
|
|
this.log.error(`Error stopping plugin ${plugin.id}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Hook Invocation ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Invoke a hook on all loaded plugins.
|
|
* Errors are isolated - one plugin's failure doesn't affect others.
|
|
*/
|
|
async invokeHook(
|
|
hookName: keyof FusionPlugin["hooks"],
|
|
...args: unknown[]
|
|
): Promise<void> {
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
const hook = plugin.hooks[hookName];
|
|
if (!hook) continue;
|
|
|
|
try {
|
|
await this.safeCallHook(plugin, hookName, args);
|
|
} catch (err) {
|
|
this.log.error(
|
|
`Error in ${hookName} hook for ${pluginId}:`,
|
|
err,
|
|
);
|
|
|
|
// Update plugin state to error
|
|
try {
|
|
await this.updatePluginState(
|
|
pluginId,
|
|
"error",
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
plugin.state = "error";
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
|
|
// Call onError hook if available
|
|
if (hookName !== "onError" && plugin.hooks.onError) {
|
|
try {
|
|
const ctx = await this.createContext(plugin);
|
|
await plugin.hooks.onError(
|
|
err instanceof Error ? err : new Error(String(err)),
|
|
ctx,
|
|
);
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async safeCallHook(
|
|
plugin: FusionPlugin,
|
|
hookName: keyof FusionPlugin["hooks"],
|
|
args: unknown[],
|
|
): Promise<void> {
|
|
const hook = plugin.hooks[hookName];
|
|
if (!hook) return;
|
|
|
|
const fn = hook as (...args: unknown[]) => unknown;
|
|
const result = fn(...await this.withLifecycleHookContext(plugin, hookName, args));
|
|
if (result instanceof Promise) {
|
|
await result;
|
|
}
|
|
}
|
|
|
|
private async withLifecycleHookContext(
|
|
plugin: FusionPlugin,
|
|
hookName: keyof FusionPlugin["hooks"],
|
|
args: unknown[],
|
|
): Promise<unknown[]> {
|
|
if (!this.isTaskLifecycleHook(hookName) || this.hasPluginContext(args.at(-1))) {
|
|
return args;
|
|
}
|
|
|
|
/*
|
|
FNXC:PluginHooks 2026-07-01-13:36:
|
|
Runtime task lifecycle hooks are invoked from fire-and-forget TaskStore event bridges, but the public hook contract still requires a per-plugin PluginContext. Append the context in PluginLoader so all runtime callers keep the fast raw event-argument path while plugins consistently receive taskStore, settings, logger, and emitEvent.
|
|
*/
|
|
return [...args, await this.createContext(plugin)];
|
|
}
|
|
|
|
private isTaskLifecycleHook(hookName: keyof FusionPlugin["hooks"]): boolean {
|
|
return hookName === "onTaskCreated" || hookName === "onTaskMoved" || hookName === "onTaskCompleted";
|
|
}
|
|
|
|
private hasPluginContext(value: unknown): value is PluginContext {
|
|
return Boolean(
|
|
value
|
|
&& typeof value === "object"
|
|
&& "taskStore" in value
|
|
&& "settings" in value
|
|
&& "logger" in value
|
|
&& "emitEvent" in value,
|
|
);
|
|
}
|
|
|
|
async checkPluginSetup(pluginId: string): Promise<PluginSetupCheckResult> {
|
|
const plugin = this.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
throw new Error(`Plugin "${pluginId}" is not loaded`);
|
|
}
|
|
|
|
if (!plugin.setup) {
|
|
return { status: "installed" };
|
|
}
|
|
|
|
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 30_000;
|
|
|
|
try {
|
|
const ctx = await this.createContext(plugin);
|
|
return await this.withTimeout(
|
|
plugin.setup.hooks.checkSetup(ctx),
|
|
timeout,
|
|
`Setup check for "${pluginId}" timed out after ${timeout}ms`,
|
|
);
|
|
} catch (error) {
|
|
return {
|
|
status: "error",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
async installPluginSetup(pluginId: string): Promise<void> {
|
|
const plugin = this.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
throw new Error(`Plugin "${pluginId}" is not loaded`);
|
|
}
|
|
|
|
if (!plugin.setup?.hooks.install) {
|
|
throw new Error(`Plugin "${pluginId}" has no install hook`);
|
|
}
|
|
|
|
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 120_000;
|
|
const ctx = await this.createContext(plugin);
|
|
|
|
try {
|
|
await this.withTimeout(
|
|
plugin.setup.hooks.install(ctx),
|
|
timeout,
|
|
`Install command for "${pluginId}" timed out after ${timeout}ms`,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes(`timed out after ${timeout}ms`)) {
|
|
throw error;
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Install hook failed for "${pluginId}": ${message}`);
|
|
}
|
|
}
|
|
|
|
async uninstallPluginSetup(pluginId: string): Promise<void> {
|
|
const plugin = this.plugins.get(pluginId);
|
|
if (!plugin) {
|
|
throw new Error(`Plugin "${pluginId}" is not loaded`);
|
|
}
|
|
|
|
if (!plugin.setup?.hooks.uninstall) {
|
|
return;
|
|
}
|
|
|
|
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 60_000;
|
|
const ctx = await this.createContext(plugin);
|
|
|
|
try {
|
|
await this.withTimeout(
|
|
plugin.setup.hooks.uninstall(ctx),
|
|
timeout,
|
|
`Uninstall command for "${pluginId}" timed out after ${timeout}ms`,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes(`timed out after ${timeout}ms`)) {
|
|
throw error;
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Uninstall hook failed for "${pluginId}": ${message}`);
|
|
}
|
|
}
|
|
|
|
// ── Accessors ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get all tools from loaded plugins.
|
|
*/
|
|
getPluginTools(): PluginToolDefinition[] {
|
|
const tools: PluginToolDefinition[] = [];
|
|
for (const plugin of this.plugins.values()) {
|
|
if (plugin.tools) {
|
|
tools.push(...plugin.tools);
|
|
}
|
|
}
|
|
return tools;
|
|
}
|
|
|
|
/**
|
|
* Get all routes from loaded plugins.
|
|
*/
|
|
getPluginRoutes(): Array<{ pluginId: string; route: PluginRouteDefinition }> {
|
|
const routes: Array<{ pluginId: string; route: PluginRouteDefinition }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.routes) {
|
|
for (const route of plugin.routes) {
|
|
routes.push({ pluginId, route });
|
|
}
|
|
}
|
|
}
|
|
return routes;
|
|
}
|
|
|
|
/**
|
|
* Get all UI slot definitions from loaded plugins.
|
|
*/
|
|
getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> {
|
|
const slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.uiSlots) {
|
|
for (const slot of plugin.uiSlots) {
|
|
slots.push({
|
|
pluginId,
|
|
slot: {
|
|
...slot,
|
|
surface: slot.surface ?? (typeof slot.slotId === "string" ? slot.slotId as PluginUiSlotDefinition["surface"] : undefined),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return slots.sort((a, b) => {
|
|
const orderA = a.slot.order ?? Number.MAX_SAFE_INTEGER;
|
|
const orderB = b.slot.order ?? Number.MAX_SAFE_INTEGER;
|
|
if (orderA !== orderB) return orderA - orderB;
|
|
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
|
|
return String(a.slot.slotId).localeCompare(String(b.slot.slotId));
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Get all structured UI contributions from loaded plugins.
|
|
*/
|
|
getPluginUiContributions(): Array<{ pluginId: string; contribution: PluginUiContributionDefinition }> {
|
|
const contributions: Array<{ pluginId: string; contribution: PluginUiContributionDefinition }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.uiContributions) {
|
|
for (const contribution of plugin.uiContributions) {
|
|
contributions.push({
|
|
pluginId,
|
|
contribution: normalizePluginUiContributionDefinition(contribution),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return contributions.sort((a, b) => {
|
|
const orderA = a.contribution.order ?? Number.MAX_SAFE_INTEGER;
|
|
const orderB = b.contribution.order ?? Number.MAX_SAFE_INTEGER;
|
|
if (orderA !== orderB) return orderA - orderB;
|
|
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
|
|
return a.contribution.contributionId.localeCompare(b.contribution.contributionId);
|
|
});
|
|
}
|
|
|
|
private async resolveCurrentManifestPath(pluginEntryPath: string): Promise<string | null> {
|
|
const candidates = new Set<string>();
|
|
|
|
try {
|
|
const entryStats = await stat(pluginEntryPath);
|
|
if (entryStats.isDirectory()) {
|
|
candidates.add(join(pluginEntryPath, "manifest.json"));
|
|
}
|
|
} catch {
|
|
// The entry file can be temporarily absent during rebuilds; still try the
|
|
// package-root candidates derived from the persisted loadable path.
|
|
}
|
|
|
|
const entryDir = dirname(pluginEntryPath);
|
|
candidates.add(join(entryDir, "manifest.json"));
|
|
|
|
if (PLUGIN_MANIFEST_PARENT_DIR_NAMES.has(basename(entryDir))) {
|
|
candidates.add(join(dirname(entryDir), "manifest.json"));
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const candidateStats = await stat(candidate);
|
|
if (candidateStats.isFile()) {
|
|
return candidate;
|
|
}
|
|
} catch {
|
|
// Try the next candidate so unusual installs keep falling back safely.
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async getCurrentManifestDashboardViews(pluginId: string): Promise<CurrentManifestDashboardViewsResult> {
|
|
let installation: PluginInstallation;
|
|
try {
|
|
installation = await this.options.pluginStore.getPlugin(pluginId);
|
|
} catch (err) {
|
|
this.log.warn(`Could not refresh dashboard views for ${pluginId}:`, err);
|
|
return { found: false };
|
|
}
|
|
|
|
const pluginPath = this.resolvePluginPath(installation.path);
|
|
const manifestPath = await this.resolveCurrentManifestPath(pluginPath);
|
|
if (!manifestPath) {
|
|
return { found: false };
|
|
}
|
|
|
|
let manifest: unknown;
|
|
try {
|
|
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
} catch (err) {
|
|
this.log.warn(`Could not read dashboard-view manifest metadata for ${pluginId}:`, err);
|
|
return { found: false };
|
|
}
|
|
|
|
const validation = validatePluginManifest(manifest);
|
|
if (!validation.valid) {
|
|
this.log.warn(`Could not refresh dashboard views for ${pluginId}: ${validation.errors.join(", ")}`);
|
|
return { found: false };
|
|
}
|
|
|
|
const dashboardViews = (manifest as { dashboardViews?: unknown }).dashboardViews;
|
|
if (dashboardViews === undefined) {
|
|
return { found: true, dashboardViews: [] };
|
|
}
|
|
|
|
return { found: true, dashboardViews: dashboardViews as PluginDashboardViewDefinition[] };
|
|
}
|
|
|
|
/**
|
|
* Get all top-level dashboard view definitions from loaded plugins.
|
|
*
|
|
* FNXC:Plugins 2026-06-28-12:30:
|
|
* Navigation metadata must come from the current on-disk manifest when present because dashboard component bundles can update immediately after a rebuild while the loaded plugin module instance remains cached. Reading manifest dashboardViews here keeps desktop and mobile nav icon/label/placement in sync with the served in-view bundle for every plugin, without per-plugin pins.
|
|
*
|
|
* FNXC:Plugins 2026-06-28-19:58:
|
|
* A valid manifest that omits dashboardViews is authoritative and means the rebuilt plugin now exposes no top-level views. Do not fall back to stale module dashboardViews after a successful manifest read; fallback is only for missing/unreadable/invalid manifests.
|
|
*/
|
|
async getPluginDashboardViews(): Promise<Array<{ pluginId: string; view: PluginDashboardViewDefinition }>> {
|
|
const views: Array<{ pluginId: string; view: PluginDashboardViewDefinition }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
const currentManifestDashboardViews = await this.getCurrentManifestDashboardViews(pluginId);
|
|
const dashboardViews = currentManifestDashboardViews.found
|
|
? currentManifestDashboardViews.dashboardViews
|
|
: plugin.dashboardViews;
|
|
if (dashboardViews) {
|
|
for (const view of dashboardViews) {
|
|
views.push({ pluginId, view });
|
|
}
|
|
}
|
|
}
|
|
return views;
|
|
}
|
|
|
|
/**
|
|
* Get all schema initialization hooks from loaded plugins.
|
|
*/
|
|
getPluginSchemaInitHooks(): LoadedPluginSchemaContract[] {
|
|
return [...this.pluginSchemaContracts.values()];
|
|
}
|
|
|
|
/**
|
|
* Get all runtime registrations from loaded plugins.
|
|
* Returns plugin ownership metadata along with the runtime registration.
|
|
*/
|
|
getPluginRuntimes(): Array<{ pluginId: string; runtime: PluginRuntimeRegistration }> {
|
|
const runtimes: Array<{ pluginId: string; runtime: PluginRuntimeRegistration }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.runtime) {
|
|
runtimes.push({ pluginId, runtime: plugin.runtime });
|
|
}
|
|
}
|
|
return runtimes;
|
|
}
|
|
|
|
/**
|
|
* Get all CLI-backed provider contributions from loaded plugins.
|
|
*/
|
|
getCliProviderContributions(): Array<{ pluginId: string; contribution: CliProviderContribution }> {
|
|
const contributions: Array<{ pluginId: string; contribution: CliProviderContribution }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (!plugin.cliProviders) continue;
|
|
for (const contribution of plugin.cliProviders) {
|
|
contributions.push({ pluginId, contribution });
|
|
}
|
|
}
|
|
return contributions;
|
|
}
|
|
|
|
/**
|
|
* 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; 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, pluginRoot: this.pluginRoots.get(pluginId) });
|
|
}
|
|
}
|
|
}
|
|
return skills;
|
|
}
|
|
|
|
/**
|
|
* Get raw MCP contributions from loaded plugins. Consumers must still apply
|
|
* project_plugin_states before session or UI use.
|
|
*
|
|
* FNXC:PluginMcpServers 2026-07-22-12:00:
|
|
* FN-8491 keeps loader enumeration intentionally raw so one project-scoped
|
|
* provider can enforce enablement consistently for every caller.
|
|
*/
|
|
getPluginMcpServers(): Array<{ pluginId: string; server: PluginMcpServerContribution }> {
|
|
const servers: Array<{ pluginId: string; server: PluginMcpServerContribution }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
/*
|
|
* FNXC:PluginMcpServers 2026-07-22-15:35:
|
|
* FN-8491 must isolate malformed runtime plugin contribution containers;
|
|
* only arrays are iterable, while individual malformed servers are
|
|
* filtered by the shared mapper later in MCP resolution.
|
|
*/
|
|
if (!Array.isArray(plugin.mcpServers)) continue;
|
|
for (const server of plugin.mcpServers) servers.push({ pluginId, server });
|
|
}
|
|
return servers;
|
|
}
|
|
|
|
/**
|
|
* Get all workflow step contributions from loaded plugins.
|
|
*/
|
|
getPluginWorkflowSteps(): Array<{ pluginId: string; step: PluginWorkflowStepContribution }> {
|
|
const steps: Array<{ pluginId: string; step: PluginWorkflowStepContribution }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.workflowSteps) {
|
|
for (const step of plugin.workflowSteps) {
|
|
steps.push({ pluginId, step });
|
|
}
|
|
}
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
/**
|
|
* Get all workflow extension contributions from loaded plugins.
|
|
*/
|
|
getPluginWorkflowExtensions(): Array<{ pluginId: string; extension: WorkflowExtensionContribution }> {
|
|
const extensions: Array<{ pluginId: string; extension: WorkflowExtensionContribution }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.workflowExtensions) {
|
|
for (const extension of plugin.workflowExtensions) {
|
|
extensions.push({ pluginId, extension });
|
|
}
|
|
}
|
|
}
|
|
return extensions;
|
|
}
|
|
|
|
/**
|
|
* Get all trait contributions from loaded plugins (U8).
|
|
*/
|
|
getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> {
|
|
const traits: Array<{ pluginId: string; trait: PluginTraitContribution }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.traits) {
|
|
for (const trait of plugin.traits) {
|
|
traits.push({ pluginId, trait });
|
|
}
|
|
}
|
|
}
|
|
return traits;
|
|
}
|
|
|
|
/**
|
|
* Get all workflow step templates derived from loaded plugin contributions.
|
|
*/
|
|
getPluginWorkflowStepTemplates(): Array<{ pluginId: string; template: WorkflowStepTemplate }> {
|
|
return this.getPluginWorkflowSteps().map(({ pluginId, step }) => ({
|
|
pluginId,
|
|
template: {
|
|
id: `plugin:${pluginId}:${step.stepId}`,
|
|
name: step.name,
|
|
description: step.description,
|
|
prompt: step.prompt ?? "",
|
|
mode: step.mode,
|
|
phase: step.phase,
|
|
scriptName: step.scriptName,
|
|
toolMode: step.toolMode,
|
|
defaultOn: step.defaultOn,
|
|
modelProvider: step.modelProvider,
|
|
modelId: step.modelId,
|
|
enabled: step.enabled,
|
|
category: "Plugin",
|
|
icon: "puzzle",
|
|
},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get all prompt contributions from loaded plugins.
|
|
*/
|
|
getPluginPromptContributions(): Array<{
|
|
pluginId: string;
|
|
contribution: PluginPromptContribution;
|
|
config: PluginPromptContributions;
|
|
}> {
|
|
const contributions: Array<{
|
|
pluginId: string;
|
|
contribution: PluginPromptContribution;
|
|
config: PluginPromptContributions;
|
|
}> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.promptContributions) {
|
|
for (const contribution of plugin.promptContributions.contributions) {
|
|
contributions.push({ pluginId, contribution, config: plugin.promptContributions });
|
|
}
|
|
}
|
|
}
|
|
return contributions;
|
|
}
|
|
|
|
/**
|
|
* Get all setup metadata and hooks from loaded plugins.
|
|
*/
|
|
getPluginSetupInfo(): Array<{ pluginId: string; manifest: PluginSetupManifest; hooks: PluginSetupHooks }> {
|
|
const setups: Array<{ pluginId: string; manifest: PluginSetupManifest; hooks: PluginSetupHooks }> = [];
|
|
for (const [pluginId, plugin] of this.plugins) {
|
|
if (plugin.setup) {
|
|
setups.push({ pluginId, manifest: plugin.setup.manifest, hooks: plugin.setup.hooks });
|
|
}
|
|
}
|
|
return setups;
|
|
}
|
|
|
|
/**
|
|
* Get all loaded plugin instances.
|
|
*/
|
|
getLoadedPlugins(): FusionPlugin[] {
|
|
return Array.from(this.plugins.values());
|
|
}
|
|
|
|
/**
|
|
* Get a loaded plugin by id.
|
|
*/
|
|
getPlugin(pluginId: string): FusionPlugin | undefined {
|
|
return this.plugins.get(pluginId);
|
|
}
|
|
|
|
/**
|
|
* Check if a plugin is loaded.
|
|
*/
|
|
isPluginLoaded(pluginId: string): boolean {
|
|
return this.plugins.has(pluginId);
|
|
}
|
|
}
|