diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf29fd32dd..6df90ff3236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc. - Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev. - Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed `gateway_start` hook, and move memory-core managed dreaming off the internal `gateway:startup` bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc. +- Plugins/config: read plugin trust decisions from the source config snapshot when a resolved runtime snapshot is active, so `plugins.allow` remains enforced and `doctor`/gateway startup no longer warn that the allowlist is empty when it is configured. Fixes #70161. Also fixes #70141. - Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation. - Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve `metadata-upgrade` pairing (platform / device family refresh) instead of being disconnected with `1008 pairing required`. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes. - Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path. diff --git a/src/plugin-sdk/facade-activation-check.runtime.ts b/src/plugin-sdk/facade-activation-check.runtime.ts index d7269390b44..b2db93666c4 100644 --- a/src/plugin-sdk/facade-activation-check.runtime.ts +++ b/src/plugin-sdk/facade-activation-check.runtime.ts @@ -4,7 +4,10 @@ import JSON5 from "json5"; import { resolveConfigPath } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { configMayNeedPluginAutoEnable } from "../config/plugin-auto-enable.shared.js"; -import { getRuntimeConfigSnapshot } from "../config/runtime-snapshot.js"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, +} from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import { @@ -66,6 +69,10 @@ function readFacadeBoundaryConfigSafely(): { cacheKey?: string; } { try { + const sourceSnapshot = getRuntimeConfigSourceSnapshot(); + if (sourceSnapshot) { + return { rawConfig: sourceSnapshot }; + } const runtimeSnapshot = getRuntimeConfigSnapshot(); if (runtimeSnapshot) { return { rawConfig: runtimeSnapshot }; diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index 73020f6a6d4..e85003e4546 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -5,6 +5,10 @@ import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/ import { createPluginActivationSource, normalizePluginsConfig } from "../plugins/config-state.js"; import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; +import { + resetFacadeActivationCheckRuntimeStateForTest, + resolveBundledPluginPublicSurfaceAccess as resolveActivationCheckBundledPluginPublicSurfaceAccess, +} from "./facade-activation-check.runtime.js"; import { __testing, canLoadActivatedBundledPluginPublicSurface, @@ -34,6 +38,7 @@ afterEach(() => { vi.restoreAllMocks(); clearRuntimeConfigSnapshot(); resetFacadeRuntimeStateForTest(); + resetFacadeActivationCheckRuntimeStateForTest(); clearPluginDiscoveryCache(); clearPluginManifestRegistryCache(); vi.doUnmock("../plugins/manifest-registry.js"); @@ -381,4 +386,52 @@ describe("plugin-sdk facade runtime", () => { }), ).toBe(true); }); + + it("prefers the source runtime snapshot for facade activation checks", () => { + const dir = createTempDirSync("openclaw-facade-source-snapshot-"); + fs.mkdirSync(path.join(dir, "demo"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "demo", "runtime-api.js"), + 'export const marker = "source-snapshot";\n', + "utf8", + ); + fs.writeFileSync( + path.join(dir, "demo", "openclaw.plugin.json"), + JSON.stringify({ + id: "demo", + }), + "utf8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + setRuntimeConfigSnapshot( + { + plugins: {}, + }, + { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + }, + ); + + expect( + resolveActivationCheckBundledPluginPublicSurfaceAccess({ + dirName: "demo", + artifactBasename: "runtime-api.js", + location: { + modulePath: path.join(dir, "demo", "runtime-api.js"), + boundaryRoot: dir, + }, + sourceExtensionsRoot: dir, + resolutionKey: "source-snapshot-demo", + }), + ).toEqual({ + allowed: true, + pluginId: "demo", + }); + }); }); diff --git a/src/plugins/activation-source-config.ts b/src/plugins/activation-source-config.ts new file mode 100644 index 00000000000..3d51e433d7f --- /dev/null +++ b/src/plugins/activation-source-config.ts @@ -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 ?? {}; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 3f3c5649785..7436af06944 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index df4cf9325aa..d5c24a28ebb 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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 { diff --git a/src/plugins/runtime/load-context.test.ts b/src/plugins/runtime/load-context.test.ts index 46c6efe63e6..c0d7b7d7c89 100644 --- a/src/plugins/runtime/load-context.test.ts +++ b/src/plugins/runtime/load-context.test.ts @@ -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: {} }, diff --git a/src/plugins/runtime/load-context.ts b/src/plugins/runtime/load-context.ts index 8f9ec36d029..5117de8b33f 100644 --- a/src/plugins/runtime/load-context.ts +++ b/src/plugins/runtime/load-context.ts @@ -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,