From b3e2c6d51650a4df5ddea38ca6d14c6f25e627d4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 11:36:50 +0000 Subject: [PATCH] Plugins: extract loader import flow --- src/extension-host/cutover-inventory.md | 5 +- src/extension-host/loader-import.test.ts | 88 ++++++++++++++++++++++++ src/extension-host/loader-import.ts | 60 ++++++++++++++++ src/plugins/loader.ts | 44 ++++-------- 4 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 src/extension-host/loader-import.test.ts create mode 100644 src/extension-host/loader-import.ts diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index c295457b6a9..88a8e67b251 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -34,6 +34,7 @@ This is an implementation checklist, not a future-design spec. | Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. | | Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, provenance indexing, and allowlist/untracked warnings now live in host-owned loader-policy helpers. | | Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. | +| Loader entry-path opening and module import | `src/plugins/loader.ts` | `src/extension-host/loader-import.ts` | `partial` | Boundary-checked entry opening and module import now delegate through host-owned loader-import helpers while preserving the current trusted in-process loading model. | | Loader module-export, config-validation, and memory-slot decisions | `src/plugins/loader.ts` | `src/extension-host/loader-runtime.ts` | `partial` | Module export resolution, export-metadata application, config validation, and early or final memory-slot decisions now delegate through host-owned loader-runtime helpers. | | Loader post-import planning and register execution | `src/plugins/loader.ts` | `src/extension-host/loader-register.ts` | `partial` | Definition application, post-import validation planning, and `register(...)` execution now delegate through host-owned loader-register helpers while preserving current plugin behavior. | | Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | Disabled, error, and appended plugin-record state transitions now delegate through host-owned loader-state helpers; a real lifecycle state machine still does not exist. | @@ -82,14 +83,14 @@ That pattern has been used for: - active registry ownership - normalized extension schema and resolved-extension records - static consumers such as skills, validation, auto-enable, and config baseline generation -- loader compatibility, initial candidate planning, policy, runtime decisions, post-import register flow, and record-state transitions +- loader compatibility, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, and record-state transitions ## Immediate Next Targets These are the next lowest-risk cutover steps: 1. Replace remaining static-only manifest-registry injections with resolved-extension registry inputs where practical. -2. Move the remaining loader orchestration into `src/extension-host/*`, especially entry-path opening and import flow, enablement completion, and lifecycle-state transitions. +2. Move the remaining loader orchestration into `src/extension-host/*`, especially cache wiring, final registry append flow, enablement completion, and lifecycle-state transitions. 3. Introduce explicit host-owned registration surfaces for runtime writes, starting with the least-coupled registries. 4. Move minimal SDK compatibility and loader normalization into `src/extension-host/*` without breaking current `openclaw/plugin-sdk/*` loading. 5. Start the first pilot on `extensions/thread-ownership` only after the host-side registry and lifecycle seams are explicit. diff --git a/src/extension-host/loader-import.test.ts b/src/extension-host/loader-import.test.ts new file mode 100644 index 00000000000..1035bcf3958 --- /dev/null +++ b/src/extension-host/loader-import.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { importExtensionHostPluginModule } from "./loader-import.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createTempPluginFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-import-")); + tempDirs.push(rootDir); + const entryPath = path.join(rootDir, "index.js"); + fs.writeFileSync(entryPath, "export default {}"); + return { rootDir, entryPath }; +} + +describe("extension host loader import", () => { + it("loads modules through a boundary-checked safe source path", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const resolvedEntryPath = fs.realpathSync(entryPath); + + const result = importExtensionHostPluginModule({ + rootDir, + source: entryPath, + origin: "workspace", + loadModule: (safeSource) => ({ safeSource }), + }); + + expect(result).toMatchObject({ + ok: true, + module: { + safeSource: resolvedEntryPath, + }, + safeSource: resolvedEntryPath, + }); + }); + + it("rejects entry paths outside the plugin root", () => { + const { rootDir } = createTempPluginFixture(); + const outsidePath = path.join(os.tmpdir(), `outside-${Date.now()}.js`); + fs.writeFileSync(outsidePath, "export default {}"); + + const result = importExtensionHostPluginModule({ + rootDir, + source: outsidePath, + origin: "workspace", + loadModule: () => { + throw new Error("should not run"); + }, + }); + + fs.rmSync(outsidePath, { force: true }); + + expect(result).toEqual({ + ok: false, + message: "plugin entry path escapes plugin root or fails alias checks", + }); + }); + + it("returns load failures without throwing", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const error = new Error("boom"); + + const result = importExtensionHostPluginModule({ + rootDir, + source: entryPath, + origin: "workspace", + loadModule: () => { + throw error; + }, + }); + + expect(result).toEqual({ + ok: false, + message: "failed to load plugin", + error, + }); + }); +}); diff --git a/src/extension-host/loader-import.ts b/src/extension-host/loader-import.ts new file mode 100644 index 00000000000..b4e6c44ef3a --- /dev/null +++ b/src/extension-host/loader-import.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import type { PluginRecord } from "../plugins/registry.js"; + +export function importExtensionHostPluginModule(params: { + rootDir: string; + source: string; + origin: PluginRecord["origin"]; + loadModule: (safeSource: string) => unknown; +}): + | { + ok: true; + module: unknown; + safeSource: string; + } + | { + ok: false; + message: string; + error?: unknown; + } { + const pluginRoot = safeRealpathOrResolve(params.rootDir); + const opened = openBoundaryFileSync({ + absolutePath: params.source, + rootPath: pluginRoot, + boundaryLabel: "plugin root", + rejectHardlinks: params.origin !== "bundled", + skipLexicalRootCheck: true, + }); + if (!opened.ok) { + return { + ok: false, + message: "plugin entry path escapes plugin root or fails alias checks", + }; + } + + const safeSource = opened.path; + fs.closeSync(opened.fd); + try { + return { + ok: true, + module: params.loadModule(safeSource), + safeSource, + }; + } catch (error) { + return { + ok: false, + message: "failed to load plugin", + error, + }; + } +} + +function safeRealpathOrResolve(value: string): string { + try { + return fs.realpathSync(value); + } catch { + return path.resolve(value); + } +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index f4b817f8d01..6eca4031912 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -1,5 +1,3 @@ -import fs from "node:fs"; -import path from "node:path"; import { createJiti } from "jiti"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; @@ -12,6 +10,7 @@ import { resolvePluginSdkAliasFile, resolvePluginSdkScopedAliasMap, } from "../extension-host/loader-compat.js"; +import { importExtensionHostPluginModule } from "../extension-host/loader-import.js"; import { buildExtensionHostProvenanceIndex, compareExtensionHostDuplicateCandidateOrder, @@ -35,7 +34,6 @@ import { setExtensionHostPluginRecordError, } from "../extension-host/loader-state.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; -import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; import { clearPluginCommands } from "./commands.js"; @@ -938,25 +936,17 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - const pluginRoot = safeRealpathOrResolve(candidate.rootDir); - const opened = openBoundaryFileSync({ - absolutePath: candidate.source, - rootPath: pluginRoot, - boundaryLabel: "plugin root", - rejectHardlinks: candidate.origin !== "bundled", - skipLexicalRootCheck: true, + const moduleImport = importExtensionHostPluginModule({ + rootDir: candidate.rootDir, + source: candidate.source, + origin: candidate.origin, + loadModule: (safeSource) => getJiti()(safeSource), }); - if (!opened.ok) { - pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks"); - continue; - } - const safeSource = opened.path; - fs.closeSync(opened.fd); - - let mod: OpenClawPluginModule | null = null; - try { - mod = getJiti()(safeSource) as OpenClawPluginModule; - } catch (err) { + if (!moduleImport.ok) { + if (moduleImport.message !== "failed to load plugin") { + pushPluginLoadError(moduleImport.message); + continue; + } recordExtensionHostPluginError({ logger, registry, @@ -964,14 +954,14 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi seenIds, pluginId, origin: candidate.origin, - error: err, + error: moduleImport.error, logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, diagnosticMessagePrefix: "failed to load plugin: ", }); continue; } - const resolved = resolveExtensionHostModuleExport(mod); + const resolved = resolveExtensionHostModuleExport(moduleImport.module as OpenClawPluginModule); const definition = resolved.definition; const register = resolved.register; @@ -1083,11 +1073,3 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi activateExtensionHostRegistry(registry, cacheKey); return registry; } - -function safeRealpathOrResolve(value: string): string { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } -}