Plugins: extract loader host seams
This commit is contained in:
parent
fb9a0383d1
commit
bcb74de2ff
43
src/extension-host/activation.test.ts
Normal file
43
src/extension-host/activation.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { getGlobalHookRunner, resetGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import { activateExtensionHostRegistry } from "./activation.js";
|
||||
import {
|
||||
getActiveExtensionHostRegistry,
|
||||
getActiveExtensionHostRegistryKey,
|
||||
} from "./active-registry.js";
|
||||
|
||||
describe("extension host activation", () => {
|
||||
beforeEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
});
|
||||
|
||||
it("activates the registry through the host boundary", () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.plugins.push({
|
||||
id: "activation-test",
|
||||
name: "activation-test",
|
||||
source: "test",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
status: "loaded",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: false,
|
||||
});
|
||||
|
||||
activateExtensionHostRegistry(registry, "activation-key");
|
||||
|
||||
expect(getActiveExtensionHostRegistry()).toBe(registry);
|
||||
expect(getActiveExtensionHostRegistryKey()).toBe("activation-key");
|
||||
expect(getGlobalHookRunner()).toBeDefined();
|
||||
});
|
||||
});
|
||||
8
src/extension-host/activation.ts
Normal file
8
src/extension-host/activation.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { initializeGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActiveExtensionHostRegistry } from "./active-registry.js";
|
||||
|
||||
export function activateExtensionHostRegistry(registry: PluginRegistry, cacheKey: string): void {
|
||||
setActiveExtensionHostRegistry(registry, cacheKey);
|
||||
initializeGlobalHookRunner(registry);
|
||||
}
|
||||
110
src/extension-host/cutover-inventory.md
Normal file
110
src/extension-host/cutover-inventory.md
Normal file
@ -0,0 +1,110 @@
|
||||
# Extension Host Cutover Inventory
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Purpose
|
||||
|
||||
This document is the Phase 0 cutover inventory for the extension-host migration.
|
||||
|
||||
It tracks:
|
||||
|
||||
- the current plugin-owned surfaces in the repo
|
||||
- where ownership lives today
|
||||
- where ownership should move
|
||||
- what has already moved
|
||||
- what is still blocked on later phases
|
||||
|
||||
This is an implementation checklist, not a future-design spec.
|
||||
|
||||
## Status Legend
|
||||
|
||||
- `moved`: the host owns the boundary now, with compatibility preserved
|
||||
- `partial`: host-owned types or views exist, but the legacy plugin path is still the active writer
|
||||
- `compat-only`: old surface still exists only to preserve callers while the host boundary takes over
|
||||
- `not started`: no meaningful migration has landed yet
|
||||
|
||||
## Current Inventory
|
||||
|
||||
| Surface | Current implementation | Target owner | Status | How it has been handled so far |
|
||||
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Active runtime registry state | `src/plugins/runtime.ts` plus global plugin runtime state | `src/extension-host/active-registry.ts` | `moved` | Host-owned active registry exists; `src/plugins/runtime.ts` is now a compatibility facade. |
|
||||
| Normalized extension descriptor model | plugin manifests and package metadata interpreted ad hoc across `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` | `partial` | `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` exist; current manifests project into them through compatibility adapters. |
|
||||
| Resolved static registry | flat rows in `src/plugins/manifest-registry.ts` | `src/extension-host/resolved-registry.ts` | `partial` | Manifest records now carry `resolvedExtension`; a host-owned resolved registry view exists for static consumers. |
|
||||
| Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` and `src/extension-host/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. |
|
||||
| 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 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 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. |
|
||||
| Channel lookup | `src/channels/plugins/index.ts`, `src/channels/plugins/registry-loader.ts`, `src/channels/registry.ts` | extension-host-backed registries plus kernel channel contracts | `partial` | Readers now consume the host-owned active registry, but writes still originate from plugin registration. |
|
||||
| Dock lookup | `src/channels/dock.ts` | host-owned static descriptors | `partial` | Runtime lookup now uses the host boundary; dock ownership itself has not moved yet. |
|
||||
| Message-channel normalization | `src/utils/message-channel.ts` | host-owned channel registry view | `partial` | Lookup path now reads through the host-owned active registry. |
|
||||
| Default plugin HTTP route lookup | `src/plugins/http-registry.ts` | host-owned route registry | `partial` | Default registry resolution now uses the host boundary; route registration compatibility still flows through the legacy plugin API. |
|
||||
| Channel catalog static metadata | `src/channels/plugins/catalog.ts` | host-owned static descriptors | `partial` | Package metadata parsing now flows through host schema helpers; full canonical catalog migration has not started. |
|
||||
| Plugin skill discovery | `src/agents/skills/plugin-skills.ts` | host-owned resolved registry | `moved` | Static consumer now reads only resolved-extension data for skill paths and enablement filtering. |
|
||||
| Plugin auto-enable | `src/config/plugin-auto-enable.ts` | host-owned resolved registry | `partial` | Primary logic runs on resolved-extension data; old manifest-registry injection remains as a compatibility input for older callers and tests. |
|
||||
| Config validation indexing | `src/config/validation.ts`, `src/config/resolved-extension-validation.ts` | host-owned resolved registry | `moved` | Validation indexing now builds from resolved-extension records instead of flat manifest rows. |
|
||||
| Config doc baseline generation | `src/config/doc-baseline.ts` | host-owned resolved registry | `moved` | Bundled plugin and channel metadata now load through the resolved-extension registry. |
|
||||
| Plugin loader activation | `src/plugins/loader.ts` | extension host lifecycle + compatibility loader | `partial` | Activation now routes through `src/extension-host/activation.ts`, but discovery, enablement, provenance, module loading, and policy still live in the legacy plugin loader. |
|
||||
| Channel registration writes | `src/plugins/registry.ts` | host-owned channel registry | `partial` | Validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
|
||||
| Provider registration writes | `src/plugins/registry.ts` | host-owned provider registry | `partial` | Provider normalization still happens in plugin-era validation, but duplicate detection and normalized registration shape now delegate to `src/extension-host/runtime-registrations.ts`. |
|
||||
| HTTP route registration writes | `src/plugins/registry.ts` | host-owned route registry | `partial` | Route validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
|
||||
| Gateway method registration writes | `src/plugins/registry.ts` | host-owned runtime contribution registry | `partial` | Duplicate detection and normalized method registration now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
|
||||
| Tool registration writes | `src/plugins/registry.ts` | host-owned tool registry | `partial` | Tool-name normalization and tool-factory shaping now delegate to `src/extension-host/runtime-registrations.ts`, but duplicate handling still follows the legacy tool path. |
|
||||
| CLI registration writes | `src/plugins/registry.ts` | host-owned CLI registry | `partial` | CLI command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
|
||||
| Service registration writes | `src/plugins/registry.ts` | host-owned service registry | `partial` | Service-id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but lifecycle remains legacy-owned. |
|
||||
| Command registration writes | `src/plugins/registry.ts` | host-owned command registry | `partial` | Command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but duplicate enforcement still depends on the legacy plugin command registry. |
|
||||
| Context-engine registration writes | `src/plugins/registry.ts` | host-owned context-engine registry | `partial` | Context-engine id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the actual context-engine registry remains legacy-owned. |
|
||||
| Legacy hook registration writes | `src/plugins/registry.ts` | host-owned hook registry | `partial` | Hook-entry construction and event normalization now delegate to `src/extension-host/runtime-registrations.ts`, but internal-hook bridging still remains in the legacy plugin registry. |
|
||||
| Typed-hook registration writes | `src/plugins/registry.ts` | host-owned typed-hook registry | `partial` | Typed-hook record construction and hook-name validation now delegate to `src/extension-host/runtime-registrations.ts`, but prompt-injection policy and execution semantics remain legacy-owned. |
|
||||
| Hook execution and global runner | `src/plugins/hook-runner-global.ts`, `src/hooks/internal-hooks.ts`, plugin hook registration in `src/plugins/registry.ts` | canonical kernel event stages + host bridges | `not started` | No canonical event-stage migration has landed yet. |
|
||||
| Service lifecycle | `src/plugins/services.ts` and plugin service registration | extension host lifecycle | `not started` | Service startup and teardown still depend on legacy plugin registry/service ownership. |
|
||||
| CLI registration | plugin CLI registration in `src/plugins/registry.ts` and CLI loaders | extension host registry + static descriptors where possible | `not started` | No host-owned CLI registry exists yet. |
|
||||
| Gateway/server methods | `src/plugins/registry.ts` gateway handler registration | host-owned runtime contribution registry | `not started` | Still registered directly into the legacy plugin registry. |
|
||||
| Slot arbitration | `src/plugins/slots.ts` | host-owned arbitration model | `not started` | Current slot selection remains plugin-era logic. |
|
||||
| ACP backend registry | `src/acp/runtime/registry.ts` | host-owned runtime-backend registry | `not started` | ACP backends still mutate a global ACP runtime registry directly. |
|
||||
| Onboarding/install/setup surfaces | `src/plugins/install.ts`, package manifests, channel catalog, onboarding commands | host-owned static descriptors | `partial` | Static metadata normalization has started; full setup/install descriptor migration is not done. |
|
||||
| Pilot migrations | `extensions/thread-ownership`, `extensions/telegram`, `extensions/acpx` | extension-host path with parity tracking | `not started` | No pilot runs through the host path yet. |
|
||||
|
||||
## Completed Pattern So Far
|
||||
|
||||
The migration pattern used so far is intentional:
|
||||
|
||||
1. Extract a host-owned boundary module.
|
||||
2. Keep the old plugin-era entry point as a compatibility facade.
|
||||
3. Move static or lookup-heavy readers first.
|
||||
4. Add focused seam tests where the dependency graph allows it.
|
||||
5. Delay loader/lifecycle/event rewrites until more readers already depend on one host-owned boundary.
|
||||
|
||||
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, policy, runtime decisions, 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 per-plugin load flow, enablement, 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.
|
||||
|
||||
## Explicitly Not Done Yet
|
||||
|
||||
This inventory should not be read as proof that the extension host is fully in charge already.
|
||||
|
||||
The following remain legacy-owned today:
|
||||
|
||||
- activation ordering
|
||||
- policy gates
|
||||
- typed and legacy hook execution
|
||||
- service lifecycle
|
||||
- CLI registration
|
||||
- gateway/server method registration
|
||||
- slot arbitration
|
||||
- ACP backend registration
|
||||
- channel runtime compatibility bridges
|
||||
- pilot parity tracking
|
||||
114
src/extension-host/loader-compat.ts
Normal file
114
src/extension-host/loader-compat.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
|
||||
type PluginSdkAliasCandidateKind = "dist" | "src";
|
||||
|
||||
const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
|
||||
|
||||
export function resolvePluginSdkAliasCandidateOrder(params: {
|
||||
modulePath: string;
|
||||
isProduction: boolean;
|
||||
}): PluginSdkAliasCandidateKind[] {
|
||||
const normalizedModulePath = params.modulePath.replace(/\\/g, "/");
|
||||
const isDistRuntime = normalizedModulePath.includes("/dist/");
|
||||
return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"];
|
||||
}
|
||||
|
||||
export function listPluginSdkAliasCandidates(params: {
|
||||
srcFile: string;
|
||||
distFile: string;
|
||||
modulePath: string;
|
||||
}): string[] {
|
||||
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
|
||||
modulePath: params.modulePath,
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
});
|
||||
let cursor = path.dirname(params.modulePath);
|
||||
const candidates: string[] = [];
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const candidateMap = {
|
||||
src: path.join(cursor, "src", "plugin-sdk", params.srcFile),
|
||||
dist: path.join(cursor, "dist", "plugin-sdk", params.distFile),
|
||||
} as const;
|
||||
for (const kind of orderedKinds) {
|
||||
candidates.push(candidateMap[kind]);
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolvePluginSdkAliasFile(params: {
|
||||
srcFile: string;
|
||||
distFile: string;
|
||||
modulePath?: string;
|
||||
}): string | null {
|
||||
try {
|
||||
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
|
||||
for (const candidate of listPluginSdkAliasCandidates({
|
||||
srcFile: params.srcFile,
|
||||
distFile: params.distFile,
|
||||
modulePath,
|
||||
})) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePluginSdkAlias(): string | null {
|
||||
return resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" });
|
||||
}
|
||||
|
||||
export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] {
|
||||
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
|
||||
const packageRoot = resolveOpenClawPackageRootSync({
|
||||
cwd: path.dirname(modulePath),
|
||||
});
|
||||
if (!packageRoot) {
|
||||
return [];
|
||||
}
|
||||
const cached = cachedPluginSdkExportedSubpaths.get(packageRoot);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
|
||||
const pkg = JSON.parse(pkgRaw) as {
|
||||
exports?: Record<string, unknown>;
|
||||
};
|
||||
const subpaths = Object.keys(pkg.exports ?? {})
|
||||
.filter((key) => key.startsWith("./plugin-sdk/"))
|
||||
.map((key) => key.slice("./plugin-sdk/".length))
|
||||
.filter((subpath) => Boolean(subpath) && !subpath.includes("/"))
|
||||
.toSorted();
|
||||
cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths);
|
||||
return subpaths;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePluginSdkScopedAliasMap(): Record<string, string> {
|
||||
const aliasMap: Record<string, string> = {};
|
||||
for (const subpath of listPluginSdkExportedSubpaths()) {
|
||||
const resolved = resolvePluginSdkAliasFile({
|
||||
srcFile: `${subpath}.ts`,
|
||||
distFile: `${subpath}.js`,
|
||||
});
|
||||
if (resolved) {
|
||||
aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved;
|
||||
}
|
||||
}
|
||||
return aliasMap;
|
||||
}
|
||||
177
src/extension-host/loader-policy.test.ts
Normal file
177
src/extension-host/loader-policy.test.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginCandidate } from "../plugins/discovery.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import {
|
||||
buildExtensionHostProvenanceIndex,
|
||||
compareExtensionHostDuplicateCandidateOrder,
|
||||
createExtensionHostPluginRecord,
|
||||
warnAboutUntrackedLoadedExtensions,
|
||||
warnWhenExtensionAllowlistIsOpen,
|
||||
} from "./loader-policy.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-policy-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("extension host loader policy", () => {
|
||||
it("creates normalized plugin records", () => {
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: "demo-plugin",
|
||||
source: "/plugins/demo/index.js",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: true,
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
id: "demo-plugin",
|
||||
name: "demo-plugin",
|
||||
source: "/plugins/demo/index.js",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
status: "loaded",
|
||||
configSchema: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit global installs over auto-discovered globals", () => {
|
||||
const installDir = makeTempDir();
|
||||
const autoDir = makeTempDir();
|
||||
const env = { ...process.env, HOME: makeTempDir() };
|
||||
const provenance = buildExtensionHostProvenanceIndex({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
demo: {
|
||||
installPath: installDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
normalizedLoadPaths: [],
|
||||
env,
|
||||
});
|
||||
|
||||
const manifestByRoot = new Map<string, { id: string }>([
|
||||
[installDir, { id: "demo" }],
|
||||
[autoDir, { id: "demo" }],
|
||||
]);
|
||||
const explicitCandidate: PluginCandidate = {
|
||||
idHint: "demo",
|
||||
source: path.join(installDir, "index.js"),
|
||||
rootDir: installDir,
|
||||
origin: "global",
|
||||
};
|
||||
const autoCandidate: PluginCandidate = {
|
||||
idHint: "demo",
|
||||
source: path.join(autoDir, "index.js"),
|
||||
rootDir: autoDir,
|
||||
origin: "global",
|
||||
};
|
||||
|
||||
expect(
|
||||
compareExtensionHostDuplicateCandidateOrder({
|
||||
left: explicitCandidate,
|
||||
right: autoCandidate,
|
||||
manifestByRoot,
|
||||
provenance,
|
||||
env,
|
||||
}),
|
||||
).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("warns when allowlist is open for non-bundled discoverable plugins", () => {
|
||||
const warnings: string[] = [];
|
||||
const warningCache = new Set<string>();
|
||||
|
||||
warnWhenExtensionAllowlistIsOpen({
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: (message) => warnings.push(message),
|
||||
error: () => {},
|
||||
},
|
||||
pluginsEnabled: true,
|
||||
allow: [],
|
||||
warningCacheKey: "warn-key",
|
||||
warningCache,
|
||||
discoverablePlugins: [
|
||||
{ id: "bundled", source: "/bundled/index.js", origin: "bundled" },
|
||||
{ id: "workspace-demo", source: "/workspace/demo.js", origin: "workspace" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain("plugins.allow is empty");
|
||||
expect(warningCache.has("warn-key")).toBe(true);
|
||||
});
|
||||
|
||||
it("warns about loaded untracked non-bundled plugins", () => {
|
||||
const trackedDir = makeTempDir();
|
||||
const untrackedDir = makeTempDir();
|
||||
const trackedFile = path.join(trackedDir, "tracked.js");
|
||||
const untrackedFile = path.join(untrackedDir, "untracked.js");
|
||||
fs.writeFileSync(trackedFile, "export {};\n", "utf8");
|
||||
fs.writeFileSync(untrackedFile, "export {};\n", "utf8");
|
||||
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.plugins.push(
|
||||
{
|
||||
...createExtensionHostPluginRecord({
|
||||
id: "tracked",
|
||||
source: trackedFile,
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: false,
|
||||
}),
|
||||
status: "loaded",
|
||||
},
|
||||
{
|
||||
...createExtensionHostPluginRecord({
|
||||
id: "untracked",
|
||||
source: untrackedFile,
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: false,
|
||||
}),
|
||||
status: "loaded",
|
||||
},
|
||||
);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const env = { ...process.env, HOME: makeTempDir() };
|
||||
const provenance = buildExtensionHostProvenanceIndex({
|
||||
config: {},
|
||||
normalizedLoadPaths: [trackedDir],
|
||||
env,
|
||||
});
|
||||
|
||||
warnAboutUntrackedLoadedExtensions({
|
||||
registry,
|
||||
provenance,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: (message) => warnings.push(message),
|
||||
error: () => {},
|
||||
},
|
||||
env,
|
||||
});
|
||||
|
||||
expect(registry.diagnostics).toHaveLength(1);
|
||||
expect(registry.diagnostics[0]?.pluginId).toBe("untracked");
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain("untracked");
|
||||
});
|
||||
});
|
||||
324
src/extension-host/loader-policy.ts
Normal file
324
src/extension-host/loader-policy.ts
Normal file
@ -0,0 +1,324 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginCandidate } from "../plugins/discovery.js";
|
||||
import { isPathInside, safeStatSync } from "../plugins/path-safety.js";
|
||||
import type { PluginRecord, PluginRegistry } from "../plugins/registry.js";
|
||||
import type { PluginDiagnostic, PluginLogger } from "../plugins/types.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
function safeRealpathOrResolve(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
type PathMatcher = {
|
||||
exact: Set<string>;
|
||||
dirs: string[];
|
||||
};
|
||||
|
||||
type InstallTrackingRule = {
|
||||
trackedWithoutPaths: boolean;
|
||||
matcher: PathMatcher;
|
||||
};
|
||||
|
||||
export type ExtensionHostProvenanceIndex = {
|
||||
loadPathMatcher: PathMatcher;
|
||||
installRules: Map<string, InstallTrackingRule>;
|
||||
};
|
||||
|
||||
export function createExtensionHostPluginRecord(params: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
source: string;
|
||||
origin: PluginRecord["origin"];
|
||||
workspaceDir?: string;
|
||||
enabled: boolean;
|
||||
configSchema: boolean;
|
||||
}): PluginRecord {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name ?? params.id,
|
||||
description: params.description,
|
||||
version: params.version,
|
||||
source: params.source,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
enabled: params.enabled,
|
||||
status: params.enabled ? "loaded" : "disabled",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: params.configSchema,
|
||||
configUiHints: undefined,
|
||||
configJsonSchema: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordExtensionHostPluginError(params: {
|
||||
logger: PluginLogger;
|
||||
registry: PluginRegistry;
|
||||
record: PluginRecord;
|
||||
seenIds: Map<string, PluginRecord["origin"]>;
|
||||
pluginId: string;
|
||||
origin: PluginRecord["origin"];
|
||||
error: unknown;
|
||||
logPrefix: string;
|
||||
diagnosticMessagePrefix: string;
|
||||
}): void {
|
||||
const errorText = String(params.error);
|
||||
const deprecatedApiHint =
|
||||
errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function")
|
||||
? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes"
|
||||
: null;
|
||||
const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText;
|
||||
params.logger.error(`${params.logPrefix}${displayError}`);
|
||||
params.record.status = "error";
|
||||
params.record.error = displayError;
|
||||
params.registry.plugins.push(params.record);
|
||||
params.seenIds.set(params.pluginId, params.origin);
|
||||
params.registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: params.record.id,
|
||||
source: params.record.source,
|
||||
message: `${params.diagnosticMessagePrefix}${displayError}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function pushExtensionHostDiagnostics(
|
||||
diagnostics: PluginDiagnostic[],
|
||||
append: PluginDiagnostic[],
|
||||
): void {
|
||||
diagnostics.push(...append);
|
||||
}
|
||||
|
||||
function createPathMatcher(): PathMatcher {
|
||||
return { exact: new Set<string>(), dirs: [] };
|
||||
}
|
||||
|
||||
function addPathToMatcher(
|
||||
matcher: PathMatcher,
|
||||
rawPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): void {
|
||||
const trimmed = rawPath.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveUserPath(trimmed, env);
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) {
|
||||
return;
|
||||
}
|
||||
const stat = safeStatSync(resolved);
|
||||
if (stat?.isDirectory()) {
|
||||
matcher.dirs.push(resolved);
|
||||
return;
|
||||
}
|
||||
matcher.exact.add(resolved);
|
||||
}
|
||||
|
||||
function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean {
|
||||
if (matcher.exact.has(sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath));
|
||||
}
|
||||
|
||||
export function buildExtensionHostProvenanceIndex(params: {
|
||||
config: OpenClawConfig;
|
||||
normalizedLoadPaths: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): ExtensionHostProvenanceIndex {
|
||||
const loadPathMatcher = createPathMatcher();
|
||||
for (const loadPath of params.normalizedLoadPaths) {
|
||||
addPathToMatcher(loadPathMatcher, loadPath, params.env);
|
||||
}
|
||||
|
||||
const installRules = new Map<string, InstallTrackingRule>();
|
||||
const installs = params.config.plugins?.installs ?? {};
|
||||
for (const [pluginId, install] of Object.entries(installs)) {
|
||||
const rule: InstallTrackingRule = {
|
||||
trackedWithoutPaths: false,
|
||||
matcher: createPathMatcher(),
|
||||
};
|
||||
const trackedPaths = [install.installPath, install.sourcePath]
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter(Boolean);
|
||||
if (trackedPaths.length === 0) {
|
||||
rule.trackedWithoutPaths = true;
|
||||
} else {
|
||||
for (const trackedPath of trackedPaths) {
|
||||
addPathToMatcher(rule.matcher, trackedPath, params.env);
|
||||
}
|
||||
}
|
||||
installRules.set(pluginId, rule);
|
||||
}
|
||||
|
||||
return { loadPathMatcher, installRules };
|
||||
}
|
||||
|
||||
function isTrackedByProvenance(params: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
index: ExtensionHostProvenanceIndex;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const sourcePath = resolveUserPath(params.source, params.env);
|
||||
const installRule = params.index.installRules.get(params.pluginId);
|
||||
if (installRule) {
|
||||
if (installRule.trackedWithoutPaths) {
|
||||
return true;
|
||||
}
|
||||
if (matchesPathMatcher(installRule.matcher, sourcePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return matchesPathMatcher(params.index.loadPathMatcher, sourcePath);
|
||||
}
|
||||
|
||||
function matchesExplicitInstallRule(params: {
|
||||
pluginId: string;
|
||||
source: string;
|
||||
index: ExtensionHostProvenanceIndex;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const sourcePath = resolveUserPath(params.source, params.env);
|
||||
const installRule = params.index.installRules.get(params.pluginId);
|
||||
if (!installRule || installRule.trackedWithoutPaths) {
|
||||
return false;
|
||||
}
|
||||
return matchesPathMatcher(installRule.matcher, sourcePath);
|
||||
}
|
||||
|
||||
function resolveCandidateDuplicateRank(params: {
|
||||
candidate: PluginCandidate;
|
||||
manifestByRoot: Map<string, { id: string }>;
|
||||
provenance: ExtensionHostProvenanceIndex;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): number {
|
||||
const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir);
|
||||
const pluginId = manifestRecord?.id;
|
||||
const isExplicitInstall =
|
||||
params.candidate.origin === "global" &&
|
||||
pluginId !== undefined &&
|
||||
matchesExplicitInstallRule({
|
||||
pluginId,
|
||||
source: params.candidate.source,
|
||||
index: params.provenance,
|
||||
env: params.env,
|
||||
});
|
||||
|
||||
switch (params.candidate.origin) {
|
||||
case "config":
|
||||
return 0;
|
||||
case "workspace":
|
||||
return 1;
|
||||
case "global":
|
||||
return isExplicitInstall ? 2 : 4;
|
||||
case "bundled":
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
export function compareExtensionHostDuplicateCandidateOrder(params: {
|
||||
left: PluginCandidate;
|
||||
right: PluginCandidate;
|
||||
manifestByRoot: Map<string, { id: string }>;
|
||||
provenance: ExtensionHostProvenanceIndex;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): number {
|
||||
const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id;
|
||||
const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id;
|
||||
if (!leftPluginId || leftPluginId !== rightPluginId) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
resolveCandidateDuplicateRank({
|
||||
candidate: params.left,
|
||||
manifestByRoot: params.manifestByRoot,
|
||||
provenance: params.provenance,
|
||||
env: params.env,
|
||||
}) -
|
||||
resolveCandidateDuplicateRank({
|
||||
candidate: params.right,
|
||||
manifestByRoot: params.manifestByRoot,
|
||||
provenance: params.provenance,
|
||||
env: params.env,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function warnWhenExtensionAllowlistIsOpen(params: {
|
||||
logger: PluginLogger;
|
||||
pluginsEnabled: boolean;
|
||||
allow: string[];
|
||||
warningCacheKey: string;
|
||||
warningCache: Set<string>;
|
||||
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
|
||||
}): void {
|
||||
if (!params.pluginsEnabled || params.allow.length > 0) {
|
||||
return;
|
||||
}
|
||||
const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled");
|
||||
if (nonBundled.length === 0 || params.warningCache.has(params.warningCacheKey)) {
|
||||
return;
|
||||
}
|
||||
const preview = nonBundled
|
||||
.slice(0, 6)
|
||||
.map((entry) => `${entry.id} (${entry.source})`)
|
||||
.join(", ");
|
||||
const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : "";
|
||||
params.warningCache.add(params.warningCacheKey);
|
||||
params.logger.warn(
|
||||
`[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function warnAboutUntrackedLoadedExtensions(params: {
|
||||
registry: PluginRegistry;
|
||||
provenance: ExtensionHostProvenanceIndex;
|
||||
logger: PluginLogger;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
for (const plugin of params.registry.plugins) {
|
||||
if (plugin.status !== "loaded" || plugin.origin === "bundled") {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isTrackedByProvenance({
|
||||
pluginId: plugin.id,
|
||||
source: plugin.source,
|
||||
index: params.provenance,
|
||||
env: params.env,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const message =
|
||||
"loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records";
|
||||
params.registry.diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: plugin.id,
|
||||
source: plugin.source,
|
||||
message,
|
||||
});
|
||||
params.logger.warn(
|
||||
`[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolve(plugin.source)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
128
src/extension-host/loader-runtime.test.ts
Normal file
128
src/extension-host/loader-runtime.test.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createExtensionHostPluginRecord } from "./loader-policy.js";
|
||||
import {
|
||||
applyExtensionHostDefinitionToRecord,
|
||||
resolveExtensionHostEarlyMemoryDecision,
|
||||
resolveExtensionHostMemoryDecision,
|
||||
resolveExtensionHostModuleExport,
|
||||
validateExtensionHostConfig,
|
||||
} from "./loader-runtime.js";
|
||||
|
||||
describe("extension host loader runtime", () => {
|
||||
it("resolves function exports as register handlers", () => {
|
||||
const register = () => {};
|
||||
expect(resolveExtensionHostModuleExport(register)).toEqual({
|
||||
register,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves object exports with default values", () => {
|
||||
const register = () => {};
|
||||
const definition = {
|
||||
id: "demo",
|
||||
register,
|
||||
};
|
||||
expect(resolveExtensionHostModuleExport({ default: definition })).toEqual({
|
||||
definition,
|
||||
register,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies export metadata to plugin records", () => {
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: "demo",
|
||||
source: "/plugins/demo.js",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: true,
|
||||
});
|
||||
record.kind = "memory";
|
||||
const diagnostics: Array<{ level: "warn" | "error"; message: string }> = [];
|
||||
|
||||
const result = applyExtensionHostDefinitionToRecord({
|
||||
record,
|
||||
definition: {
|
||||
id: "demo",
|
||||
name: "Demo Plugin",
|
||||
description: "demo desc",
|
||||
version: "1.2.3",
|
||||
kind: "memory",
|
||||
},
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(record.name).toBe("Demo Plugin");
|
||||
expect(record.description).toBe("demo desc");
|
||||
expect(record.version).toBe("1.2.3");
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects export id mismatches", () => {
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: "demo",
|
||||
source: "/plugins/demo.js",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
applyExtensionHostDefinitionToRecord({
|
||||
record,
|
||||
definition: {
|
||||
id: "other",
|
||||
},
|
||||
diagnostics: [],
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
message: 'plugin id mismatch (config uses "demo", export uses "other")',
|
||||
});
|
||||
});
|
||||
|
||||
it("validates config through the host helper", () => {
|
||||
expect(
|
||||
validateExtensionHostConfig({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
value: { enabled: true },
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable bundled memory plugins early based on slot policy", () => {
|
||||
const result = resolveExtensionHostEarlyMemoryDecision({
|
||||
origin: "bundled",
|
||||
manifestKind: "memory",
|
||||
recordId: "memory-b",
|
||||
memorySlot: "memory-a",
|
||||
selectedMemoryPluginId: null,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toContain('memory slot set to "memory-a"');
|
||||
});
|
||||
|
||||
it("returns the post-definition memory slot decision", () => {
|
||||
const result = resolveExtensionHostMemoryDecision({
|
||||
recordId: "memory-a",
|
||||
recordKind: "memory",
|
||||
memorySlot: "memory-a",
|
||||
selectedMemoryPluginId: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
selected: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
125
src/extension-host/loader-runtime.ts
Normal file
125
src/extension-host/loader-runtime.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { resolveMemorySlotDecision } from "../plugins/config-state.js";
|
||||
import type { PluginRecord } from "../plugins/registry.js";
|
||||
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
||||
import type { OpenClawPluginDefinition, PluginDiagnostic } from "../plugins/types.js";
|
||||
|
||||
export function validateExtensionHostConfig(params: {
|
||||
schema?: Record<string, unknown>;
|
||||
cacheKey?: string;
|
||||
value?: unknown;
|
||||
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
|
||||
const schema = params.schema;
|
||||
if (!schema) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
||||
}
|
||||
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
|
||||
const result = validateJsonSchemaValue({
|
||||
schema,
|
||||
cacheKey,
|
||||
value: params.value ?? {},
|
||||
});
|
||||
if (result.ok) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
||||
}
|
||||
return { ok: false, errors: result.errors.map((error) => error.text) };
|
||||
}
|
||||
|
||||
export function resolveExtensionHostModuleExport(moduleExport: unknown): {
|
||||
definition?: OpenClawPluginDefinition;
|
||||
register?: OpenClawPluginDefinition["register"];
|
||||
} {
|
||||
const resolved =
|
||||
moduleExport &&
|
||||
typeof moduleExport === "object" &&
|
||||
"default" in (moduleExport as Record<string, unknown>)
|
||||
? (moduleExport as { default: unknown }).default
|
||||
: moduleExport;
|
||||
if (typeof resolved === "function") {
|
||||
return {
|
||||
register: resolved as OpenClawPluginDefinition["register"],
|
||||
};
|
||||
}
|
||||
if (resolved && typeof resolved === "object") {
|
||||
const def = resolved as OpenClawPluginDefinition;
|
||||
const register = def.register ?? def.activate;
|
||||
return { definition: def, register };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function applyExtensionHostDefinitionToRecord(params: {
|
||||
record: PluginRecord;
|
||||
definition?: OpenClawPluginDefinition;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
}):
|
||||
| {
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
message: string;
|
||||
} {
|
||||
if (params.definition?.id && params.definition.id !== params.record.id) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `plugin id mismatch (config uses "${params.record.id}", export uses "${params.definition.id}")`,
|
||||
};
|
||||
}
|
||||
|
||||
params.record.name = params.definition?.name ?? params.record.name;
|
||||
params.record.description = params.definition?.description ?? params.record.description;
|
||||
params.record.version = params.definition?.version ?? params.record.version;
|
||||
const manifestKind = params.record.kind as string | undefined;
|
||||
const exportKind = params.definition?.kind as string | undefined;
|
||||
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: params.record.id,
|
||||
source: params.record.source,
|
||||
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
|
||||
});
|
||||
}
|
||||
params.record.kind = params.definition?.kind ?? params.record.kind;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function resolveExtensionHostEarlyMemoryDecision(params: {
|
||||
origin: PluginRecord["origin"];
|
||||
manifestKind?: PluginRecord["kind"];
|
||||
recordId: string;
|
||||
memorySlot?: string;
|
||||
selectedMemoryPluginId: string | null;
|
||||
}): { enabled: boolean; reason?: string } {
|
||||
if (params.origin !== "bundled" || params.manifestKind !== "memory") {
|
||||
return { enabled: true };
|
||||
}
|
||||
const decision = resolveMemorySlotDecision({
|
||||
id: params.recordId,
|
||||
kind: "memory",
|
||||
slot: params.memorySlot,
|
||||
selectedId: params.selectedMemoryPluginId,
|
||||
});
|
||||
return {
|
||||
enabled: decision.enabled,
|
||||
...(decision.enabled ? {} : { reason: decision.reason }),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExtensionHostMemoryDecision(params: {
|
||||
recordId: string;
|
||||
recordKind?: PluginRecord["kind"];
|
||||
memorySlot?: string;
|
||||
selectedMemoryPluginId: string | null;
|
||||
}): { enabled: boolean; selected: boolean; reason?: string } {
|
||||
const decision = resolveMemorySlotDecision({
|
||||
id: params.recordId,
|
||||
kind: params.recordKind,
|
||||
slot: params.memorySlot,
|
||||
selectedId: params.selectedMemoryPluginId,
|
||||
});
|
||||
return {
|
||||
enabled: decision.enabled,
|
||||
selected: decision.selected,
|
||||
...(decision.enabled ? {} : { reason: decision.reason }),
|
||||
};
|
||||
}
|
||||
81
src/extension-host/loader-state.test.ts
Normal file
81
src/extension-host/loader-state.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { createExtensionHostPluginRecord } from "./loader-policy.js";
|
||||
import {
|
||||
appendExtensionHostPluginRecord,
|
||||
setExtensionHostPluginRecordDisabled,
|
||||
setExtensionHostPluginRecordError,
|
||||
} from "./loader-state.js";
|
||||
|
||||
function createRegistry(): PluginRegistry {
|
||||
return {
|
||||
plugins: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
typedHooks: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("extension host loader state", () => {
|
||||
it("marks plugin records disabled", () => {
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: "demo",
|
||||
source: "/plugins/demo.js",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: true,
|
||||
});
|
||||
|
||||
expect(setExtensionHostPluginRecordDisabled(record, "disabled by policy")).toMatchObject({
|
||||
enabled: false,
|
||||
status: "disabled",
|
||||
error: "disabled by policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks plugin records as errors", () => {
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: "demo",
|
||||
source: "/plugins/demo.js",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: true,
|
||||
});
|
||||
|
||||
expect(setExtensionHostPluginRecordError(record, "failed to load")).toMatchObject({
|
||||
status: "error",
|
||||
error: "failed to load",
|
||||
});
|
||||
});
|
||||
|
||||
it("appends records and optionally updates seen ids", () => {
|
||||
const registry = createRegistry();
|
||||
const seenIds = new Map<string, "workspace" | "global" | "bundled" | "config">();
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: "demo",
|
||||
source: "/plugins/demo.js",
|
||||
origin: "workspace",
|
||||
enabled: true,
|
||||
configSchema: true,
|
||||
});
|
||||
|
||||
appendExtensionHostPluginRecord({
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId: "demo",
|
||||
origin: "workspace",
|
||||
});
|
||||
|
||||
expect(registry.plugins).toEqual([record]);
|
||||
expect(seenIds.get("demo")).toBe("workspace");
|
||||
});
|
||||
});
|
||||
33
src/extension-host/loader-state.ts
Normal file
33
src/extension-host/loader-state.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { PluginRecord, PluginRegistry } from "../plugins/registry.js";
|
||||
|
||||
export function setExtensionHostPluginRecordDisabled(
|
||||
record: PluginRecord,
|
||||
reason?: string,
|
||||
): PluginRecord {
|
||||
record.enabled = false;
|
||||
record.status = "disabled";
|
||||
record.error = reason;
|
||||
return record;
|
||||
}
|
||||
|
||||
export function setExtensionHostPluginRecordError(
|
||||
record: PluginRecord,
|
||||
message: string,
|
||||
): PluginRecord {
|
||||
record.status = "error";
|
||||
record.error = message;
|
||||
return record;
|
||||
}
|
||||
|
||||
export function appendExtensionHostPluginRecord(params: {
|
||||
registry: PluginRegistry;
|
||||
record: PluginRecord;
|
||||
seenIds?: Map<string, PluginRecord["origin"]>;
|
||||
pluginId?: string;
|
||||
origin?: PluginRecord["origin"];
|
||||
}): void {
|
||||
params.registry.plugins.push(params.record);
|
||||
if (params.seenIds && params.pluginId && params.origin) {
|
||||
params.seenIds.set(params.pluginId, params.origin);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createJiti } from "jiti";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { activateExtensionHostRegistry } from "../extension-host/activation.js";
|
||||
import {
|
||||
listPluginSdkAliasCandidates,
|
||||
listPluginSdkExportedSubpaths,
|
||||
resolvePluginSdkAlias,
|
||||
resolvePluginSdkAliasCandidateOrder,
|
||||
resolvePluginSdkAliasFile,
|
||||
resolvePluginSdkScopedAliasMap,
|
||||
} from "../extension-host/loader-compat.js";
|
||||
import {
|
||||
buildExtensionHostProvenanceIndex,
|
||||
compareExtensionHostDuplicateCandidateOrder,
|
||||
createExtensionHostPluginRecord,
|
||||
pushExtensionHostDiagnostics,
|
||||
recordExtensionHostPluginError,
|
||||
warnAboutUntrackedLoadedExtensions,
|
||||
warnWhenExtensionAllowlistIsOpen,
|
||||
} from "../extension-host/loader-policy.js";
|
||||
import {
|
||||
applyExtensionHostDefinitionToRecord,
|
||||
resolveExtensionHostEarlyMemoryDecision,
|
||||
resolveExtensionHostMemoryDecision,
|
||||
resolveExtensionHostModuleExport,
|
||||
validateExtensionHostConfig,
|
||||
} from "../extension-host/loader-runtime.js";
|
||||
import {
|
||||
appendExtensionHostPluginRecord,
|
||||
setExtensionHostPluginRecordDisabled,
|
||||
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 { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { clearPluginCommands } from "./commands.js";
|
||||
@ -14,17 +42,14 @@ import {
|
||||
applyTestPluginDefaults,
|
||||
normalizePluginsConfig,
|
||||
resolveEffectiveEnableState,
|
||||
resolveMemorySlotDecision,
|
||||
type NormalizedPluginsConfig,
|
||||
} from "./config-state.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
||||
import { clearPluginInteractiveHandlers } from "./interactive.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import { isPathInside, safeStatSync } from "./path-safety.js";
|
||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||
import { resolvePluginCacheInputs } from "./roots.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import { validateJsonSchemaValue } from "./schema-validator.js";
|
||||
@ -657,7 +682,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
if (cacheEnabled) {
|
||||
const cached = getCachedPluginRegistry(cacheKey);
|
||||
if (cached) {
|
||||
activatePluginRegistry(cached, cacheKey);
|
||||
activateExtensionHostRegistry(cached, cacheKey);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
@ -719,19 +744,20 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
});
|
||||
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
||||
warnWhenAllowlistIsOpen({
|
||||
pushExtensionHostDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
||||
warnWhenExtensionAllowlistIsOpen({
|
||||
logger,
|
||||
pluginsEnabled: normalized.enabled,
|
||||
allow: normalized.allow,
|
||||
warningCacheKey: cacheKey,
|
||||
warningCache: openAllowlistWarningCache,
|
||||
discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
source: plugin.source,
|
||||
origin: plugin.origin,
|
||||
})),
|
||||
});
|
||||
const provenance = buildProvenanceIndex({
|
||||
const provenance = buildExtensionHostProvenanceIndex({
|
||||
config: cfg,
|
||||
normalizedLoadPaths: normalized.loadPaths,
|
||||
env,
|
||||
@ -766,7 +792,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
||||
);
|
||||
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
|
||||
return compareDuplicateCandidateOrder({
|
||||
return compareExtensionHostDuplicateCandidateOrder({
|
||||
left,
|
||||
right,
|
||||
manifestByRoot,
|
||||
@ -788,7 +814,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
const pluginId = manifestRecord.id;
|
||||
const existingOrigin = seenIds.get(pluginId);
|
||||
if (existingOrigin) {
|
||||
const record = createPluginRecord({
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
@ -803,9 +829,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
enabled: false,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
});
|
||||
record.status = "disabled";
|
||||
record.error = `overridden by ${existingOrigin} plugin`;
|
||||
registry.plugins.push(record);
|
||||
setExtensionHostPluginRecordDisabled(record, `overridden by ${existingOrigin} plugin`);
|
||||
appendExtensionHostPluginRecord({ registry, record });
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -816,7 +841,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
rootConfig: cfg,
|
||||
});
|
||||
const entry = normalized.entries[pluginId];
|
||||
const record = createPluginRecord({
|
||||
const record = createExtensionHostPluginRecord({
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
@ -835,10 +860,14 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.configUiHints = manifestRecord.configUiHints;
|
||||
record.configJsonSchema = manifestRecord.configSchema;
|
||||
const pushPluginLoadError = (message: string) => {
|
||||
record.status = "error";
|
||||
record.error = message;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
setExtensionHostPluginRecordError(record, message);
|
||||
appendExtensionHostPluginRecord({
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@ -848,10 +877,14 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
};
|
||||
|
||||
if (!enableState.enabled) {
|
||||
record.status = "disabled";
|
||||
record.error = enableState.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
setExtensionHostPluginRecordDisabled(record, enableState.reason);
|
||||
appendExtensionHostPluginRecord({
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -881,21 +914,23 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
|
||||
// Fast-path bundled memory plugins that are guaranteed disabled by slot policy.
|
||||
// This avoids opening/importing heavy memory plugin modules that will never register.
|
||||
if (candidate.origin === "bundled" && manifestRecord.kind === "memory") {
|
||||
const earlyMemoryDecision = resolveMemorySlotDecision({
|
||||
id: record.id,
|
||||
kind: "memory",
|
||||
slot: memorySlot,
|
||||
selectedId: selectedMemoryPluginId,
|
||||
const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({
|
||||
origin: candidate.origin,
|
||||
manifestKind: manifestRecord.kind,
|
||||
recordId: record.id,
|
||||
memorySlot,
|
||||
selectedMemoryPluginId,
|
||||
});
|
||||
if (!earlyMemoryDecision.enabled) {
|
||||
setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason);
|
||||
appendExtensionHostPluginRecord({
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
if (!earlyMemoryDecision.enabled) {
|
||||
record.enabled = false;
|
||||
record.status = "disabled";
|
||||
record.error = earlyMemoryDecision.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!manifestRecord.configSchema) {
|
||||
@ -922,7 +957,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
try {
|
||||
mod = getJiti()(safeSource) as OpenClawPluginModule;
|
||||
} catch (err) {
|
||||
recordPluginError({
|
||||
recordExtensionHostPluginError({
|
||||
logger,
|
||||
registry,
|
||||
record,
|
||||
@ -936,49 +971,40 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = resolvePluginModuleExport(mod);
|
||||
const resolved = resolveExtensionHostModuleExport(mod);
|
||||
const definition = resolved.definition;
|
||||
const register = resolved.register;
|
||||
|
||||
if (definition?.id && definition.id !== record.id) {
|
||||
pushPluginLoadError(
|
||||
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
|
||||
);
|
||||
const definitionResult = applyExtensionHostDefinitionToRecord({
|
||||
record,
|
||||
definition,
|
||||
diagnostics: registry.diagnostics,
|
||||
});
|
||||
if (!definitionResult.ok) {
|
||||
pushPluginLoadError(definitionResult.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
record.name = definition?.name ?? record.name;
|
||||
record.description = definition?.description ?? record.description;
|
||||
record.version = definition?.version ?? record.version;
|
||||
const manifestKind = record.kind as string | undefined;
|
||||
const exportKind = definition?.kind as string | undefined;
|
||||
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
||||
registry.diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
|
||||
});
|
||||
}
|
||||
record.kind = definition?.kind ?? record.kind;
|
||||
|
||||
if (record.kind === "memory" && memorySlot === record.id) {
|
||||
memorySlotMatched = true;
|
||||
}
|
||||
|
||||
const memoryDecision = resolveMemorySlotDecision({
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
slot: memorySlot,
|
||||
selectedId: selectedMemoryPluginId,
|
||||
const memoryDecision = resolveExtensionHostMemoryDecision({
|
||||
recordId: record.id,
|
||||
recordKind: record.kind,
|
||||
memorySlot,
|
||||
selectedMemoryPluginId,
|
||||
});
|
||||
|
||||
if (!memoryDecision.enabled) {
|
||||
record.enabled = false;
|
||||
record.status = "disabled";
|
||||
record.error = memoryDecision.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
setExtensionHostPluginRecordDisabled(record, memoryDecision.reason);
|
||||
appendExtensionHostPluginRecord({
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -986,7 +1012,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
selectedMemoryPluginId = record.id;
|
||||
}
|
||||
|
||||
const validatedConfig = validatePluginConfig({
|
||||
const validatedConfig = validateExtensionHostConfig({
|
||||
schema: manifestRecord.configSchema,
|
||||
cacheKey: manifestRecord.schemaCacheKey,
|
||||
value: entry?.config,
|
||||
@ -999,8 +1025,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
|
||||
if (validateOnly) {
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
appendExtensionHostPluginRecord({
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1026,10 +1057,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
message: "plugin register returned a promise; async registration is ignored",
|
||||
});
|
||||
}
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
appendExtensionHostPluginRecord({
|
||||
registry,
|
||||
record,
|
||||
seenIds,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
} catch (err) {
|
||||
recordPluginError({
|
||||
recordExtensionHostPluginError({
|
||||
logger,
|
||||
registry,
|
||||
record,
|
||||
@ -1050,7 +1086,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
});
|
||||
}
|
||||
|
||||
warnAboutUntrackedLoadedPlugins({
|
||||
warnAboutUntrackedLoadedExtensions({
|
||||
registry,
|
||||
provenance,
|
||||
logger,
|
||||
@ -1060,7 +1096,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
if (cacheEnabled) {
|
||||
setCachedPluginRegistry(cacheKey, registry);
|
||||
}
|
||||
activatePluginRegistry(registry, cacheKey);
|
||||
activateExtensionHostRegistry(registry, cacheKey);
|
||||
return registry;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user