fix: avoid startup auto-enable runtime defaults

This commit is contained in:
Shakker
2026-04-27 13:04:01 +01:00
parent 6ced6bc4a3
commit a88f2ba939
6 changed files with 199 additions and 31 deletions

View File

@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Agents/reasoning: recover fully wrapped unclosed `<think>` 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.

View File

@@ -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: {

View File

@@ -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<string, unknown>).x_search))
);
}
function resolveRelevantSetupAutoEnablePluginIds(cfg: OpenClawConfig): string[] {
const pluginIds = new Set<string>(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<string, unknown>).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<string, unknown>).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)) {

View File

@@ -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({

View File

@@ -56,6 +56,8 @@ type GatewayStartupConfigOverrides = {
tailscale?: GatewayTailscaleConfig;
};
type GatewayStartupConfigMeasure = <T>(name: string, run: () => T | Promise<T>) => Promise<T>;
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<GatewayStartupConfigSnapshotLoadResult> {
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")}`,

View File

@@ -313,6 +313,7 @@ export async function startGatewayServer(
loadGatewayStartupConfigSnapshot({
minimalTestGateway,
log,
measure: (name, run) => startupTrace.measure(name, run),
}),
);
const configSnapshot = startupConfigLoad.snapshot;