Plugins: add loader finalization policy

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 14:52:01 +00:00
parent 9fbfd18c76
commit fd7488e10a
No known key found for this signature in database
4 changed files with 190 additions and 12 deletions

View File

@ -43,6 +43,7 @@ This is an implementation checklist, not a future-design spec.
| Loader mutable activation state session | local variables in `src/plugins/loader.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-session.ts` | `partial` | Seen-id tracking, memory-slot selection state, and finalization inputs now live in a host-owned loader session instead of being spread across top-level loader variables. |
| Loader activation policy outcomes | open-coded in `src/plugins/loader.ts` and `src/extension-host/loader-flow.ts` | `src/extension-host/loader-activation-policy.ts` | `partial` | Duplicate precedence, config enablement, and early memory-slot gating now resolve through explicit host-owned activation-policy outcomes instead of remaining as inline loader decisions. |
| Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | The loader now enforces an explicit lifecycle transition model (`prepared -> imported -> validated -> registered -> ready`, plus terminal `disabled` and `error`) while still mapping back to compatibility `PluginRecord.status` values. |
| Loader finalization policy results | mixed inside `src/plugins/loader.ts`, `src/extension-host/loader-policy.ts`, and `src/extension-host/loader-finalize.ts` | `src/extension-host/loader-finalization-policy.ts` | `partial` | Memory-slot finalization warnings and provenance-based untracked-extension warnings now resolve through explicit host-owned finalization-policy results before the finalizer applies them. |
| Loader final cache, warning, and activation finalization | `src/plugins/loader.ts` | `src/extension-host/loader-finalize.ts` | `partial` | Cache writes, untracked-extension warnings, final memory-slot warnings, readiness promotion, and registry activation now delegate through a host-owned loader-finalize helper; broader host lifecycle and policy semantics are still pending. |
| 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. |
@ -89,14 +90,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, cache control, initial candidate planning, entry-path import, explicit activation-policy outcomes, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, and final cache plus activation finalization
- loader compatibility, cache control, initial candidate planning, entry-path import, explicit activation-policy outcomes, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, explicit finalization-policy results, and final cache plus activation finalization
## 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. Extend the new loader lifecycle state machine, session-owned activation state, and activation-policy outcomes into broader activation-state and policy ownership in `src/extension-host/*`.
2. Extend the new loader lifecycle state machine, session-owned activation state, activation-policy outcomes, and finalization-policy results into broader activation-state and policy ownership in `src/extension-host/*`.
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.

View File

@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { PluginRegistry } from "../plugins/registry.js";
import { resolveExtensionHostFinalizationPolicy } from "./loader-finalization-policy.js";
function createRegistry(): PluginRegistry {
return {
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
}
describe("extension host loader finalization policy", () => {
it("emits memory-slot diagnostics when no selected memory plugin matched", () => {
const result = resolveExtensionHostFinalizationPolicy({
registry: createRegistry(),
memorySlot: "memory-a",
memorySlotMatched: false,
provenance: {
loadPathMatcher: { exact: new Set(), dirs: [] },
installRules: new Map(),
},
env: process.env,
});
expect(result.diagnostics).toContainEqual({
level: "warn",
message: "memory slot plugin not found or not marked as memory: memory-a",
});
});
it("emits provenance warnings for untracked non-bundled plugins", () => {
const registry = createRegistry();
registry.plugins.push({
id: "demo",
name: "demo",
source: "/tmp/demo/index.js",
origin: "workspace",
enabled: true,
status: "loaded",
lifecycleState: "ready",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
});
const result = resolveExtensionHostFinalizationPolicy({
registry,
memorySlotMatched: true,
provenance: {
loadPathMatcher: { exact: new Set(), dirs: [] },
installRules: new Map(),
},
env: process.env,
});
expect(result.diagnostics).toContainEqual({
level: "warn",
pluginId: "demo",
source: "/tmp/demo/index.js",
message:
"loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records",
});
expect(result.warningMessages[0]).toContain("[plugins] demo:");
});
});

View File

@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import type { PluginRegistry } from "../plugins/registry.js";
import type { PluginDiagnostic } from "../plugins/types.js";
import type { ExtensionHostProvenanceIndex } from "./loader-policy.js";
function safeRealpathOrResolve(value: string): string {
try {
return fs.realpathSync(value);
} catch {
return path.resolve(value);
}
}
function matchesPathMatcher(
matcher: { exact: Set<string>; dirs: string[] },
sourcePath: string,
): boolean {
if (matcher.exact.has(sourcePath)) {
return true;
}
return matcher.dirs.some(
(dirPath) => sourcePath === dirPath || sourcePath.startsWith(`${dirPath}/`),
);
}
function isTrackedByProvenance(params: {
pluginId: string;
source: string;
index: ExtensionHostProvenanceIndex;
env: NodeJS.ProcessEnv;
}): boolean {
const sourcePath = params.source.startsWith("~")
? `${params.env.HOME ?? ""}${params.source.slice(1)}`
: params.source;
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);
}
export function resolveExtensionHostFinalizationPolicy(params: {
registry: PluginRegistry;
memorySlot?: string | null;
memorySlotMatched: boolean;
provenance: ExtensionHostProvenanceIndex;
env: NodeJS.ProcessEnv;
}): {
diagnostics: PluginDiagnostic[];
warningMessages: string[];
} {
const diagnostics: PluginDiagnostic[] = [];
const warningMessages: string[] = [];
if (typeof params.memorySlot === "string" && !params.memorySlotMatched) {
diagnostics.push({
level: "warn",
message: `memory slot plugin not found or not marked as memory: ${params.memorySlot}`,
});
}
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";
diagnostics.push({
level: "warn",
pluginId: plugin.id,
source: plugin.source,
message,
});
warningMessages.push(
`[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolve(plugin.source)})`,
);
}
return { diagnostics, warningMessages };
}

View File

@ -1,7 +1,7 @@
import type { PluginRegistry } from "../plugins/registry.js";
import type { PluginLogger } from "../plugins/types.js";
import { resolveExtensionHostFinalizationPolicy } from "./loader-finalization-policy.js";
import type { ExtensionHostProvenanceIndex } from "./loader-policy.js";
import { warnAboutUntrackedLoadedExtensions } from "./loader-policy.js";
import { markExtensionHostRegistryPluginsReady } from "./loader-state.js";
export function finalizeExtensionHostRegistryLoad(params: {
@ -16,19 +16,17 @@ export function finalizeExtensionHostRegistryLoad(params: {
setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void;
activateRegistry: (registry: PluginRegistry, cacheKey: string) => void;
}): PluginRegistry {
if (typeof params.memorySlot === "string" && !params.memorySlotMatched) {
params.registry.diagnostics.push({
level: "warn",
message: `memory slot plugin not found or not marked as memory: ${params.memorySlot}`,
});
}
warnAboutUntrackedLoadedExtensions({
const finalizationPolicy = resolveExtensionHostFinalizationPolicy({
registry: params.registry,
memorySlot: params.memorySlot,
memorySlotMatched: params.memorySlotMatched,
provenance: params.provenance,
logger: params.logger,
env: params.env,
});
params.registry.diagnostics.push(...finalizationPolicy.diagnostics);
for (const warning of finalizationPolicy.warningMessages) {
params.logger.warn(warning);
}
if (params.cacheEnabled) {
params.setCachedRegistry(params.cacheKey, params.registry);