mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix: avoid startup auto-enable runtime defaults
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")}`,
|
||||
|
||||
@@ -313,6 +313,7 @@ export async function startGatewayServer(
|
||||
loadGatewayStartupConfigSnapshot({
|
||||
minimalTestGateway,
|
||||
log,
|
||||
measure: (name, run) => startupTrace.measure(name, run),
|
||||
}),
|
||||
);
|
||||
const configSnapshot = startupConfigLoad.snapshot;
|
||||
|
||||
Reference in New Issue
Block a user