fix(plugins): preserve source activation config

This commit is contained in:
Peter Steinberger
2026-04-22 19:25:50 +01:00
parent 6d003cbcee
commit 4b2b261367
8 changed files with 226 additions and 5 deletions

View File

@@ -0,0 +1,19 @@
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
export function resolvePluginActivationSourceConfig(params: {
config?: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
}): OpenClawConfig {
if (params.activationSourceConfig !== undefined) {
return params.activationSourceConfig;
}
const sourceSnapshot = getRuntimeConfigSourceSnapshot();
if (sourceSnapshot && params.config === getRuntimeConfigSnapshot()) {
return sourceSnapshot;
}
return params.config ?? {};
}

View File

@@ -3,6 +3,10 @@ import path from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { listAgentHarnessIds } from "../agents/harness/registry.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import { getContextEngineFactory, listContextEngineIds } from "../context-engine/registry.js";
import {
clearInternalHooks,
@@ -822,6 +826,7 @@ function expectEscapingEntryRejected(params: {
}
afterEach(() => {
clearRuntimeConfigSnapshot();
resetPluginLoaderTestStateForTest();
});
@@ -5758,6 +5763,65 @@ module.exports = {
});
});
it("uses the source runtime snapshot allowlist for plugin trust checks", () => {
useNoBundledPlugins();
const stateDir = makeTempDir();
withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => {
const globalDir = path.join(stateDir, "extensions", "trusted-plugin");
mkdirSafe(globalDir);
writePlugin({
id: "trusted-plugin",
body: simplePluginBody("trusted-plugin"),
dir: globalDir,
filename: "index.cjs",
});
const untrustedDir = path.join(stateDir, "extensions", "untrusted-plugin");
mkdirSafe(untrustedDir);
writePlugin({
id: "untrusted-plugin",
body: simplePluginBody("untrusted-plugin"),
dir: untrustedDir,
filename: "index.cjs",
});
const runtimeConfig = {
plugins: {
enabled: true,
allow: ["runtime-added-plugin"],
},
} satisfies PluginLoadConfig;
const sourceConfig = {
plugins: {
enabled: true,
allow: ["trusted-plugin"],
},
} satisfies PluginLoadConfig;
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
const warnings: string[] = [];
const registry = loadOpenClawPlugins({
cache: false,
logger: createWarningLogger(warnings),
config: runtimeConfig,
});
expect(registry.plugins.find((entry) => entry.id === "trusted-plugin")?.status).toBe(
"loaded",
);
expect(registry.plugins.find((entry) => entry.id === "untrusted-plugin")).toMatchObject({
status: "disabled",
error: "not in allowlist",
});
expect(warnings.some((message) => message.includes("plugins.allow is empty"))).toBe(false);
expect(
warnings.some(
(message) =>
message.includes("trusted-plugin") &&
message.includes("loaded without install/load-path provenance"),
),
).toBe(false);
});
});
it.each([
{
name: "rejects plugin entry files that escape plugin root via symlink",

View File

@@ -29,6 +29,7 @@ import {
restoreDetachedTaskLifecycleRuntimeRegistration,
} from "../tasks/detached-task-runtime-state.js";
import { resolveUserPath } from "../utils.js";
import { resolvePluginActivationSourceConfig } from "./activation-source-config.js";
import { buildPluginApi } from "./api-builder.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import {
@@ -833,11 +834,18 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
const env = options.env ?? process.env;
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
const activationSourceConfig = options.activationSourceConfig ?? options.config ?? {};
const activationSourceConfig = resolvePluginActivationSourceConfig({
config: options.config,
activationSourceConfig: options.activationSourceConfig,
});
const normalized = normalizePluginsConfig(cfg.plugins);
const activationSource = createPluginActivationSource({
config: activationSourceConfig,
});
const trustNormalized = mergeTrustPluginConfigFromActivationSource({
normalized,
activationSource,
});
const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds);
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
const forceSetupOnlyChannelPlugins = options.forceSetupOnlyChannelPlugins === true;
@@ -848,7 +856,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
const cacheKey = buildCacheKey({
workspaceDir: options.workspaceDir,
plugins: normalized,
plugins: trustNormalized,
activationMetadataKey: buildActivationMetadataHash({
activationSource,
autoEnabledReasons: options.autoEnabledReasons ?? {},
@@ -868,7 +876,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
return {
env,
cfg,
normalized,
normalized: trustNormalized,
activationSourceConfig,
activationSource,
autoEnabledReasons: options.autoEnabledReasons ?? {},
@@ -884,6 +892,44 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
};
}
function mergeTrustPluginConfigFromActivationSource(params: {
normalized: NormalizedPluginsConfig;
activationSource: PluginActivationConfigSource;
}): NormalizedPluginsConfig {
const source = params.activationSource.plugins;
const allow = mergePluginTrustList(params.normalized.allow, source.allow);
const deny = mergePluginTrustList(params.normalized.deny, source.deny);
const loadPaths = mergePluginTrustList(params.normalized.loadPaths, source.loadPaths);
if (
allow === params.normalized.allow &&
deny === params.normalized.deny &&
loadPaths === params.normalized.loadPaths
) {
return params.normalized;
}
return {
...params.normalized,
allow,
deny,
loadPaths,
};
}
function mergePluginTrustList(runtimeList: string[], sourceList: readonly string[]): string[] {
if (sourceList.length === 0) {
return runtimeList;
}
const merged = [...runtimeList];
const seen = new Set(merged);
for (const entry of sourceList) {
if (!seen.has(entry)) {
merged.push(entry);
seen.add(entry);
}
}
return merged.length === runtimeList.length ? runtimeList : merged;
}
function getCompatibleActivePluginRegistry(
options: PluginLoadOptions = {},
): PluginRegistry | undefined {

View File

@@ -12,6 +12,8 @@ const resolveDefaultAgentIdMock = vi.fn<
let resolvePluginRuntimeLoadContext: typeof import("./load-context.js").resolvePluginRuntimeLoadContext;
let buildPluginRuntimeLoadOptions: typeof import("./load-context.js").buildPluginRuntimeLoadOptions;
let clearRuntimeConfigSnapshot: typeof import("../../config/runtime-snapshot.js").clearRuntimeConfigSnapshot;
let setRuntimeConfigSnapshot: typeof import("../../config/runtime-snapshot.js").setRuntimeConfigSnapshot;
vi.mock("../../config/config.js", () => ({
loadConfig: loadConfigMock,
@@ -29,6 +31,8 @@ vi.mock("../../agents/agent-scope.js", () => ({
describe("resolvePluginRuntimeLoadContext", () => {
beforeEach(async () => {
vi.resetModules();
({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../../config/runtime-snapshot.js"));
({ resolvePluginRuntimeLoadContext, buildPluginRuntimeLoadOptions } =
await import("./load-context.js"));
loadConfigMock.mockReset();
@@ -42,6 +46,7 @@ describe("resolvePluginRuntimeLoadContext", () => {
changes: [],
autoEnabledReasons: {},
}));
clearRuntimeConfigSnapshot();
});
it("builds the runtime plugin load context from the auto-enabled config", () => {
@@ -88,6 +93,27 @@ describe("resolvePluginRuntimeLoadContext", () => {
expect(resolveAgentWorkspaceDirMock).toHaveBeenCalledWith(resolvedConfig, "default");
});
it("uses the source runtime snapshot for plugin activation source config", () => {
const runtimeConfig = { plugins: {} };
const sourceConfig = {
plugins: {
allow: ["trusted-plugin"],
},
};
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
loadConfigMock.mockReturnValue(runtimeConfig);
const context = resolvePluginRuntimeLoadContext();
expect(context.rawConfig).toBe(runtimeConfig);
expect(context.activationSourceConfig).toBe(sourceConfig);
expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({
config: runtimeConfig,
env: process.env,
});
});
it("builds plugin load options from the shared runtime context", () => {
const context = resolvePluginRuntimeLoadContext({
config: { plugins: {} },

View File

@@ -3,6 +3,7 @@ import { loadConfig } from "../../config/config.js";
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSubsystemLogger } from "../../logging.js";
import { resolvePluginActivationSourceConfig } from "../activation-source-config.js";
import type { PluginLoadOptions } from "../loader.js";
import type { PluginLogger } from "../types.js";
@@ -45,6 +46,10 @@ export function resolvePluginRuntimeLoadContext(
): PluginRuntimeLoadContext {
const env = options?.env ?? process.env;
const rawConfig = options?.config ?? loadConfig();
const activationSourceConfig = resolvePluginActivationSourceConfig({
config: rawConfig,
activationSourceConfig: options?.activationSourceConfig,
});
const autoEnabled = applyPluginAutoEnable({ config: rawConfig, env });
const config = autoEnabled.config;
const workspaceDir =
@@ -52,7 +57,7 @@ export function resolvePluginRuntimeLoadContext(
return {
rawConfig,
config,
activationSourceConfig: options?.activationSourceConfig ?? rawConfig,
activationSourceConfig,
autoEnabledReasons: autoEnabled.autoEnabledReasons,
workspaceDir,
env,