fix: keep disabled channel doctor probes lazy

This commit is contained in:
Peter Steinberger
2026-04-24 18:48:20 +01:00
parent 1042b893f6
commit 94275f13fb
11 changed files with 304 additions and 55 deletions

View File

@@ -10,6 +10,12 @@
.bun
.artifacts
**/.artifacts
.local
**/.local
.pi
**/.pi
__openclaw_vitest__
**/__openclaw_vitest__
.tmp
**/.tmp
.DS_Store
@@ -40,6 +46,9 @@ docs/.generated
*.log
tmp
**/tmp
dist-runtime
**/dist-runtime
openclaw-path-alias-*
# build artifacts
dist

View File

@@ -904,9 +904,56 @@ assert_dep_absent_everywhere() {
exit 1
fi
done
if find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f | grep -q .; then
echo "disabled $channel unexpectedly staged $dep_path externally" >&2
find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true
if ! node - <<'NODE' "$OPENCLAW_PLUGIN_STAGE_DIR" "$dep_path"
const fs = require("node:fs");
const path = require("node:path");
const stageDir = process.argv[2];
const depName = process.argv[3];
const manifestName = ".openclaw-runtime-deps.json";
const matches = [];
function visit(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
visit(fullPath);
continue;
}
if (entry.name !== manifestName) {
continue;
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(fullPath, "utf8"));
} catch {
continue;
}
const specs = Array.isArray(parsed.specs) ? parsed.specs : [];
for (const spec of specs) {
if (typeof spec === "string" && spec.startsWith(`${depName}@`)) {
matches.push(`${fullPath}: ${spec}`);
}
}
}
}
visit(stageDir);
if (matches.length > 0) {
process.stderr.write(`${matches.join("\n")}\n`);
process.exit(1);
}
NODE
then
echo "disabled $channel unexpectedly selected $dep_path for external runtime deps" >&2
cat /tmp/openclaw-disabled-config-doctor.log >&2
exit 1
fi
}
@@ -969,7 +1016,7 @@ assert_dep_absent_everywhere telegram grammy "$root"
assert_dep_absent_everywhere slack @slack/web-api "$root"
assert_dep_absent_everywhere discord discord-api-types "$root"
if grep -Eq "\\[plugins\\] (telegram|slack|discord) installed bundled runtime deps:" /tmp/openclaw-disabled-config-doctor.log; then
if grep -Eq "(used by .*\\b(telegram|slack|discord)\\b|\\[plugins\\] (telegram|slack|discord) installed bundled runtime deps:)" /tmp/openclaw-disabled-config-doctor.log; then
echo "doctor installed runtime deps for an explicitly disabled channel/plugin" >&2
cat /tmp/openclaw-disabled-config-doctor.log >&2
exit 1

View File

@@ -522,6 +522,13 @@ describe("bundled channel entry shape guards", () => {
"./bundled.js?scope=bundled-setup-only-feature",
);
expect(
bundled.listBundledChannelLegacyStateMigrationDetectors({
config: { channels: { alpha: { enabled: false } } },
}),
).toEqual([]);
expect(testGlobal.__bundledSetupOnlySetupLoaded).toBeUndefined();
const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors();
expect(
detectors.map((detector) =>

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type {
@@ -17,6 +18,7 @@ import {
} from "../../plugins/bundled-runtime-root.js";
import { unwrapDefaultModuleExport } from "../../plugins/module-export.js";
import type { PluginRuntime } from "../../plugins/runtime/types.js";
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js";
import { normalizeChannelMeta } from "./meta-normalization.js";
import { isJavaScriptModulePath, loadChannelPluginModule } from "./module-loader.js";
@@ -44,8 +46,12 @@ type BundledChannelSetupEntryRuntimeContract = {
loadSetupSecrets?: (
options?: BundledEntryModuleLoadOptions,
) => ChannelPlugin["secrets"] | undefined;
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
loadLegacyStateMigrationDetector?: (
options?: BundledEntryModuleLoadOptions,
) => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: (
options?: BundledEntryModuleLoadOptions,
) => BundledChannelLegacySessionSurface;
features?: {
legacyStateMigrations?: boolean;
legacySessionSurfaces?: boolean;
@@ -347,15 +353,74 @@ function listBundledChannelPluginIdsForRoot(
.toSorted((left, right) => left.localeCompare(right));
}
function shouldIncludeBundledChannelSetupFeatureForConfig(params: {
metadata: BundledChannelPluginMetadata;
config?: OpenClawConfig;
}): boolean {
if (!params.config) {
return true;
}
const plugins = params.config.plugins;
if (plugins?.enabled === false) {
return false;
}
const pluginId = params.metadata.manifest.id;
if (plugins?.deny?.includes(pluginId)) {
return false;
}
if (plugins?.entries?.[pluginId]?.enabled === false) {
return false;
}
let hasExplicitChannelDisable = false;
for (const channelId of params.metadata.manifest.channels ?? [pluginId]) {
const normalizedChannelId = normalizeOptionalLowercaseString(channelId);
if (!normalizedChannelId) {
continue;
}
const channelConfig = (params.config.channels as Record<string, unknown> | undefined)?.[
normalizedChannelId
];
if (!channelConfig || typeof channelConfig !== "object" || Array.isArray(channelConfig)) {
continue;
}
if ((channelConfig as { enabled?: unknown }).enabled === false) {
hasExplicitChannelDisable = true;
continue;
}
return true;
}
return !hasExplicitChannelDisable;
}
function listBundledChannelPluginIdsForSetupFeature(
rootScope: BundledChannelRootScope,
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
options: { config?: OpenClawConfig } = {},
): readonly ChannelId[] {
const hinted = listBundledChannelMetadata(rootScope)
.filter((metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true)
.filter(
(metadata) =>
metadata.packageManifest?.setupFeatures?.[feature] === true &&
shouldIncludeBundledChannelSetupFeatureForConfig({
metadata,
config: options.config,
}),
)
.map((metadata) => metadata.manifest.id)
.toSorted((left, right) => left.localeCompare(right));
return hinted.length > 0 ? hinted : listBundledChannelPluginIdsForRoot(rootScope);
return hinted.length > 0
? hinted
: listBundledChannelMetadata(rootScope)
.filter((metadata) =>
shouldIncludeBundledChannelSetupFeatureForConfig({
metadata,
config: options.config,
}),
)
.map((metadata) => metadata.manifest.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledChannelPluginIds(): readonly ChannelId[] {
@@ -626,9 +691,12 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] {
export function listBundledChannelSetupPluginsByFeature(
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
options: { config?: OpenClawConfig } = {},
): readonly ChannelPlugin[] {
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
return listBundledChannelPluginIdsForSetupFeature(rootScope, feature).flatMap((id) => {
return listBundledChannelPluginIdsForSetupFeature(rootScope, feature, {
config: options.config,
}).flatMap((id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
if (!hasSetupEntryFeature(setupEntry, feature)) {
return [];
@@ -638,50 +706,50 @@ export function listBundledChannelSetupPluginsByFeature(
});
}
export function listBundledChannelLegacySessionSurfaces(): readonly BundledChannelLegacySessionSurface[] {
export function listBundledChannelLegacySessionSurfaces(
options: {
config?: OpenClawConfig;
} = {},
): readonly BundledChannelLegacySessionSurface[] {
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces").flatMap(
(id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(
id,
rootScope,
cacheContext,
);
const surface = setupEntry?.loadLegacySessionSurface?.();
if (surface) {
return [surface];
}
if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) {
return [];
}
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
return plugin?.messaging ? [plugin.messaging] : [];
},
);
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces", {
config: options.config,
}).flatMap((id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
const surface = setupEntry?.loadLegacySessionSurface?.({ installRuntimeDeps: false });
if (surface) {
return [surface];
}
if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) {
return [];
}
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
return plugin?.messaging ? [plugin.messaging] : [];
});
}
export function listBundledChannelLegacyStateMigrationDetectors(): readonly BundledChannelLegacyStateMigrationDetector[] {
export function listBundledChannelLegacyStateMigrationDetectors(
options: {
config?: OpenClawConfig;
} = {},
): readonly BundledChannelLegacyStateMigrationDetector[] {
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations").flatMap(
(id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(
id,
rootScope,
cacheContext,
);
const detector = setupEntry?.loadLegacyStateMigrationDetector?.();
if (detector) {
return [detector];
}
if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) {
return [];
}
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
return plugin?.lifecycle?.detectLegacyStateMigrations
? [plugin.lifecycle.detectLegacyStateMigrations]
: [];
},
);
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations", {
config: options.config,
}).flatMap((id) => {
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
const detector = setupEntry?.loadLegacyStateMigrationDetector?.({ installRuntimeDeps: false });
if (detector) {
return [detector];
}
if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) {
return [];
}
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
return plugin?.lifecycle?.detectLegacyStateMigrations
? [plugin.lifecycle.detectLegacyStateMigrations]
: [];
});
}
export function hasBundledChannelEntryFeature(

View File

@@ -33,4 +33,40 @@ describe("doctor allowlist-policy repair", () => {
expect(result.config.channels?.matrix?.allowFrom).toBeUndefined();
expect(result.config.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org"]);
});
it("skips disabled channel and account entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValue(["alice"]);
const result = await maybeRepairAllowlistPolicyAllowFrom({
channels: {
telegram: {
enabled: false,
dmPolicy: "allowlist",
},
signal: {
accounts: {
disabled: { enabled: false, dmPolicy: "allowlist" },
},
},
},
});
expect(result).toEqual({
config: {
channels: {
telegram: {
enabled: false,
dmPolicy: "allowlist",
},
signal: {
accounts: {
disabled: { enabled: false, dmPolicy: "allowlist" },
},
},
},
},
changes: [],
});
expect(readChannelAllowFromStoreMock).not.toHaveBeenCalled();
});
});

View File

@@ -118,6 +118,9 @@ export async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig):
if (!channelConfig || typeof channelConfig !== "object") {
continue;
}
if (channelConfig.enabled === false) {
continue;
}
await recoverAllowFromForAccount({
channelName,
account: channelConfig,
@@ -132,6 +135,9 @@ export async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig):
if (!accountConfig || typeof accountConfig !== "object") {
continue;
}
if ((accountConfig as { enabled?: unknown }).enabled === false) {
continue;
}
await recoverAllowFromForAccount({
channelName,
account: accountConfig as Record<string, unknown>,

View File

@@ -11,6 +11,7 @@ import type {
ChannelDoctorSequenceResult,
} from "../../../channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
type ChannelDoctorEntry = {
doctor: ChannelDoctorAdapter;
@@ -59,6 +60,9 @@ export type ChannelDoctorEmptyAllowlistPolicyHooks = {
};
function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
if (cfg.plugins?.enabled === false) {
return [];
}
const channels =
cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels)
? cfg.channels
@@ -72,6 +76,9 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
if (channelId === "defaults") {
return false;
}
if (isChannelDoctorBlockedByConfig(channelId, cfg)) {
return false;
}
const entry = channelEntries[channelId];
return (
!entry ||
@@ -83,6 +90,21 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
.toSorted();
}
function isChannelDoctorBlockedByConfig(channelId: string, cfg: OpenClawConfig): boolean {
if (cfg.plugins?.enabled === false) {
return true;
}
const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? channelId;
if (cfg.plugins?.entries?.[normalizedChannelId]?.enabled === false) {
return true;
}
const channelEntry = (cfg.channels as Record<string, unknown> | undefined)?.[normalizedChannelId];
return (
Boolean(channelEntry && typeof channelEntry === "object" && !Array.isArray(channelEntry)) &&
(channelEntry as { enabled?: unknown }).enabled === false
);
}
function safeGetLoadedChannelPlugin(id: string) {
try {
return getLoadedChannelPlugin(id);
@@ -180,7 +202,12 @@ function listChannelDoctorEntries(
if (channelIds.length === 0) {
return [];
}
const selectedIds = new Set(channelIds);
const selectedIds = new Set(
channelIds.filter((id) => !isChannelDoctorBlockedByConfig(id, context.cfg)),
);
if (selectedIds.size === 0) {
return [];
}
const readOnlyPluginsById =
options.readOnlyPluginsById ?? listReadOnlyChannelPluginsById(context);

View File

@@ -56,4 +56,34 @@ describe("doctor empty allowlist policy scan", () => {
expect(warnings).toContain("extra:channels.telegram");
});
it("skips disabled channel and account entries", () => {
const extraWarningsForAccount = vi.fn(({ prefix }) => [`extra:${prefix}`]);
const warnings = scanEmptyAllowlistPolicyWarnings(
{
channels: {
telegram: {
enabled: false,
dmPolicy: "allowlist",
accounts: {
default: { dmPolicy: "allowlist" },
},
},
signal: {
accounts: {
disabled: { enabled: false, dmPolicy: "allowlist" },
},
},
},
},
{ doctorFixCommand: "openclaw doctor --fix", extraWarningsForAccount },
);
expect(warnings).toEqual(["extra:channels.signal"]);
expect(extraWarningsForAccount).toHaveBeenCalledTimes(1);
expect(extraWarningsForAccount).toHaveBeenCalledWith(
expect.objectContaining({ prefix: "channels.signal" }),
);
});
});

View File

@@ -12,6 +12,13 @@ type ScanEmptyAllowlistPolicyWarningsParams = {
) => boolean;
};
function isDisabledRecord(value: unknown): boolean {
return (
Boolean(value && typeof value === "object" && !Array.isArray(value)) &&
(value as { enabled?: unknown }).enabled === false
);
}
export function scanEmptyAllowlistPolicyWarnings(
cfg: OpenClawConfig,
params: ScanEmptyAllowlistPolicyWarningsParams,
@@ -76,6 +83,9 @@ export function scanEmptyAllowlistPolicyWarnings(
if (!channelConfig || typeof channelConfig !== "object") {
continue;
}
if (isDisabledRecord(channelConfig)) {
continue;
}
checkAccount(channelConfig, `channels.${channelName}`, channelName);
const accounts = asObjectRecord(channelConfig.accounts);
@@ -86,6 +96,9 @@ export function scanEmptyAllowlistPolicyWarnings(
if (!account || typeof account !== "object") {
continue;
}
if (isDisabledRecord(account)) {
continue;
}
checkAccount(
account as DoctorAccountRecord,
`channels.${channelName}.accounts.${accountId}`,

View File

@@ -669,7 +669,7 @@ async function collectChannelLegacyStateMigrationPlans(params: {
const plans: ChannelLegacyStateMigrationPlan[] = [];
// Legacy state detection belongs on a narrow setup-entry surface so doctor
// does not cold-load unrelated runtime channel code.
const detectors = listBundledChannelLegacyStateMigrationDetectors();
const detectors = listBundledChannelLegacyStateMigrationDetectors({ config: params.cfg });
for (const detectLegacyStateMigrations of detectors) {
const detected = await detectLegacyStateMigrations({
cfg: params.cfg,

View File

@@ -111,8 +111,12 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
loadSetupSecrets?: (
options?: BundledEntryModuleLoadOptions,
) => ChannelPlugin["secrets"] | undefined;
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
loadLegacyStateMigrationDetector?: (
options?: BundledEntryModuleLoadOptions,
) => BundledChannelLegacyStateMigrationDetector;
loadLegacySessionSurface?: (
options?: BundledEntryModuleLoadOptions,
) => BundledChannelLegacySessionSurface;
setChannelRuntime?: (runtime: PluginRuntime) => void;
features?: BundledChannelSetupEntryFeatures;
};
@@ -519,17 +523,19 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
}
: undefined;
const loadLegacyStateMigrationDetector = legacyStateMigrations
? () =>
? (options?: BundledEntryModuleLoadOptions) =>
loadBundledEntryExportSync<BundledChannelLegacyStateMigrationDetector>(
importMetaUrl,
legacyStateMigrations,
options,
)
: undefined;
const loadLegacySessionSurface = legacySessionSurface
? () =>
? (options?: BundledEntryModuleLoadOptions) =>
loadBundledEntryExportSync<BundledChannelLegacySessionSurface>(
importMetaUrl,
legacySessionSurface,
options,
)
: undefined;
return {