From a88f2ba9392cfb2f42bbcffa25f85cac756c06f0 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 27 Apr 2026 13:04:01 +0100 Subject: [PATCH] fix: avoid startup auto-enable runtime defaults --- CHANGELOG.md | 1 + src/config/plugin-auto-enable.core.test.ts | 51 +++++++++- src/config/plugin-auto-enable.shared.ts | 93 ++++++++++++++----- .../server-startup-config.recovery.test.ts | 62 +++++++++++++ src/gateway/server-startup-config.ts | 22 ++++- src/gateway/server.impl.ts | 1 + 6 files changed, 199 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbc8995f3c..b672e44f86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Agents/reasoning: recover fully wrapped unclosed `` replies that would otherwise sanitize to empty text while keeping strict stripping for closed reasoning blocks and unclosed tails after visible text. Fixes #37696; supersedes #51915. Thanks @druide67 and @okuyam2y. - Control UI/Gateway: bind WebChat handshakes to their active socket and reject post-close server registrations, so aborted connects no longer leave zombie clients or misleading duplicate WebSocket connection logs. Fixes #72753. Thanks @LumenFromTheFuture. - Agents/fallback: split ambiguous provider failures into `empty_response`, `no_error_details`, and `unclassified`, and add flat fallback-step fields to structured fallback logs so primary-model failures stay visible when later fallbacks also fail. Fixes #71922; refs #71744. Thanks @andyk-ms and @nikolaykazakovvs-ux. +- Gateway/startup: run plugin auto-enable from authored source config and skip disabled setup probes, avoiding runtime-default plugin allowlist writes and a second config snapshot read during startup. Thanks @shakkernerd. - Plugins/Windows: normalize Windows absolute paths before handing bundled plugin modules to Jiti, so Feishu/Lark message sending no longer fails with unsupported `c:` ESM loader URLs. Fixes #72783. Thanks @jackychen-png. - CLI/doctor: run bundled plugin runtime-dependency repairs through the async npm installer with spinner/line progress and heartbeat updates, so long `openclaw doctor --fix` installs no longer look hung in TTY or piped output. Fixes #72775. Thanks @dfpalhano. - Feishu/Windows: normalize bundled channel sidecar loads before Jiti evaluates them, so Feishu outbound sends no longer fail with raw `C:` ESM loader errors on Windows. Fixes #72783. Thanks @jackychen-png. diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index e0a9e031c1c..f75d0c5b86e 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -1,4 +1,5 @@ -import { afterAll, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { applyPluginAutoEnable, detectPluginAutoEnableCandidates, @@ -18,6 +19,10 @@ afterAll(() => { resetPluginAutoEnableTestState(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("applyPluginAutoEnable core", () => { it("detects typed channel-configured candidates", () => { const candidates = detectPluginAutoEnableCandidates({ @@ -157,6 +162,50 @@ describe("applyPluginAutoEnable core", () => { expect(result.changes).toEqual([]); }); + it("does not load plugin manifests for disabled plugin entries under a restrictive allowlist", () => { + const readFileSync = vi.spyOn(fs, "readFileSync"); + + const result = applyPluginAutoEnable({ + config: { + browser: { enabled: false }, + plugins: { + allow: ["telegram"], + entries: { + browser: { enabled: false }, + }, + }, + }, + env, + }); + + expect(result.config.plugins?.allow).toEqual(["telegram"]); + expect(result.config.plugins?.entries?.browser?.enabled).toBe(false); + expect(result.changes).toEqual([]); + expect( + readFileSync.mock.calls.some( + ([filePath]) => typeof filePath === "string" && filePath.endsWith("openclaw.plugin.json"), + ), + ).toBe(false); + }); + + it("still treats a non-disabled browser plugin entry as setup auto-enable input", () => { + const result = applyPluginAutoEnable({ + config: { + plugins: { + allow: ["telegram"], + entries: { + browser: {}, + }, + }, + }, + env, + }); + + expect(result.config.plugins?.allow).toEqual(["telegram", "browser"]); + expect(result.config.plugins?.entries?.browser?.enabled).toBe(true); + expect(result.changes).toContain("browser plugin configured, enabled automatically."); + }); + it("does not auto-enable or allowlist non-bundled web fetch providers from config", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 4558515f079..42a12e5d4ce 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -354,39 +354,71 @@ function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] { .filter(Boolean); } +function hasOwnPluginEntry(cfg: OpenClawConfig, pluginId: string): boolean { + const entries = cfg.plugins?.entries; + return !!entries && typeof entries === "object" && Object.hasOwn(entries, pluginId); +} + +function hasNonDisabledPluginEntry(cfg: OpenClawConfig, pluginId: string): boolean { + if (!hasOwnPluginEntry(cfg, pluginId)) { + return false; + } + const entry = cfg.plugins?.entries?.[pluginId]; + return !isRecord(entry) || entry.enabled !== false; +} + +function hasBrowserSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean { + if (isRecord(cfg.browser) && cfg.browser.enabled !== false) { + return true; + } + if (hasNonDisabledPluginEntry(cfg, "browser")) { + return true; + } + return hasBrowserToolReference(cfg); +} + +function hasAcpxSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean { + if (!isRecord(cfg.acp)) { + return false; + } + const backend = normalizeOptionalLowercaseString(cfg.acp.backend); + const configured = + cfg.acp.enabled === true || + (isRecord(cfg.acp.dispatch) && cfg.acp.dispatch.enabled === true) || + backend === "acpx"; + return configured && (!backend || backend === "acpx"); +} + +function hasXaiSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean { + const pluginConfig = cfg.plugins?.entries?.xai?.config; + return ( + (isRecord(pluginConfig) && + (isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))) || + (isRecord(cfg.tools?.web) && isRecord((cfg.tools.web as Record).x_search)) + ); +} + function resolveRelevantSetupAutoEnablePluginIds(cfg: OpenClawConfig): string[] { const pluginIds = new Set(collectConfiguredPluginEntryIds(cfg)); - if ( - isRecord(cfg.browser) || - isRecord(cfg.plugins?.entries?.browser) || - hasBrowserToolReference(cfg) - ) { + if (hasBrowserSetupAutoEnableRelevantConfig(cfg)) { pluginIds.add("browser"); } - if (isRecord(cfg.acp) || isRecord(cfg.plugins?.entries?.acpx)) { + if (hasAcpxSetupAutoEnableRelevantConfig(cfg)) { pluginIds.add("acpx"); } - if ( - isRecord(cfg.plugins?.entries?.xai) || - (isRecord(cfg.tools?.web) && isRecord((cfg.tools.web as Record).x_search)) - ) { + if (hasXaiSetupAutoEnableRelevantConfig(cfg)) { pluginIds.add("xai"); } return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); } function hasSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean { - const entries = cfg.plugins?.entries; - if (isRecord(cfg.browser) || isRecord(cfg.acp) || hasBrowserToolReference(cfg)) { - return true; - } - if (isRecord(entries?.browser) || isRecord(entries?.acpx) || isRecord(entries?.xai)) { - return true; - } - if (isRecord(cfg.tools?.web) && isRecord((cfg.tools.web as Record).x_search)) { - return true; - } - return hasConfiguredPluginConfigEntry(cfg); + return ( + hasBrowserSetupAutoEnableRelevantConfig(cfg) || + hasAcpxSetupAutoEnableRelevantConfig(cfg) || + hasXaiSetupAutoEnableRelevantConfig(cfg) || + hasConfiguredPluginConfigEntry(cfg) + ); } function hasPluginEntries(cfg: OpenClawConfig): boolean { @@ -394,8 +426,19 @@ function hasPluginEntries(cfg: OpenClawConfig): boolean { return !!entries && typeof entries === "object" && Object.keys(entries).length > 0; } -function hasPluginAllowlistWithEntries(cfg: OpenClawConfig): boolean { - return Array.isArray(cfg.plugins?.allow) && cfg.plugins.allow.length > 0 && hasPluginEntries(cfg); +function hasPluginAllowlistWithMaterialEntries(cfg: OpenClawConfig): boolean { + if ( + !Array.isArray(cfg.plugins?.allow) || + cfg.plugins.allow.length === 0 || + !hasPluginEntries(cfg) + ) { + return false; + } + const entries = cfg.plugins?.entries; + if (!entries || typeof entries !== "object") { + return false; + } + return Object.values(entries).some(hasMaterialPluginEntryConfig); } function hasConfiguredProviderModelOrHarness(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { @@ -412,7 +455,7 @@ function hasConfiguredProviderModelOrHarness(cfg: OpenClawConfig, env: NodeJS.Pr } function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - if (hasPluginAllowlistWithEntries(cfg)) { + if (hasPluginAllowlistWithMaterialEntries(cfg)) { return true; } if (hasConfiguredPluginConfigEntry(cfg)) { @@ -438,7 +481,7 @@ export function configMayNeedPluginAutoEnable( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, ): boolean { - if (hasPluginAllowlistWithEntries(cfg)) { + if (hasPluginAllowlistWithMaterialEntries(cfg)) { return true; } if (hasConfiguredPluginConfigEntry(cfg)) { diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index f7ad484e4f3..8fccf042266 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -14,6 +14,7 @@ vi.mock("../config/config.js", () => ({ } return snapshot.issues.every((issue) => issue.path.startsWith("plugins.entries.")); }), + replaceConfigFile: vi.fn(), shouldAttemptLastKnownGoodRecovery: vi.fn((snapshot: ConfigFileSnapshot) => { if (snapshot.valid) { return false; @@ -75,6 +76,67 @@ describe("gateway startup config recovery", () => { vi.clearAllMocks(); }); + it("runs startup plugin auto-enable against source config without persisting runtime defaults", async () => { + const sourceConfig = { + browser: { enabled: false }, + gateway: { mode: "local" }, + plugins: { + allow: ["bench-plugin"], + entries: { + browser: { enabled: false }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + ...sourceConfig, + plugins: { + ...sourceConfig.plugins, + entries: { + ...sourceConfig.plugins?.entries, + "memory-core": { + config: { + dreaming: { + enabled: false, + }, + }, + }, + }, + }, + } as OpenClawConfig; + const snapshot = { + ...buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify(sourceConfig)}\n`, + parsed: sourceConfig, + valid: true, + config: runtimeConfig, + issues: [], + legacyIssues: [], + }), + sourceConfig, + resolved: sourceConfig, + runtimeConfig, + config: runtimeConfig, + } satisfies ConfigFileSnapshot; + vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(snapshot); + const log = { info: vi.fn(), warn: vi.fn() }; + + await expect( + loadGatewayStartupConfigSnapshot({ + minimalTestGateway: false, + log, + }), + ).resolves.toEqual({ + snapshot, + wroteConfig: false, + }); + + expect(configIo.readConfigFileSnapshot).toHaveBeenCalledTimes(1); + expect(configIo.replaceConfigFile).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); + }); + it("restores last-known-good config before startup validation", async () => { const invalidSnapshot = buildSnapshot({ valid: false, raw: "{ invalid json" }); const recoveredSnapshot = buildSnapshot({ diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 3656dc0e345..3ddccc185f1 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -56,6 +56,8 @@ type GatewayStartupConfigOverrides = { tailscale?: GatewayTailscaleConfig; }; +type GatewayStartupConfigMeasure = (name: string, run: () => T | Promise) => Promise; + export type GatewayStartupConfigSnapshotLoadResult = { snapshot: ConfigFileSnapshot; wroteConfig: boolean; @@ -187,8 +189,10 @@ function resolveGatewayStartupConfigWithoutInvalidPluginEntries(params: { export async function loadGatewayStartupConfigSnapshot(params: { minimalTestGateway: boolean; log: GatewayStartupLog; + measure?: GatewayStartupConfigMeasure; }): Promise { - let configSnapshot = await readConfigFileSnapshot(); + const measure = params.measure ?? (async (_name, run) => await run()); + let configSnapshot = await measure("config.snapshot.read", () => readConfigFileSnapshot()); let wroteConfig = false; let degradedStartupConfig = false; let degradedPluginConfig = false; @@ -236,7 +240,9 @@ export async function loadGatewayStartupConfigSnapshot(params: { params.log.warn( `gateway: invalid config was restored from last-known-good backup: ${configSnapshot.path}`, ); - configSnapshot = await readConfigFileSnapshot(); + configSnapshot = await measure("config.snapshot.recovery-read", () => + readConfigFileSnapshot(), + ); if (configSnapshot.valid) { enqueueConfigRecoveryNotice({ cfg: configSnapshot.config, @@ -251,7 +257,9 @@ export async function loadGatewayStartupConfigSnapshot(params: { params.log.warn( `gateway: invalid config was repaired by stripping a non-JSON prefix: ${configSnapshot.path}`, ); - configSnapshot = await readConfigFileSnapshot(); + configSnapshot = await measure("config.snapshot.prefix-recovery-read", () => + readConfigFileSnapshot(), + ); } } assertValidGatewayStartupConfigSnapshot(configSnapshot, { includeDoctorHint: true }); @@ -260,7 +268,9 @@ export async function loadGatewayStartupConfigSnapshot(params: { const autoEnable = params.minimalTestGateway || degradedStartupConfig || degradedPluginConfig ? { config: configSnapshot.config, changes: [] as string[] } - : applyPluginAutoEnable({ config: configSnapshot.config, env: process.env }); + : await measure("config.snapshot.auto-enable", () => + applyPluginAutoEnable({ config: configSnapshot.sourceConfig, env: process.env }), + ); if (autoEnable.changes.length === 0) { return { snapshot: configSnapshot, @@ -276,7 +286,9 @@ export async function loadGatewayStartupConfigSnapshot(params: { afterWrite: { mode: "auto" }, }); wroteConfig = true; - configSnapshot = await readConfigFileSnapshot(); + configSnapshot = await measure("config.snapshot.auto-enable-read", () => + readConfigFileSnapshot(), + ); assertValidGatewayStartupConfigSnapshot(configSnapshot); params.log.info( `gateway: auto-enabled plugins:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6673dbb5181..0078ade5790 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -313,6 +313,7 @@ export async function startGatewayServer( loadGatewayStartupConfigSnapshot({ minimalTestGateway, log, + measure: (name, run) => startupTrace.measure(name, run), }), ); const configSnapshot = startupConfigLoad.snapshot;