fix(doctor): ignore channel metadata during plugin repair (#120891)

* fix(doctor): ignore channel metadata during plugin repair

* test(doctor): avoid unsafe optional chain
This commit is contained in:
Peter Steinberger
2026-08-08 22:38:16 -07:00
committed by GitHub
parent a996a86f98
commit 562129d5df
39 changed files with 1005 additions and 1280 deletions
+6
View File
@@ -0,0 +1,6 @@
const CHANNEL_CONFIG_METADATA_KEYS = new Set(["defaults", "modelByChannel"]);
/** Returns true when a channels key contains shared metadata rather than a channel entry. */
export function isChannelConfigMetadataKey(value: string): boolean {
return CHANNEL_CONFIG_METADATA_KEYS.has(value.trim());
}
+22
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { isChannelConfigMetadataKey } from "./config-metadata.js";
import {
hasMeaningfulChannelConfig,
listExplicitlyDisabledChannelIdsForConfig,
@@ -57,6 +58,25 @@ describe("config presence", () => {
expect(hasMeaningfulChannelConfig({ homeserver: "https://matrix.example.org" })).toBe(true);
});
it("excludes metadata and blank keys while trimming configured channel ids", () => {
const cfg = {
channels: {
defaults: { token: "test-token" },
modelByChannel: { discord: "openai/gpt-5.6-luna" },
" ": { token: "dummy" },
" matrix ": { homeserver: "https://matrix.example.org" },
},
} as unknown as OpenClawConfig;
expect(isChannelConfigMetadataKey(" modelByChannel ")).toBe(true);
expectPotentialConfiguredChannelCase({
cfg,
env: {},
expectedIds: ["matrix"],
options: { includePersistedAuthState: false },
});
});
it("ignores enabled-only matrix config when listing configured channels", () => {
const env = {} as NodeJS.ProcessEnv;
const cfg = { channels: { matrix: { enabled: false } } };
@@ -76,6 +96,8 @@ describe("config presence", () => {
telegram: { enabled: true },
slack: { botToken: "token" },
discord: false,
modelByChannel: { enabled: false },
" ": { enabled: false },
},
} as unknown as OpenClawConfig;
+12 -10
View File
@@ -17,10 +17,9 @@ import { hasNonEmptyString } from "../infra/outbound/channel-target.js";
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
import { listOfficialExternalChannelEnvVars } from "../plugins/official-external-plugin-catalog.js";
import { isRecord } from "../utils.js";
import { isChannelConfigMetadataKey } from "./config-metadata.js";
import { listBundledChannelIds } from "./plugins/bundled-ids.js";
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
export type AmbientEnvTriggerPolicy = "allow" | "suppress";
type ChannelPresenceOptions = {
@@ -64,7 +63,9 @@ export function listExplicitlyDisabledChannelIdsForConfig(cfg: OpenClawConfig):
}
return Object.entries(channels)
.filter(([, value]) => isRecord(value) && value.enabled === false)
.map(([channelId]) => normalizeOptionalLowercaseString(channelId))
.map(([channelId]) => channelId.trim())
.filter((channelId) => channelId && !isChannelConfigMetadataKey(channelId))
.map((channelId) => normalizeOptionalLowercaseString(channelId))
.filter((channelId): channelId is string => Boolean(channelId));
}
@@ -139,15 +140,20 @@ export function listPotentialConfiguredChannelPresenceSignals(
): ChannelPresenceSignal[] {
const signals: ChannelPresenceSignal[] = [];
const seenSignals = new Set<string>();
const addSignal = (channelId: string, source: ChannelPresenceSignalSource) => {
const configuredChannelIds = new Set<string>();
const addSignal = (rawChannelId: string, source: ChannelPresenceSignalSource) => {
const channelId = rawChannelId.trim();
if (!channelId || isChannelConfigMetadataKey(channelId)) {
return;
}
const key = `${source}:${channelId}`;
if (seenSignals.has(key)) {
return;
}
seenSignals.add(key);
configuredChannelIds.add(channelId);
signals.push({ channelId, source });
};
const configuredChannelIds = new Set<string>();
const channelIds = options.channelIds ?? listBundledChannelIds(env, options.discovery);
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
const scopedChannelIds = options.channelIds
@@ -163,13 +169,12 @@ export function listPotentialConfiguredChannelPresenceSignals(
const channels = isRecord(cfg.channels) ? cfg.channels : null;
if (channels) {
for (const [key, value] of Object.entries(channels)) {
if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) {
if (isChannelConfigMetadataKey(key)) {
continue;
}
// Shared channel defaults are not concrete channel configuration; only per-channel entries
// with meaningful settings should produce presence signals.
if (hasMeaningfulChannelConfig(value)) {
configuredChannelIds.add(key);
addSignal(key, "config");
}
}
@@ -182,13 +187,11 @@ export function listPotentialConfiguredChannelPresenceSignals(
}
for (const [prefix, channelId] of channelEnvPrefixes) {
if (key.startsWith(prefix)) {
configuredChannelIds.add(channelId);
addSignal(channelId, "env");
}
}
for (const { channelId, envVars } of officialExternalChannelEnvVars) {
if (envVars.includes(key)) {
configuredChannelIds.add(channelId);
addSignal(channelId, "env");
}
}
@@ -200,7 +203,6 @@ export function listPotentialConfiguredChannelPresenceSignals(
// when the state directory exists to keep startup/status checks cheap.
for (const channelId of listPersistedAuthStateChannelIds(options)) {
if (hasPersistedAuthState({ channelId, cfg, env, options })) {
configuredChannelIds.add(channelId);
addSignal(channelId, "persisted-auth");
}
}
@@ -159,6 +159,35 @@ describe("collectChannelLegacyConfigRules", () => {
expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled();
});
it("keeps disabled channel migrations while excluding channel metadata and blank ids", () => {
loadBundledChannelDoctorContractApiMock.mockImplementation((channelId: string) => ({
legacyConfigRules: [
{
path: ["channels", channelId, "legacy"],
message: `legacy ${channelId} rule`,
},
],
}));
const rules = collectChannelLegacyConfigRules({
plugins: { enabled: false },
channels: {
defaults: {},
modelByChannel: { discord: "openai/gpt-5.6-luna" },
" ": {},
discord: { enabled: false, legacy: true },
},
});
expect(rules).toEqual([
{
path: ["channels", "discord", "legacy"],
message: "legacy discord rule",
},
]);
expect(loadBundledChannelDoctorContractApiMock).toHaveBeenCalledExactlyOnceWith("discord");
});
it("scopes channel legacy scans to touched channels during dry-run validation", () => {
loadBundledChannelDoctorContractApiMock.mockImplementation((channelId: string) => ({
legacyConfigRules: [
+6 -3
View File
@@ -6,6 +6,7 @@
import type { LegacyConfigRule } from "../../config/legacy.shared.js";
import type { OpenClawConfig } from "../../config/types.js";
import { listPluginDoctorLegacyConfigRules } from "../../plugins/doctor-contract-registry.js";
import { isChannelConfigMetadataKey } from "../config-metadata.js";
import { getBootstrapChannelPlugin } from "./bootstrap-registry.js";
import { loadBundledChannelDoctorContractApi } from "./doctor-contract-api.js";
import type { ChannelId } from "./types.public.js";
@@ -19,7 +20,8 @@ function collectConfiguredChannelIds(raw: unknown): ChannelId[] {
return [];
}
return Object.keys(channels)
.filter((channelId) => channelId !== "defaults")
.map((channelId) => channelId.trim())
.filter((channelId) => channelId && !isChannelConfigMetadataKey(channelId))
.map((channelId) => channelId as ChannelId);
}
@@ -65,12 +67,13 @@ function collectRelevantChannelIdsForTouchedPaths(params: {
if (!second) {
return filteredChannelIds;
}
if (second === "defaults") {
const channelId = second.trim();
if (!channelId || isChannelConfigMetadataKey(channelId)) {
continue;
}
// Channel ids are the second segment under channels.*; deeper touched paths
// still map back to the owning channel for rule collection.
touchedChannelIds.add(second as ChannelId);
touchedChannelIds.add(channelId as ChannelId);
}
if (touchedChannelIds.size === 0) {
@@ -1474,7 +1474,10 @@ describe("normalizeCompatibilityConfigValues", () => {
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.5"],
},
models: {
"anthropic/claude-opus-4-7": { alias: "Opus" },
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "auto", mode: "strict" },
},
},
},
},
@@ -1484,7 +1487,7 @@ describe("normalizeCompatibilityConfigValues", () => {
expect(res.config.agents?.defaults?.models).toEqual({
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "claude-cli" },
agentRuntime: { id: "claude-cli", mode: "strict" },
},
"anthropic/claude-sonnet-4-6": {
agentRuntime: { id: "claude-cli" },
@@ -140,6 +140,10 @@ describe("channel doctor compatibility mutations", () => {
defaults: {
enabled: true,
},
modelByChannel: {
discord: "openai/gpt-5.6-luna",
},
" ": { token: "dummy" },
},
} as never);
+7 -28
View File
@@ -14,6 +14,7 @@ import type {
} from "../../../channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { isUnresolvedSecretInputError } from "../../../config/types.secrets.js";
import { listDoctorConfiguredChannelIds } from "./configured-channel-ids.js";
type ChannelDoctorEntry = {
id: string;
@@ -65,34 +66,12 @@ 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
: null;
if (!channels) {
return [];
}
const channelEntries = channels as Record<string, unknown>;
return Object.keys(channels)
.filter((channelId) => {
if (channelId === "defaults") {
return false;
}
if (isChannelDoctorBlockedByConfig(channelId, cfg)) {
return false;
}
const entry = channelEntries[channelId];
return (
!entry ||
typeof entry !== "object" ||
Array.isArray(entry) ||
(entry as { enabled?: unknown }).enabled !== false
);
})
.toSorted();
return listDoctorConfiguredChannelIds(cfg, {
configEntryPolicy: "enabled",
skipWhenPluginsDisabled: true,
excludeExplicitlyDisabled: true,
sort: "codepoint",
}).filter((channelId) => !isChannelDoctorBlockedByConfig(channelId, cfg));
}
function isChannelDoctorBlockedByConfig(channelId: string, cfg: OpenClawConfig): boolean {
@@ -48,6 +48,23 @@ function firstMigrationCall() {
}
describe("bundled channel legacy config migrations", () => {
it("does not treat channel metadata or blank ids as channel plugins", () => {
collectRelevantDoctorPluginIds.mockReturnValue([]);
applyPluginDoctorCompatibilityMigrations.mockReturnValue({ config: {}, changes: [] });
applyChannelDoctorCompatibilityMigrations({
channels: {
defaults: {},
modelByChannel: { discord: "openai/gpt-5.6-luna" },
" ": {},
},
});
expect(loadBundledChannelDoctorContractApi).not.toHaveBeenCalled();
expect(getBootstrapChannelPlugin).not.toHaveBeenCalled();
expect(applyPluginDoctorCompatibilityMigrations).not.toHaveBeenCalled();
});
it("only renames heartbeat blocks that use the common visibility shape", () => {
collectRelevantDoctorPluginIds.mockReturnValue([]);
loadBundledChannelDoctorContractApi.mockReturnValue({
@@ -1,4 +1,6 @@
// Legacy config migration bridge for channel doctor compatibility contracts.
import { isChannelConfigMetadataKey } from "../../../channels/config-metadata.js";
import { getBootstrapChannelPlugin } from "../../../channels/plugins/bootstrap-registry.js";
import { loadBundledChannelDoctorContractApi } from "../../../channels/plugins/doctor-contract-api.js";
import type { OpenClawConfig } from "../../../config/types.js";
@@ -6,6 +8,7 @@ import {
applyPluginDoctorCompatibilityMigrations,
collectRelevantDoctorPluginIds,
} from "../../../plugins/doctor-contract-registry.js";
import { listDoctorConfiguredChannelIds } from "./configured-channel-ids.js";
import { isRecord } from "./legacy-config-record-shared.js";
type ChannelDoctorCompatibilityMutation = {
@@ -17,16 +20,6 @@ type ChannelDoctorCompatibilityNormalizer = (params: {
cfg: OpenClawConfig;
}) => ChannelDoctorCompatibilityMutation;
function collectRelevantDoctorChannelIds(raw: unknown): string[] {
const channels = isRecord(raw) && isRecord(raw.channels) ? raw.channels : null;
if (!channels) {
return [];
}
return Object.keys(channels)
.filter((channelId) => channelId !== "defaults")
.toSorted();
}
function migrateHeartbeatVisibility(raw: Record<string, unknown>, changes: string[]): void {
const channels = isRecord(raw.channels) ? raw.channels : null;
if (!channels) {
@@ -59,7 +52,7 @@ function migrateHeartbeatVisibility(raw: Record<string, unknown>, changes: strin
migrateEntry(defaults, "channels.defaults");
}
for (const [channelId, value] of Object.entries(channels)) {
if (channelId === "defaults" || !isRecord(value)) {
if (!channelId.trim() || isChannelConfigMetadataKey(channelId) || !isRecord(value)) {
continue;
}
const preserveEmptyPluginBlock = channelId === "feishu";
@@ -116,7 +109,10 @@ export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, un
migrateHeartbeatVisibility(cfg, changes);
const unresolvedChannelIds: string[] = [];
for (const channelId of collectRelevantDoctorChannelIds(cfg)) {
for (const channelId of listDoctorConfiguredChannelIds(cfg, {
configEntryPolicy: "raw",
sort: "codepoint",
})) {
const normalizeCompatibilityConfig = resolveBundledChannelCompatibilityNormalizer(channelId);
if (!normalizeCompatibilityConfig) {
unresolvedChannelIds.push(channelId);
@@ -0,0 +1,96 @@
// Canonical configured-channel enumeration for doctor flows with intentionally distinct policies.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { isChannelConfigMetadataKey } from "../../../channels/config-metadata.js";
import {
hasMeaningfulChannelConfig,
listExplicitlyDisabledChannelIdsForConfig,
listPotentialConfiguredChannelPresenceSignals,
} from "../../../channels/config-presence.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { isRecord } from "../../../utils.js";
type ConfiguredChannelEntryPolicy = "raw" | "enabled" | "meaningful" | "enabled-or-meaningful";
type DoctorConfiguredChannelIdOptions = {
configEntryPolicy: ConfiguredChannelEntryPolicy;
env?: NodeJS.ProcessEnv;
candidateChannelIds?: readonly string[];
skipWhenPluginsDisabled?: boolean;
excludeExplicitlyDisabled?: boolean;
mapEnvironmentChannelId?: (channelId: string) => string;
environmentChannelIsConfigured?: (channelId: string) => boolean;
sort?: "codepoint" | "locale";
};
function includesConfigEntry(value: unknown, policy: ConfiguredChannelEntryPolicy): boolean {
if (policy === "raw") {
return true;
}
if (policy === "enabled") {
return !isRecord(value) || value.enabled !== false;
}
const meaningful = hasMeaningfulChannelConfig(value);
return policy === "meaningful"
? meaningful
: (isRecord(value) && value.enabled === true) || meaningful;
}
/** Lists configured channel ids while leaving caller-specific activation policy at the caller. */
export function listDoctorConfiguredChannelIds(
config: unknown,
options: DoctorConfiguredChannelIdOptions,
): string[] {
const root = isRecord(config) ? config : {};
const cfg = root as OpenClawConfig;
if (options.skipWhenPluginsDisabled && isRecord(root.plugins) && root.plugins.enabled === false) {
return [];
}
const disabledIds = options.excludeExplicitlyDisabled
? new Set(listExplicitlyDisabledChannelIdsForConfig(cfg))
: null;
const ids = new Set<string>();
const add = (rawChannelId: string) => {
const channelId = rawChannelId.trim();
const normalized = normalizeOptionalLowercaseString(channelId);
if (
!channelId ||
isChannelConfigMetadataKey(channelId) ||
(normalized && disabledIds?.has(normalized))
) {
return;
}
ids.add(channelId);
};
const channels = isRecord(root.channels) ? root.channels : null;
if (channels) {
for (const [channelId, entry] of Object.entries(channels)) {
if (includesConfigEntry(entry, options.configEntryPolicy)) {
add(channelId);
}
}
}
if (options.env) {
for (const signal of listPotentialConfiguredChannelPresenceSignals(cfg, options.env, {
channelIds: options.candidateChannelIds,
includePersistedAuthState: false,
})) {
if (signal.source !== "env") {
continue;
}
const channelId = options.mapEnvironmentChannelId?.(signal.channelId) ?? signal.channelId;
if (options.environmentChannelIsConfigured?.(channelId) === false) {
continue;
}
add(channelId);
}
}
const result = [...ids];
if (options.sort === "locale") {
return result.toSorted((left, right) => left.localeCompare(right));
}
return options.sort === "codepoint" ? result.toSorted() : result;
}
@@ -508,10 +508,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
introduced: "2026-04-26",
removeAfter: "2026-07-26",
source: "tools.web.search.apiKey and tools.web.search.<provider>",
migration: "src/commands/doctor/shared/legacy-web-search-migrate.ts",
migration: "src/commands/doctor/shared/legacy-web-tools-migrate.ts",
replacement: "plugins.entries.<plugin>.config.webSearch",
docsPath: "/tools/web",
tests: ["src/commands/doctor/shared/legacy-web-search-migrate.test.ts"],
tests: ["src/commands/doctor/shared/legacy-web-tools-migrate.test.ts"],
notes:
"Provider/plugin ownership can move as bundled providers externalize; verify the current manifest owner before deleting migration support.",
}),
@@ -521,10 +521,10 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
introduced: "2026-04-26",
removeAfter: "2026-07-26",
source: "tools.web.fetch.firecrawl",
migration: "src/commands/doctor/shared/legacy-web-fetch-migrate.ts",
migration: "src/commands/doctor/shared/legacy-web-tools-migrate.ts",
replacement: "plugins.entries.firecrawl.config.webFetch",
docsPath: "/tools/web-fetch",
tests: ["src/commands/doctor/shared/legacy-web-fetch-migrate.test.ts"],
tests: ["src/commands/doctor/shared/legacy-web-tools-migrate.test.ts"],
}),
deprecatedCompatRecord("doctor-x-search-plugin-config", {
status: "removal-pending",
@@ -532,11 +532,11 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
introduced: "2026-04-26",
removeAfter: "2026-07-26",
source: "tools.web.x_search.apiKey",
migration: "src/commands/doctor/shared/legacy-x-search-migrate.ts",
migration: "src/commands/doctor/shared/legacy-web-tools-migrate.ts",
replacement: "plugins.entries.xai.config.webSearch.apiKey",
docsPath: "/tools/grok-search",
tests: [
"src/commands/doctor/shared/legacy-x-search-migrate.test.ts",
"src/commands/doctor/shared/legacy-web-tools-migrate.test.ts",
"src/commands/doctor/shared/legacy-config-migrate.test.ts",
],
}),
@@ -11,9 +11,11 @@ import {
normalizeLegacyTalkConfig,
seedMissingDefaultAccountsFromSingleAccountBase,
} from "./legacy-config-core-normalizers.js";
import { migrateLegacyWebFetchConfig } from "./legacy-web-fetch-migrate.js";
import { migrateLegacyWebSearchConfig } from "./legacy-web-search-migrate.js";
import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js";
import {
migrateLegacyWebFetchConfig,
migrateLegacyWebSearchConfig,
migrateLegacyXSearchConfig,
} from "./legacy-web-tools-migrate.js";
/** Run common compatibility migrations before caller-specific setup/channel passes. */
export function normalizeBaseCompatibilityConfigValues(
@@ -18,6 +18,10 @@ import {
} from "./codex-route-model-ref.js";
import { hasOwnKey, isRecord } from "./legacy-config-record-shared.js";
import { isLegacyModelsAddCodexMetadataModel } from "./legacy-models-add-metadata.js";
import {
modelEntryWithRuntimePolicy,
selectedCanonicalModelRefsForRuntimePolicy,
} from "./legacy-runtime-model-policy.js";
import {
legacyRuntimeModelAliasRequiresRuntimePolicy,
listLegacyRuntimeModelProviderAliases,
@@ -390,20 +394,6 @@ function runtimeNeedsExplicitModelPolicy(runtime: string | undefined): runtime i
return Boolean(runtime && runtime !== "codex");
}
function modelEntryWithRuntimePolicy(entry: unknown, runtime: string): Record<string, unknown> {
const base = isRecord(entry) ? { ...entry } : {};
const currentRuntime = isRecord(base.agentRuntime)
? normalizeOptionalLowercaseString(base.agentRuntime.id)
: undefined;
if (!currentRuntime || currentRuntime === "auto") {
base.agentRuntime = {
...(isRecord(base.agentRuntime) ? base.agentRuntime : {}),
id: runtime,
};
}
return base;
}
function mergeModelEntryWithRuntimePolicy(
legacyEntry: unknown,
currentEntry: unknown,
@@ -411,7 +401,9 @@ function mergeModelEntryWithRuntimePolicy(
requiresRuntimePolicy = runtimeNeedsExplicitModelPolicy(runtime),
): unknown {
const merged = mergeModelEntry(legacyEntry, currentEntry);
return runtime && requiresRuntimePolicy ? modelEntryWithRuntimePolicy(merged, runtime) : merged;
return runtime && requiresRuntimePolicy
? modelEntryWithRuntimePolicy(merged, runtime).entry
: merged;
}
function normalizeLegacyRuntimeAllowlistModels(
@@ -485,52 +477,15 @@ function ensureSelectedModelRuntimePolicies(
}
const current = next[ref];
const updated = modelEntryWithRuntimePolicy(current, runtime);
if (JSON.stringify(updated) !== JSON.stringify(current ?? {})) {
next[ref] = updated;
changed = true;
if (!updated.changed) {
continue;
}
next[ref] = updated.entry;
changed = true;
}
return { value: next, changed };
}
function selectedCanonicalModelRefsForRuntimePolicy(
rawModel: unknown,
provider: string,
runtime: string,
requiresRuntimePolicy: boolean,
): SelectedRuntimeRef[] {
const refs: SelectedRuntimeRef[] = [];
const addRef = (rawRef: unknown) => {
if (typeof rawRef !== "string") {
return;
}
const trimmed = rawRef.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash >= trimmed.length - 1) {
return;
}
if (normalizeProviderId(trimmed.slice(0, slash)) !== normalizeProviderId(provider)) {
return;
}
refs.push({ ref: trimmed, runtime, requiresRuntimePolicy });
};
if (typeof rawModel === "string") {
addRef(rawModel);
return refs;
}
if (!isRecord(rawModel)) {
return refs;
}
addRef(rawModel.primary);
if (Array.isArray(rawModel.fallbacks)) {
for (const fallback of rawModel.fallbacks) {
addRef(fallback);
}
}
return refs;
}
function normalizeLegacyCodexCliRuntimePinsInModels(
rawModels: unknown,
path: string,
@@ -602,12 +557,14 @@ function normalizeLegacyRuntimeAgentContainer(
}
if (legacyWholeAgentRuntime) {
const selectedRefs = selectedCanonicalModelRefsForRuntimePolicy(
const selectedRefs: SelectedRuntimeRef[] = selectedCanonicalModelRefsForRuntimePolicy(
next.model ?? raw.model,
legacyWholeAgentRuntime.provider,
legacyWholeAgentRuntime.runtime,
legacyWholeAgentRuntime.requiresRuntimePolicy,
);
).map((ref) => ({
ref,
runtime: legacyWholeAgentRuntime.runtime,
requiresRuntimePolicy: legacyWholeAgentRuntime.requiresRuntimePolicy,
}));
const modelRuntimes = ensureSelectedModelRuntimePolicies(next.models, selectedRefs);
if (modelRuntimes.changed) {
next.models = modelRuntimes.value;
@@ -14,16 +14,10 @@ import {
listPluginDoctorLegacyConfigRules,
} from "../../../plugins/doctor-contract-registry.js";
import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js";
import { listDoctorConfiguredChannelIds } from "./configured-channel-ids.js";
function collectConfiguredChannelIds(raw: unknown): ReadonlySet<string> {
if (!raw || typeof raw !== "object") {
return new Set();
}
const channels = (raw as { channels?: unknown }).channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return new Set();
}
return new Set(Object.keys(channels).filter((channelId) => channelId !== "defaults"));
return new Set(listDoctorConfiguredChannelIds(raw, { configEntryPolicy: "raw" }));
}
function collectPluginLegacyConfigRules(
@@ -2299,7 +2299,10 @@ describe("legacy migrate sandbox scope aliases", () => {
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.5"],
},
models: {
"anthropic/claude-opus-4-7": { alias: "Opus" },
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "auto", mode: "strict" },
},
},
},
list: [
@@ -2327,7 +2330,7 @@ describe("legacy migrate sandbox scope aliases", () => {
models: {
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "claude-cli" },
agentRuntime: { id: "claude-cli", mode: "strict" },
},
"anthropic/claude-sonnet-4-6": {
agentRuntime: { id: "claude-cli" },
@@ -20,6 +20,10 @@ import {
type LegacyConfigRule,
} from "../../../config/legacy.shared.js";
import { isBlockedObjectKey } from "../../../infra/prototype-keys.js";
import {
modelEntryWithRuntimePolicy,
selectedCanonicalModelRefsForRuntimePolicy,
} from "./legacy-runtime-model-policy.js";
import { listLegacyRuntimeModelProviderAliases } from "./legacy-runtime-model-providers.js";
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
@@ -708,61 +712,6 @@ function resolveLegacyAgentRuntimeIntent(raw: unknown): LegacyAgentRuntimeIntent
return alias ? { provider: alias.provider, runtime: alias.runtime } : undefined;
}
function selectedCanonicalModelRefsForRuntimePolicy(rawModel: unknown, provider: string): string[] {
const refs: string[] = [];
const addRef = (rawRef: unknown) => {
if (typeof rawRef !== "string") {
return;
}
const trimmed = rawRef.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash >= trimmed.length - 1) {
return;
}
if (normalizeProviderId(trimmed.slice(0, slash)) !== normalizeProviderId(provider)) {
return;
}
refs.push(trimmed);
};
if (typeof rawModel === "string") {
addRef(rawModel);
return refs;
}
const model = getRecord(rawModel);
if (!model) {
return refs;
}
addRef(model.primary);
if (Array.isArray(model.fallbacks)) {
for (const fallback of model.fallbacks) {
addRef(fallback);
}
}
return refs;
}
function modelEntryWithRuntimePolicy(
entry: unknown,
runtime: string,
): {
changed: boolean;
entry: Record<string, unknown>;
} {
const base = getRecord(entry) ? { ...(entry as Record<string, unknown>) } : {};
const currentRuntime = getRecord(base.agentRuntime);
const currentRuntimeId =
typeof currentRuntime?.id === "string" ? currentRuntime.id.trim().toLowerCase() : "";
if (currentRuntimeId && currentRuntimeId !== "auto") {
return { changed: false, entry: base };
}
base.agentRuntime = {
...currentRuntime,
id: runtime,
};
return { changed: true, entry: base };
}
function preserveLegacyWholeAgentRuntimePolicy(
container: Record<string, unknown>,
pathLabel: string,
@@ -9,7 +9,7 @@ import { isRecord } from "./legacy-config-record-shared.js";
import {
migrateLegacyXSearchConfig,
resolveLegacyXSearchModelTarget,
} from "./legacy-x-search-migrate.js";
} from "./legacy-web-tools-migrate.js";
const LEGACY_OPENAI_CODEX_PLUGIN_ID = "openai-codex";
const OPENAI_PLUGIN_ID = "openai";
@@ -7,7 +7,7 @@ import {
import {
listLegacyWebSearchConfigPaths,
migrateLegacyWebSearchConfig,
} from "./legacy-web-search-migrate.js";
} from "./legacy-web-tools-migrate.js";
const LEGACY_WEB_SEARCH_RULES: LegacyConfigRule[] = [
{
@@ -11,6 +11,7 @@ import { LEGACY_OAUTH_REF_PROVIDER } from "../../../agents/auth-profiles/legacy-
import type { LegacyOAuthRef } from "../../../agents/auth-profiles/legacy-oauth-ref.js";
import { resolveOAuthDir, resolveStateDir } from "../../../config/paths.js";
import { loadJsonFile } from "../../../infra/json-file.js";
import { isPathInside } from "../../../infra/path-safety.js";
export { isLegacyOAuthRef } from "../../../agents/auth-profiles/legacy-oauth-ref.js";
export type { LegacyOAuthRef } from "../../../agents/auth-profiles/legacy-oauth-ref.js";
@@ -144,13 +145,6 @@ function encryptLegacyOAuthMaterialForTest(params: {
};
}
function isPathInsideOrEqual(parentDir: string, candidatePath: string): boolean {
const relative = path.relative(path.resolve(parentDir), path.resolve(candidatePath));
return (
relative === "" || (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function uniquePaths(paths: Array<string | undefined>): string[] {
return uniqueStrings(paths.filter((entry): entry is string => Boolean(entry)));
}
@@ -198,7 +192,7 @@ function resolveLegacyOAuthSecretKeyFileCandidates(env: NodeJS.ProcessEnv): stri
function resolveLegacyOAuthSecretKeyFilePath(env: NodeJS.ProcessEnv): string | undefined {
const stateDir = resolveStateDir(env);
return resolveLegacyOAuthSecretKeyFileCandidates(env).find(
(candidate) => !isPathInsideOrEqual(stateDir, candidate),
(candidate) => !isPathInside(stateDir, candidate),
);
}
@@ -0,0 +1,57 @@
// Shared legacy runtime policy projection for selected canonical model refs.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { isRecord } from "./legacy-config-record-shared.js";
/** Select canonical refs owned by a provider, preserving config order and duplicates. */
export function selectedCanonicalModelRefsForRuntimePolicy(
rawModel: unknown,
provider: string,
): string[] {
const refs: string[] = [];
const addRef = (rawRef: unknown) => {
if (typeof rawRef !== "string") {
return;
}
const ref = rawRef.trim();
const slash = ref.indexOf("/");
if (
slash <= 0 ||
slash >= ref.length - 1 ||
normalizeProviderId(ref.slice(0, slash)) !== normalizeProviderId(provider)
) {
return;
}
refs.push(ref);
};
if (typeof rawModel === "string") {
addRef(rawModel);
return refs;
}
if (!isRecord(rawModel)) {
return refs;
}
addRef(rawModel.primary);
if (Array.isArray(rawModel.fallbacks)) {
for (const fallback of rawModel.fallbacks) {
addRef(fallback);
}
}
return refs;
}
/** Add runtime policy unless the model entry already selects an explicit non-auto runtime. */
export function modelEntryWithRuntimePolicy(
entry: unknown,
runtime: string,
): { changed: boolean; entry: Record<string, unknown> } {
const next = isRecord(entry) ? { ...entry } : {};
const currentRuntime = isRecord(next.agentRuntime) ? next.agentRuntime : undefined;
const currentRuntimeId = normalizeOptionalLowercaseString(currentRuntime?.id);
if (currentRuntimeId && currentRuntimeId !== "auto") {
return { changed: false, entry: next };
}
next.agentRuntime = { ...currentRuntime, id: runtime };
return { changed: true, entry: next };
}
@@ -1,67 +0,0 @@
// Legacy web-fetch migration tests cover doctor repair of old web fetch config.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { migrateLegacyWebFetchConfig } from "./legacy-web-fetch-migrate.js";
describe("legacy web fetch config", () => {
it("migrates legacy Firecrawl fetch config into plugin-owned config", () => {
const res = migrateLegacyWebFetchConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
timeoutSeconds: 15,
firecrawl: {
apiKey: "firecrawl-key",
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: false,
},
},
},
},
} as OpenClawConfig);
expect(res.config.tools?.web?.fetch).toEqual({
provider: "firecrawl",
timeoutSeconds: 15,
});
expect(res.config.plugins?.entries?.firecrawl).toEqual({
enabled: true,
config: {
webFetch: {
apiKey: "firecrawl-key",
baseUrl: "https://api.firecrawl.dev",
onlyMainContent: false,
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.fetch.firecrawl → plugins.entries.firecrawl.config.webFetch.",
]);
});
it("drops legacy firecrawl.enabled when migrating plugin-owned config", () => {
const res = migrateLegacyWebFetchConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
firecrawl: {
enabled: false,
apiKey: "firecrawl-key",
},
},
},
},
} as OpenClawConfig);
expect(res.config.plugins?.entries?.firecrawl).toEqual({
enabled: true,
config: {
webFetch: {
apiKey: "firecrawl-key",
},
},
});
});
});
@@ -1,126 +0,0 @@
// Legacy web-fetch config migration from tools.web.fetch to plugin-owned config.
import { mergeMissing } from "../../../config/legacy.shared.js";
import {
cloneRecord,
ensureRecord,
hasOwnKey,
isRecord,
type JsonRecord,
} from "./legacy-config-record-shared.js";
const DANGEROUS_RECORD_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function resolveLegacyFetchConfig(raw: unknown): JsonRecord | undefined {
if (!isRecord(raw)) {
return undefined;
}
const tools = isRecord(raw.tools) ? raw.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
return isRecord(web?.fetch) ? web.fetch : undefined;
}
function copyLegacyFirecrawlFetchConfig(fetch: JsonRecord): JsonRecord | undefined {
const current = fetch.firecrawl;
if (!isRecord(current)) {
return undefined;
}
const next = cloneRecord(current);
delete next.enabled;
return next;
}
function hasMappedLegacyWebFetchConfig(raw: unknown): boolean {
const fetch = resolveLegacyFetchConfig(raw);
if (!fetch) {
return false;
}
return isRecord(fetch.firecrawl);
}
function migratePluginWebFetchConfig(params: {
root: JsonRecord;
payload: JsonRecord;
changes: string[];
}) {
const plugins = ensureRecord(params.root, "plugins");
const entries = ensureRecord(plugins, "entries");
const entry = ensureRecord(entries, "firecrawl");
const config = ensureRecord(entry, "config");
const hadEnabled = entry.enabled !== undefined;
const existing = isRecord(config.webFetch) ? cloneRecord(config.webFetch) : undefined;
if (!hadEnabled) {
entry.enabled = true;
}
if (!existing) {
config.webFetch = cloneRecord(params.payload);
params.changes.push(
"Moved tools.web.fetch.firecrawl → plugins.entries.firecrawl.config.webFetch.",
);
return;
}
const merged = cloneRecord(existing);
mergeMissing(merged, params.payload);
const changed = JSON.stringify(merged) !== JSON.stringify(existing) || !hadEnabled;
config.webFetch = merged;
if (changed) {
params.changes.push(
"Merged tools.web.fetch.firecrawl → plugins.entries.firecrawl.config.webFetch (filled missing fields from legacy; kept explicit plugin config values).",
);
return;
}
params.changes.push(
"Removed tools.web.fetch.firecrawl (plugins.entries.firecrawl.config.webFetch already set).",
);
}
/** Move legacy Firecrawl web-fetch config into plugins.entries.firecrawl.config.webFetch. */
export function migrateLegacyWebFetchConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw) || !hasMappedLegacyWebFetchConfig(raw)) {
return { config: raw, changes: [] };
}
return normalizeLegacyWebFetchConfigRecord(raw);
}
function normalizeLegacyWebFetchConfigRecord<T extends JsonRecord>(
raw: T,
): {
config: T;
changes: string[];
} {
const nextRoot = structuredClone(raw);
const tools = ensureRecord(nextRoot, "tools");
const web = ensureRecord(tools, "web");
const fetch = resolveLegacyFetchConfig(nextRoot);
if (!fetch) {
return { config: raw, changes: [] };
}
const nextFetch: JsonRecord = {};
for (const [key, value] of Object.entries(fetch)) {
if (key === "firecrawl" && isRecord(value)) {
continue;
}
if (DANGEROUS_RECORD_KEYS.has(key)) {
continue;
}
nextFetch[key] = value;
}
web.fetch = nextFetch;
const firecrawl = copyLegacyFirecrawlFetchConfig(fetch);
const changes: string[] = [];
if (firecrawl && Object.keys(firecrawl).length > 0) {
migratePluginWebFetchConfig({
root: nextRoot,
payload: firecrawl,
changes,
});
} else if (hasOwnKey(fetch, "firecrawl")) {
changes.push("Removed empty tools.web.fetch.firecrawl.");
}
return { config: nextRoot, changes };
}
@@ -1,221 +0,0 @@
// Legacy web-search migration tests cover doctor repair of old web search config.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import {
listLegacyWebSearchConfigPaths,
migrateLegacyWebSearchConfig,
} from "./legacy-web-search-migrate.js";
type LegacyWebSearchConfig = Omit<OpenClawConfig, "tools"> & {
tools?: {
web?: {
search?: Record<string, unknown>;
};
};
};
describe("legacy web search config", () => {
it("migrates legacy provider config through bundled web search ownership metadata", () => {
const res = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: {
web: {
search: {
provider: "grok",
apiKey: "brave-key",
grok: {
apiKey: "xai-key",
model: "grok-4-1-fast",
},
kimi: {
apiKey: "kimi-key",
model: "kimi-k2.5",
},
},
},
},
});
expect(res.config.tools?.web?.search).toEqual({
provider: "grok",
});
expect(res.config.plugins?.entries?.brave).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "brave-key",
},
},
});
expect(res.config.plugins?.entries?.xai).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "xai-key",
model: "grok-4.3",
},
},
});
expect(res.config.plugins?.entries?.moonshot).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "kimi-key",
model: "kimi-k2.5",
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.search.apiKey → plugins.entries.brave.config.webSearch.apiKey.",
'Updated tools.web.search.grok.model from "grok-4-1-fast" to "grok-4.3".',
"Moved tools.web.search.grok → plugins.entries.xai.config.webSearch.",
"Moved tools.web.search.kimi → plugins.entries.moonshot.config.webSearch.",
]);
});
it("repairs retired Grok code aliases while preserving current aliases", () => {
const retired = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: {
web: {
search: { grok: { model: "grok-code-fast-1" } },
},
},
});
const current = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: {
web: {
search: { grok: { model: "grok-latest" } },
},
},
});
expect(retired.config.plugins?.entries?.xai?.config?.webSearch).toEqual({
model: "grok-build-0.1",
});
expect(retired.changes).toEqual([
'Updated tools.web.search.grok.model from "grok-code-fast-1" to "grok-build-0.1".',
"Moved tools.web.search.grok → plugins.entries.xai.config.webSearch.",
]);
expect(current.config.plugins?.entries?.xai?.config?.webSearch).toEqual({
model: "grok-latest",
});
});
it("does not mutate the caller's original config", () => {
const input = {
tools: {
web: {
search: {
provider: "grok",
apiKey: "brave-key",
grok: {
apiKey: "xai-key",
model: "grok-4-search",
},
},
},
},
} satisfies LegacyWebSearchConfig;
const original = structuredClone(input);
const res = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>(input);
expect(res.config.plugins?.entries?.xai?.config?.webSearch).toEqual({
apiKey: "xai-key",
model: "grok-4-search",
});
expect(input).toEqual(original);
});
it("preserves unrelated record-valued web search config", () => {
const res = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: {
web: {
search: {
apiKey: "brave-key",
customSearch: {
endpoint: "https://search.example.test",
mode: "strict",
},
openaiCodex: {
enabled: true,
},
},
},
},
});
expect(res.config.tools?.web?.search).toEqual({
customSearch: {
endpoint: "https://search.example.test",
mode: "strict",
},
openaiCodex: {
enabled: true,
},
});
expect(res.config.plugins?.entries?.brave).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "brave-key",
},
},
});
});
it("drops dangerous record keys while preserving unrelated web search config", () => {
const res = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: {
web: {
search: {
apiKey: "brave-key",
["__proto__"]: {
polluted: true,
},
constructor: {
polluted: true,
},
customSearch: {
endpoint: "https://search.example.test",
},
prototype: {
polluted: true,
},
},
},
},
});
expect(res.config.tools?.web?.search).toEqual({
customSearch: {
endpoint: "https://search.example.test",
},
});
});
it("lists legacy paths for metadata-owned provider config", () => {
expect(
listLegacyWebSearchConfigPaths({
tools: {
web: {
search: {
apiKey: "brave-key",
grok: {
apiKey: "xai-key",
model: "grok-4-search",
},
kimi: {
model: "kimi-k2.5",
},
},
},
},
}),
).toEqual([
"tools.web.search.apiKey",
"tools.web.search.grok.apiKey",
"tools.web.search.grok.model",
"tools.web.search.kimi.model",
]);
});
});
@@ -1,299 +0,0 @@
// Legacy web-search config migration from tools.web.search to plugin-owned configs.
import { mergeMissing } from "../../../config/legacy.shared.js";
import {
cloneRecord,
ensureRecord,
hasOwnKey,
isRecord,
type JsonRecord,
} from "./legacy-config-record-shared.js";
const DANGEROUS_RECORD_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const BUNDLED_LEGACY_WEB_SEARCH_OWNERS = new Map<string, string>([
["brave", "brave"],
["duckduckgo", "duckduckgo"],
["exa", "exa"],
["firecrawl", "firecrawl"],
["firecrawl-free", "firecrawl"],
["gemini", "google"],
["grok", "xai"],
["kimi", "moonshot"],
["minimax", "minimax"],
["ollama", "ollama"],
["parallel", "parallel"],
["parallel-free", "parallel"],
["perplexity", "perplexity"],
["searxng", "searxng"],
["tavily", "tavily"],
]);
// Tavily, Parallel (paid + free), and Firecrawl free only ever used the plugin-owned
// config path, so there is no legacy `tools.web.search.<id>.*` shape to migrate for them.
const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set([
"firecrawl-free",
"parallel",
"parallel-free",
"tavily",
]);
const LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID = "brave";
const RETIRED_GROK_WEB_SEARCH_MODELS = new Set([
"grok-4-1-fast",
"grok-4-1-fast-reasoning",
"grok-4-fast",
"grok-4-fast-reasoning",
"grok-4-0709",
]);
const RETIRED_GROK_CODE_MODELS = new Set([
"grok-code-fast-1",
"grok-code-fast",
"grok-code-fast-1-0825",
]);
function resolveLegacyGrokWebSearchModelTarget(model: unknown): string | undefined {
if (typeof model !== "string") {
return undefined;
}
const normalized = model.trim().toLowerCase();
if (RETIRED_GROK_WEB_SEARCH_MODELS.has(normalized)) {
return "grok-4.3";
}
if (RETIRED_GROK_CODE_MODELS.has(normalized)) {
return "grok-build-0.1";
}
return undefined;
}
function getBundledLegacyWebSearchOwners(): ReadonlyMap<string, string> {
return BUNDLED_LEGACY_WEB_SEARCH_OWNERS;
}
function getLegacyWebSearchProviderIds(
owners: ReadonlyMap<string, string> = getBundledLegacyWebSearchOwners(),
): string[] {
return [...owners.keys()]
.filter((providerId) => !NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS.has(providerId))
.toSorted((left, right) => left.localeCompare(right));
}
function getLegacyWebSearchProviderIdSet(owners: ReadonlyMap<string, string>): Set<string> {
return new Set(getLegacyWebSearchProviderIds(owners));
}
function resolveLegacySearchConfig(raw: unknown): JsonRecord | undefined {
if (!isRecord(raw)) {
return undefined;
}
const tools = isRecord(raw.tools) ? raw.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
return isRecord(web?.search) ? web.search : undefined;
}
function copyLegacyProviderConfig(search: JsonRecord, providerKey: string): JsonRecord | undefined {
const current = search[providerKey];
return isRecord(current) ? cloneRecord(current) : undefined;
}
function hasMappedLegacyWebSearchConfig(
raw: unknown,
owners: ReadonlyMap<string, string>,
): boolean {
const search = resolveLegacySearchConfig(raw);
if (!search) {
return false;
}
if (hasOwnKey(search, "apiKey")) {
return true;
}
return getLegacyWebSearchProviderIds(owners).some((providerId) => isRecord(search[providerId]));
}
function resolveLegacyGlobalWebSearchMigration(
search: JsonRecord,
owners: ReadonlyMap<string, string>,
): {
pluginId: string;
payload: JsonRecord;
legacyPath: string;
targetPath: string;
} | null {
const legacyProviderConfig = copyLegacyProviderConfig(
search,
LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID,
);
const payload = legacyProviderConfig ?? {};
const hasLegacyApiKey = hasOwnKey(search, "apiKey");
if (hasLegacyApiKey) {
payload.apiKey = search.apiKey;
}
if (Object.keys(payload).length === 0) {
return null;
}
const pluginId =
owners.get(LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID) ?? LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID;
return {
pluginId,
payload,
legacyPath: hasLegacyApiKey
? "tools.web.search.apiKey"
: `tools.web.search.${LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID}`,
targetPath:
hasLegacyApiKey && !legacyProviderConfig
? `plugins.entries.${pluginId}.config.webSearch.apiKey`
: `plugins.entries.${pluginId}.config.webSearch`,
};
}
function migratePluginWebSearchConfig(params: {
root: JsonRecord;
legacyPath: string;
targetPath: string;
pluginId: string;
payload: JsonRecord;
changes: string[];
}) {
const plugins = ensureRecord(params.root, "plugins");
const entries = ensureRecord(plugins, "entries");
const entry = ensureRecord(entries, params.pluginId);
const config = ensureRecord(entry, "config");
const hadEnabled = entry.enabled !== undefined;
const existing = isRecord(config.webSearch) ? cloneRecord(config.webSearch) : undefined;
if (!hadEnabled) {
entry.enabled = true;
}
if (!existing) {
config.webSearch = cloneRecord(params.payload);
params.changes.push(`Moved ${params.legacyPath}${params.targetPath}.`);
return;
}
const merged = cloneRecord(existing);
mergeMissing(merged, params.payload);
const changed = JSON.stringify(merged) !== JSON.stringify(existing) || !hadEnabled;
config.webSearch = merged;
if (changed) {
params.changes.push(
`Merged ${params.legacyPath}${params.targetPath} (filled missing fields from legacy; kept explicit plugin config values).`,
);
return;
}
params.changes.push(`Removed ${params.legacyPath} (${params.targetPath} already set).`);
}
/** List legacy tools.web.search provider config paths present in raw config. */
export function listLegacyWebSearchConfigPaths(raw: unknown): string[] {
const owners = getBundledLegacyWebSearchOwners();
const search = resolveLegacySearchConfig(raw);
if (!search) {
return [];
}
const paths: string[] = [];
if ("apiKey" in search) {
paths.push("tools.web.search.apiKey");
}
for (const providerId of getLegacyWebSearchProviderIds(owners)) {
const scoped = search[providerId];
if (isRecord(scoped)) {
for (const key of Object.keys(scoped)) {
paths.push(`tools.web.search.${providerId}.${key}`);
}
}
}
return paths;
}
/** Move legacy web-search provider config into provider plugin entries. */
export function migrateLegacyWebSearchConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw)) {
return { config: raw, changes: [] };
}
const owners = getBundledLegacyWebSearchOwners();
if (!hasMappedLegacyWebSearchConfig(raw, owners)) {
return { config: raw, changes: [] };
}
return normalizeLegacyWebSearchConfigRecord(structuredClone(raw) as T & JsonRecord, owners);
}
function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
raw: T,
owners: ReadonlyMap<string, string>,
): {
config: T;
changes: string[];
} {
const nextRoot = cloneRecord(raw);
const tools = ensureRecord(nextRoot, "tools");
const web = ensureRecord(tools, "web");
const search = resolveLegacySearchConfig(nextRoot);
if (!search) {
return { config: raw, changes: [] };
}
const nextSearch: JsonRecord = {};
const changes: string[] = [];
for (const [key, value] of Object.entries(search)) {
if (key === "apiKey") {
continue;
}
if (getLegacyWebSearchProviderIdSet(owners).has(key) && isRecord(value)) {
continue;
}
if (DANGEROUS_RECORD_KEYS.has(key)) {
continue;
}
nextSearch[key] = value;
}
web.search = nextSearch;
const globalSearchMigration = resolveLegacyGlobalWebSearchMigration(search, owners);
if (globalSearchMigration) {
migratePluginWebSearchConfig({
root: nextRoot,
legacyPath: globalSearchMigration.legacyPath,
targetPath: globalSearchMigration.targetPath,
pluginId: globalSearchMigration.pluginId,
payload: globalSearchMigration.payload,
changes,
});
}
for (const providerId of getLegacyWebSearchProviderIds(owners)) {
if (providerId === LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID) {
continue;
}
const scoped = copyLegacyProviderConfig(search, providerId);
if (!scoped || Object.keys(scoped).length === 0) {
continue;
}
const pluginId = owners.get(providerId);
if (!pluginId) {
continue;
}
if (providerId === "grok") {
const targetModel = resolveLegacyGrokWebSearchModelTarget(scoped.model);
if (targetModel) {
const previousModel = scoped.model;
scoped.model = targetModel;
changes.push(
`Updated tools.web.search.grok.model from ${JSON.stringify(previousModel)} to ${JSON.stringify(targetModel)}.`,
);
}
}
migratePluginWebSearchConfig({
root: nextRoot,
legacyPath: `tools.web.search.${providerId}`,
targetPath: `plugins.entries.${pluginId}.config.webSearch`,
pluginId,
payload: scoped,
changes,
});
}
return { config: nextRoot, changes };
}
@@ -0,0 +1,262 @@
// Legacy web tool migration tests cover provider-owned search and fetch config repair.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import {
listLegacyWebSearchConfigPaths,
migrateLegacyWebFetchConfig,
migrateLegacyWebSearchConfig,
migrateLegacyXSearchConfig,
} from "./legacy-web-tools-migrate.js";
type LegacyWebSearchConfig = Omit<OpenClawConfig, "tools"> & {
tools?: { web?: { search?: Record<string, unknown> } };
};
describe("legacy web search config", () => {
it("migrates provider config in deterministic owner order", () => {
const result = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: {
web: {
search: {
provider: "grok",
apiKey: "test-key",
grok: { apiKey: "test-secret", model: "grok-4-1-fast" },
kimi: { apiKey: "sample", model: "kimi-k2.5" },
},
},
},
});
expect(result.config.tools?.web?.search).toEqual({ provider: "grok" });
expect(result.config.plugins?.entries?.brave?.config?.webSearch).toEqual({
apiKey: "test-key",
});
expect(result.config.plugins?.entries?.xai?.config?.webSearch).toEqual({
apiKey: "test-secret",
model: "grok-4.3",
});
expect(result.config.plugins?.entries?.moonshot?.config?.webSearch).toEqual({
apiKey: "sample",
model: "kimi-k2.5",
});
expect(result.changes).toEqual([
"Moved tools.web.search.apiKey → plugins.entries.brave.config.webSearch.apiKey.",
'Updated tools.web.search.grok.model from "grok-4-1-fast" to "grok-4.3".',
"Moved tools.web.search.grok → plugins.entries.xai.config.webSearch.",
"Moved tools.web.search.kimi → plugins.entries.moonshot.config.webSearch.",
]);
});
it("repairs retired Grok code aliases while preserving current aliases", () => {
const retired = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: { web: { search: { grok: { model: "grok-code-fast-1" } } } },
});
const current = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: { web: { search: { grok: { model: "grok-latest" } } } },
});
expect(retired.config.plugins?.entries?.xai?.config?.webSearch).toEqual({
model: "grok-build-0.1",
});
expect(retired.changes[0]).toContain("grok-build-0.1");
expect(current.config.plugins?.entries?.xai?.config?.webSearch).toEqual({
model: "grok-latest",
});
});
it("gives global auth precedence over Brave source while recursively preserving plugin config", () => {
const result = migrateLegacyWebSearchConfig<LegacyWebSearchConfig>({
tools: {
web: {
search: {
apiKey: "example",
brave: {
apiKey: "test-key",
nested: { fromLegacy: true, shared: "legacy" },
},
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: { webSearch: { nested: { shared: "plugin" } } },
},
},
},
});
expect(result.config.plugins?.entries?.brave?.config?.webSearch).toEqual({
apiKey: "example",
nested: { fromLegacy: true, shared: "plugin" },
});
expect(result.changes).toEqual([
"Merged tools.web.search.apiKey → plugins.entries.brave.config.webSearch (filled missing fields from legacy; kept explicit plugin config values).",
]);
});
it("strips empty mapped records without mutating the input or inventing a change", () => {
const input = { tools: { web: { search: { provider: "grok", grok: {} } } } };
const result = migrateLegacyWebSearchConfig(input);
expect(result.config).not.toBe(input);
expect(result.config.tools.web.search).toEqual({ provider: "grok" });
expect(result.config).not.toHaveProperty("plugins");
expect(result.changes).toStrictEqual([]);
expect(input.tools.web.search).toEqual({ provider: "grok", grok: {} });
});
it("preserves unknown fields, drops dangerous keys, and lists mapped paths", () => {
const input = {
tools: {
web: {
search: {
apiKey: "test-key",
grok: { apiKey: "test-secret", model: "grok-latest" },
customSearch: { endpoint: "https://search.example.test" },
["__proto__"]: { polluted: true },
constructor: { polluted: true },
prototype: { polluted: true },
},
},
},
};
expect(listLegacyWebSearchConfigPaths(input)).toEqual([
"tools.web.search.apiKey",
"tools.web.search.grok.apiKey",
"tools.web.search.grok.model",
]);
expect(migrateLegacyWebSearchConfig(input).config.tools.web.search).toEqual({
customSearch: { endpoint: "https://search.example.test" },
});
});
});
describe("legacy web fetch config", () => {
it("moves Firecrawl config, discards enabled, and preserves other fetch knobs", () => {
const result = migrateLegacyWebFetchConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
timeoutSeconds: 15,
firecrawl: {
enabled: false,
apiKey: "dummy",
onlyMainContent: false,
},
},
},
},
} as OpenClawConfig);
expect(result.config.tools?.web?.fetch).toEqual({
provider: "firecrawl",
timeoutSeconds: 15,
});
expect(result.config.plugins?.entries?.firecrawl).toEqual({
enabled: true,
config: { webFetch: { apiKey: "dummy", onlyMainContent: false } },
});
expect(result.changes).toEqual([
"Moved tools.web.fetch.firecrawl → plugins.entries.firecrawl.config.webFetch.",
]);
});
it.each([{ firecrawl: {} }, { firecrawl: { enabled: false }, timeoutSeconds: 10 }])(
"removes an empty Firecrawl payload without creating plugin config: %j",
(fetch) => {
const result = migrateLegacyWebFetchConfig({ tools: { web: { fetch } } });
expect(result.config.tools.web.fetch).toEqual(
"timeoutSeconds" in fetch ? { timeoutSeconds: 10 } : {},
);
expect(result.config).not.toHaveProperty("plugins");
expect(result.changes).toEqual(["Removed empty tools.web.fetch.firecrawl."]);
},
);
});
describe("legacy x_search config", () => {
it("moves only auth and leaves the other legacy knobs in place", () => {
const result = migrateLegacyXSearchConfig({
tools: {
web: {
x_search: { apiKey: "fake", enabled: true, model: "grok-4-1-fast" },
} as Record<string, unknown>,
},
} as OpenClawConfig);
const web = result.config.tools?.web as Record<string, unknown> | undefined;
expect(web?.x_search).toEqual({
enabled: true,
model: "grok-4-1-fast",
});
expect(result.config.plugins?.entries?.xai?.config?.webSearch).toEqual({
apiKey: "fake",
});
});
it.each([
{ name: "value", webSearch: { apiKey: "test-token" } },
{ name: "own undefined", webSearch: { apiKey: undefined } },
])("keeps explicit plugin-owned auth including $name", ({ webSearch }) => {
const result = migrateLegacyXSearchConfig({
tools: { web: { x_search: { apiKey: "placeholder", cacheTtlMinutes: 5 } } },
plugins: { entries: { xai: { enabled: true, config: { webSearch } } } },
});
expect(result.config.plugins.entries.xai.config.webSearch).toEqual(webSearch);
expect(result.config.tools.web.x_search).toEqual({ cacheTtlMinutes: 5 });
expect(result.changes).toEqual([
"Removed tools.web.x_search.apiKey (plugins.entries.xai.config.webSearch.apiKey already set).",
]);
});
it("moves SecretRefs unchanged", () => {
const apiKey = { source: "env", provider: "default", id: "X_SEARCH_KEY_REF" };
const result = migrateLegacyXSearchConfig({
tools: { web: { x_search: { apiKey, enabled: true } } },
} as OpenClawConfig);
expect(result.config.plugins?.entries?.xai?.config?.webSearch).toEqual({ apiKey });
});
it("repairs model before auth and removes an emptied source after activating the plugin", () => {
const combined = migrateLegacyXSearchConfig({
tools: { web: { x_search: { apiKey: "placeholder", model: "grok-3" } } },
});
const authOnly = migrateLegacyXSearchConfig({
tools: { web: { x_search: { apiKey: "placeholder" } } },
});
expect(combined.config.tools.web.x_search).toEqual({ model: "grok-4.3" });
expect(combined.changes).toEqual([
'Updated tools.web.x_search.model from "grok-3" to "grok-4.3".',
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
]);
expect(authOnly.config.tools.web).not.toHaveProperty("x_search");
expect(authOnly.changes).toEqual([
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
"Removed empty tools.web.x_search.",
]);
});
it("repairs retired model-only aliases without creating plugin config", () => {
const retired = migrateLegacyXSearchConfig({
tools: { web: { x_search: { enabled: true, model: "grok-code-fast-1" } } },
});
const current = migrateLegacyXSearchConfig({
tools: { web: { x_search: { model: "grok-latest" } } },
});
expect(retired.config.tools.web.x_search).toEqual({
enabled: true,
model: "grok-build-0.1",
});
expect(retired.config).not.toHaveProperty("plugins");
expect(current.changes).toStrictEqual([]);
});
});
@@ -0,0 +1,334 @@
// Legacy web tool config migrations into plugin-owned provider config.
import { mergeMissing } from "../../../config/legacy.shared.js";
import {
cloneRecord,
ensureRecord,
hasOwnKey,
isRecord,
type JsonRecord,
} from "./legacy-config-record-shared.js";
const DANGEROUS_RECORD_KEYS = new Set(["__proto__", "prototype", "constructor"]);
const LEGACY_WEB_SEARCH_OWNERS = new Map<string, string>([
["brave", "brave"],
["duckduckgo", "duckduckgo"],
["exa", "exa"],
["firecrawl", "firecrawl"],
["firecrawl-free", "firecrawl"],
["gemini", "google"],
["grok", "xai"],
["kimi", "moonshot"],
["minimax", "minimax"],
["ollama", "ollama"],
["parallel", "parallel"],
["parallel-free", "parallel"],
["perplexity", "perplexity"],
["searxng", "searxng"],
["tavily", "tavily"],
]);
const NON_MIGRATED_SEARCH_PROVIDERS = new Set([
"firecrawl-free",
"parallel",
"parallel-free",
"tavily",
]);
const RETIRED_GROK_SEARCH_MODELS = new Set([
"grok-4-1-fast",
"grok-4-1-fast-reasoning",
"grok-4-fast",
"grok-4-fast-reasoning",
"grok-4-0709",
]);
const RETIRED_GROK_CODE_MODELS = new Set([
"grok-code-fast-1",
"grok-code-fast",
"grok-code-fast-1-0825",
]);
const RETIRED_X_SEARCH_MODELS = new Set([
"grok-4-1-fast-non-reasoning",
"grok-4-fast-non-reasoning",
"grok-3",
]);
type PluginMove = {
pluginId: string;
configKey: "webSearch" | "webFetch";
payload: JsonRecord;
legacyPath: string;
targetPath: string;
mergeMode?: "missing" | "own-api-key";
activatedMessage?: string;
};
type MigrationStep = { message: string } | { move: PluginMove };
type PreparedSlot = { retained: JsonRecord; deleteSource?: boolean; steps: MigrationStep[] };
function legacySearchProviderIds(): string[] {
return [...LEGACY_WEB_SEARCH_OWNERS.keys()]
.filter((providerId) => !NON_MIGRATED_SEARCH_PROVIDERS.has(providerId))
.toSorted((left, right) => left.localeCompare(right));
}
function resolveWebSlot(raw: unknown, slot: string): JsonRecord | undefined {
if (!isRecord(raw) || !isRecord(raw.tools) || !isRecord(raw.tools.web)) {
return undefined;
}
const value = raw.tools.web[slot];
return isRecord(value) ? value : undefined;
}
function retainedSource(source: JsonRecord, removedRecordKeys: ReadonlySet<string>): JsonRecord {
const retained: JsonRecord = {};
for (const [key, value] of Object.entries(source)) {
if (DANGEROUS_RECORD_KEYS.has(key) || (removedRecordKeys.has(key) && isRecord(value))) {
continue;
}
retained[key] = value;
}
return retained;
}
function applyPluginMove(root: JsonRecord, move: PluginMove, changes: string[]): boolean {
const entries = ensureRecord(ensureRecord(root, "plugins"), "entries");
const entry = ensureRecord(entries, move.pluginId);
const activated = entry.enabled === undefined;
if (activated) {
entry.enabled = true;
}
const config = ensureRecord(entry, "config");
const existingValue = config[move.configKey];
const existingWasRecord = isRecord(existingValue);
const existing = cloneRecord(existingWasRecord ? existingValue : undefined);
if (!existingWasRecord) {
config[move.configKey] = cloneRecord(move.payload);
changes.push(`Moved ${move.legacyPath}${move.targetPath}.`);
} else if (move.mergeMode === "own-api-key") {
if (!hasOwnKey(existing, "apiKey")) {
existing.apiKey = move.payload.apiKey;
config[move.configKey] = existing;
changes.push(`Merged ${move.legacyPath}${move.targetPath} (filled missing plugin auth).`);
} else {
changes.push(`Removed ${move.legacyPath} (${move.targetPath} already set).`);
}
} else {
const merged = cloneRecord(existing);
mergeMissing(merged, move.payload);
config[move.configKey] = merged;
if (JSON.stringify(merged) !== JSON.stringify(existing) || activated) {
changes.push(
`Merged ${move.legacyPath}${move.targetPath} (filled missing fields from legacy; kept explicit plugin config values).`,
);
} else {
changes.push(`Removed ${move.legacyPath} (${move.targetPath} already set).`);
}
}
return activated;
}
function migrateLegacyWebSlot<T>(
raw: T,
slot: "search" | "fetch" | "x_search",
prepare: (source: JsonRecord) => PreparedSlot | null,
): { config: T; changes: string[] } {
const source = resolveWebSlot(raw, slot);
const prepared = source ? prepare(source) : null;
if (!isRecord(raw) || !prepared) {
return { config: raw, changes: [] };
}
const nextRoot = structuredClone(raw) as T & JsonRecord;
const web = ensureRecord(ensureRecord(nextRoot, "tools"), "web");
if (prepared.deleteSource) {
delete web[slot];
} else {
web[slot] = prepared.retained;
}
const changes: string[] = [];
for (const step of prepared.steps) {
if ("message" in step) {
changes.push(step.message);
continue;
}
const activated = applyPluginMove(nextRoot, step.move, changes);
if (activated && step.move.activatedMessage) {
changes.push(step.move.activatedMessage);
}
}
return { config: nextRoot, changes };
}
function resolveGrokModelTarget(model: unknown, xSearch: boolean): string | undefined {
if (typeof model !== "string") {
return undefined;
}
const normalized = model.trim().toLowerCase();
if ((xSearch ? RETIRED_X_SEARCH_MODELS : RETIRED_GROK_SEARCH_MODELS).has(normalized)) {
return "grok-4.3";
}
return RETIRED_GROK_CODE_MODELS.has(normalized) ? "grok-build-0.1" : undefined;
}
function searchMove(
providerId: string,
payload: JsonRecord,
paths?: { legacyPath: string; targetPath: string },
): PluginMove {
const pluginId = LEGACY_WEB_SEARCH_OWNERS.get(providerId) ?? providerId;
return {
pluginId,
configKey: "webSearch",
payload,
legacyPath: paths?.legacyPath ?? `tools.web.search.${providerId}`,
targetPath: paths?.targetPath ?? `plugins.entries.${pluginId}.config.webSearch`,
};
}
function prepareWebSearch(source: JsonRecord): PreparedSlot | null {
const providerIds = legacySearchProviderIds();
if (!hasOwnKey(source, "apiKey") && !providerIds.some((id) => isRecord(source[id]))) {
return null;
}
const retained = retainedSource(source, new Set(["apiKey", ...providerIds]));
delete retained.apiKey;
const steps: MigrationStep[] = [];
const braveRecord = isRecord(source.brave) ? cloneRecord(source.brave) : undefined;
const bravePayload = cloneRecord(braveRecord);
if (hasOwnKey(source, "apiKey")) {
bravePayload.apiKey = source.apiKey;
}
if (Object.keys(bravePayload).length > 0) {
const hasGlobalApiKey = hasOwnKey(source, "apiKey");
steps.push({
move: searchMove(
"brave",
bravePayload,
hasGlobalApiKey
? {
legacyPath: "tools.web.search.apiKey",
targetPath: braveRecord
? "plugins.entries.brave.config.webSearch"
: "plugins.entries.brave.config.webSearch.apiKey",
}
: undefined,
),
});
}
for (const providerId of providerIds) {
if (providerId === "brave" || !isRecord(source[providerId])) {
continue;
}
const payload = cloneRecord(source[providerId]);
if (Object.keys(payload).length === 0) {
continue;
}
if (providerId === "grok") {
const modelTarget = resolveGrokModelTarget(payload.model, false);
if (modelTarget) {
steps.push({
message: `Updated tools.web.search.grok.model from ${JSON.stringify(payload.model)} to ${JSON.stringify(modelTarget)}.`,
});
payload.model = modelTarget;
}
}
steps.push({ move: searchMove(providerId, payload) });
}
return { retained, steps };
}
function prepareWebFetch(source: JsonRecord): PreparedSlot | null {
if (!isRecord(source.firecrawl)) {
return null;
}
const payload = cloneRecord(source.firecrawl);
delete payload.enabled;
const retained = retainedSource(source, new Set(["firecrawl"]));
return {
retained,
steps:
Object.keys(payload).length > 0
? [
{
move: {
pluginId: "firecrawl",
configKey: "webFetch",
payload,
legacyPath: "tools.web.fetch.firecrawl",
targetPath: "plugins.entries.firecrawl.config.webFetch",
},
},
]
: [{ message: "Removed empty tools.web.fetch.firecrawl." }],
};
}
/** Resolve a supported replacement for a retired legacy X search model. */
export function resolveLegacyXSearchModelTarget(model: unknown): string | undefined {
return resolveGrokModelTarget(model, true);
}
function prepareXSearch(source: JsonRecord): PreparedSlot | null {
const hasAuth = hasOwnKey(source, "apiKey");
const modelTarget = resolveLegacyXSearchModelTarget(source.model);
if (!hasAuth && !modelTarget) {
return null;
}
const retained = cloneRecord(source);
const steps: MigrationStep[] = [];
if (hasAuth) {
delete retained.apiKey;
}
if (modelTarget) {
steps.push({
message: `Updated tools.web.x_search.model from ${JSON.stringify(source.model)} to ${JSON.stringify(modelTarget)}.`,
});
retained.model = modelTarget;
}
if (hasAuth) {
steps.push({
move: {
pluginId: "xai",
configKey: "webSearch",
payload: { apiKey: source.apiKey },
legacyPath: "tools.web.x_search.apiKey",
targetPath: "plugins.entries.xai.config.webSearch.apiKey",
mergeMode: "own-api-key",
...(Object.keys(retained).length === 0
? { activatedMessage: "Removed empty tools.web.x_search." }
: {}),
},
});
}
return { retained, deleteSource: Object.keys(retained).length === 0, steps };
}
/** List legacy tools.web.search provider config paths present in raw config. */
export function listLegacyWebSearchConfigPaths(raw: unknown): string[] {
const source = resolveWebSlot(raw, "search");
if (!source) {
return [];
}
const paths = hasOwnKey(source, "apiKey") ? ["tools.web.search.apiKey"] : [];
for (const providerId of legacySearchProviderIds()) {
if (isRecord(source[providerId])) {
paths.push(
...Object.keys(source[providerId]).map((key) => `tools.web.search.${providerId}.${key}`),
);
}
}
return paths;
}
/** Move legacy web-search provider config into provider plugin entries. */
export function migrateLegacyWebSearchConfig<T>(raw: T): { config: T; changes: string[] } {
return migrateLegacyWebSlot(raw, "search", prepareWebSearch);
}
/** Move legacy Firecrawl web-fetch config into plugin-owned config. */
export function migrateLegacyWebFetchConfig<T>(raw: T): { config: T; changes: string[] } {
return migrateLegacyWebSlot(raw, "fetch", prepareWebFetch);
}
/** Move legacy X search auth and repair retired legacy model defaults. */
export function migrateLegacyXSearchConfig<T>(raw: T): { config: T; changes: string[] } {
return migrateLegacyWebSlot(raw, "x_search", prepareXSearch);
}
@@ -1,163 +0,0 @@
// Legacy X search migration tests cover doctor repair of old X search config.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js";
describe("legacy x_search config migration", () => {
it("moves only legacy x_search auth into the xai plugin config", () => {
const res = migrateLegacyXSearchConfig({
tools: {
web: {
x_search: {
apiKey: "xai-legacy-key",
enabled: true,
model: "grok-4-1-fast",
},
} as Record<string, unknown>,
},
} as OpenClawConfig);
expect((res.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "grok-4-1-fast",
});
expect(res.config.plugins?.entries?.xai).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "xai-legacy-key",
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
]);
});
it("keeps explicit plugin-owned auth when migrating legacy x_search config", () => {
const res = migrateLegacyXSearchConfig({
tools: {
web: {
x_search: {
apiKey: "xai-legacy-key",
enabled: true,
model: "legacy-model",
cacheTtlMinutes: 5,
},
} as Record<string, unknown>,
},
plugins: {
entries: {
xai: {
enabled: true,
config: {
webSearch: {
apiKey: "plugin-key",
},
xSearch: {
model: "plugin-model",
},
},
},
},
},
} as OpenClawConfig);
expect((res.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "legacy-model",
cacheTtlMinutes: 5,
});
expect(res.config.plugins?.entries?.xai?.config).toEqual({
webSearch: {
apiKey: "plugin-key",
},
xSearch: {
model: "plugin-model",
},
});
});
it("moves legacy x_search SecretRefs into the xai plugin auth slot unchanged", () => {
const res = migrateLegacyXSearchConfig({
tools: {
web: {
x_search: {
apiKey: {
source: "env",
provider: "default",
id: "X_SEARCH_KEY_REF",
},
enabled: true,
},
} as Record<string, unknown>,
},
} as OpenClawConfig);
expect((res.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
});
expect(res.config.plugins?.entries?.xai).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: {
source: "env",
provider: "default",
id: "X_SEARCH_KEY_REF",
},
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.x_search.apiKey → plugins.entries.xai.config.webSearch.apiKey.",
]);
});
it("repairs a retired knob-only x_search model without creating plugin config", () => {
const config = {
tools: {
web: {
x_search: {
enabled: true,
model: "grok-4-1-fast-non-reasoning",
},
} as Record<string, unknown>,
},
} as OpenClawConfig;
const res = migrateLegacyXSearchConfig(config);
expect((res.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
enabled: true,
model: "grok-4.3",
});
expect(res.changes).toEqual([
'Updated tools.web.x_search.model from "grok-4-1-fast-non-reasoning" to "grok-4.3".',
]);
expect(res.config.plugins?.entries?.xai).toBeUndefined();
expect(config.tools?.web).toEqual({
x_search: { enabled: true, model: "grok-4-1-fast-non-reasoning" },
});
});
it("repairs retired Grok code aliases and preserves current aliases", () => {
const retired = migrateLegacyXSearchConfig({
tools: { web: { x_search: { model: "grok-code-fast-1" } } },
} as OpenClawConfig);
const current = migrateLegacyXSearchConfig({
tools: { web: { x_search: { model: "grok-latest" } } },
} as OpenClawConfig);
expect((retired.config.tools?.web as Record<string, unknown> | undefined)?.x_search).toEqual({
model: "grok-build-0.1",
});
expect(retired.changes).toEqual([
'Updated tools.web.x_search.model from "grok-code-fast-1" to "grok-build-0.1".',
]);
expect(current).toEqual({
config: { tools: { web: { x_search: { model: "grok-latest" } } } },
changes: [],
});
});
});
@@ -1,135 +0,0 @@
// Legacy X search config migration from tools.web.x_search to the xAI plugin config.
import { isRecord } from "./legacy-config-record-shared.js";
type JsonRecord = Record<string, unknown>;
const XAI_PLUGIN_ID = "xai";
const X_SEARCH_LEGACY_PATH = "tools.web.x_search";
const XAI_WEB_SEARCH_PLUGIN_KEY_PATH = `plugins.entries.${XAI_PLUGIN_ID}.config.webSearch.apiKey`;
const RETIRED_X_SEARCH_MODELS = new Set([
"grok-4-1-fast-non-reasoning",
"grok-4-fast-non-reasoning",
"grok-3",
]);
const RETIRED_CODE_MODELS = new Set([
"grok-code-fast-1",
"grok-code-fast",
"grok-code-fast-1-0825",
]);
function cloneRecord<T extends JsonRecord | undefined>(value: T): T {
if (!value) {
return value;
}
return { ...value } as T;
}
function ensureRecord(target: JsonRecord, key: string): JsonRecord {
const current = target[key];
if (isRecord(current)) {
return current;
}
const next: JsonRecord = {};
target[key] = next;
return next;
}
function resolveLegacyXSearchConfig(raw: unknown): JsonRecord | undefined {
if (!isRecord(raw)) {
return undefined;
}
const tools = isRecord(raw.tools) ? raw.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
return isRecord(web?.x_search) ? web.x_search : undefined;
}
function resolveLegacyXSearchAuth(legacy: JsonRecord): unknown {
return legacy.apiKey;
}
export function resolveLegacyXSearchModelTarget(modelValue: unknown): string | undefined {
if (typeof modelValue !== "string") {
return undefined;
}
const model = modelValue.trim().toLowerCase();
if (RETIRED_X_SEARCH_MODELS.has(model)) {
return "grok-4.3";
}
if (RETIRED_CODE_MODELS.has(model)) {
return "grok-build-0.1";
}
return undefined;
}
/** Move legacy X search auth and repair retired legacy model defaults. */
export function migrateLegacyXSearchConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw)) {
return { config: raw, changes: [] };
}
const legacy = resolveLegacyXSearchConfig(raw);
const hasLegacyAuth = legacy ? Object.hasOwn(legacy, "apiKey") : false;
const modelTarget = legacy ? resolveLegacyXSearchModelTarget(legacy.model) : undefined;
if (!legacy || (!hasLegacyAuth && !modelTarget)) {
return { config: raw, changes: [] };
}
const nextRoot = structuredClone(raw);
const tools = ensureRecord(nextRoot, "tools");
const web = ensureRecord(tools, "web");
const nextLegacy = cloneRecord(legacy) ?? {};
if (hasLegacyAuth) {
delete nextLegacy.apiKey;
}
const changes: string[] = [];
if (modelTarget) {
nextLegacy.model = modelTarget;
changes.push(
`Updated ${X_SEARCH_LEGACY_PATH}.model from ${JSON.stringify(legacy.model)} to ${JSON.stringify(modelTarget)}.`,
);
}
if (Object.keys(nextLegacy).length === 0) {
delete web.x_search;
} else {
web.x_search = nextLegacy;
}
const auth = resolveLegacyXSearchAuth(legacy);
let hadEnabled = true;
if (hasLegacyAuth) {
const plugins = ensureRecord(nextRoot, "plugins");
const entries = ensureRecord(plugins, "entries");
const entry = ensureRecord(entries, XAI_PLUGIN_ID);
hadEnabled = entry.enabled !== undefined;
if (!hadEnabled) {
entry.enabled = true;
}
const config = ensureRecord(entry, "config");
const existingWebSearch = isRecord(config.webSearch)
? cloneRecord(config.webSearch)
: undefined;
if (!existingWebSearch) {
config.webSearch = { apiKey: auth };
changes.push(`Moved ${X_SEARCH_LEGACY_PATH}.apiKey → ${XAI_WEB_SEARCH_PLUGIN_KEY_PATH}.`);
} else if (!Object.hasOwn(existingWebSearch, "apiKey")) {
existingWebSearch.apiKey = auth;
config.webSearch = existingWebSearch;
changes.push(
`Merged ${X_SEARCH_LEGACY_PATH}.apiKey → ${XAI_WEB_SEARCH_PLUGIN_KEY_PATH} (filled missing plugin auth).`,
);
} else {
changes.push(
`Removed ${X_SEARCH_LEGACY_PATH}.apiKey (${XAI_WEB_SEARCH_PLUGIN_KEY_PATH} already set).`,
);
}
}
if (hasLegacyAuth && Object.keys(nextLegacy).length === 0 && !hadEnabled) {
changes.push(`Removed empty ${X_SEARCH_LEGACY_PATH}.`);
}
return {
config: nextRoot as T,
changes,
};
}
@@ -1,8 +1,4 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import {
listExplicitlyDisabledChannelIdsForConfig,
listPotentialConfiguredChannelIds,
} from "../../../channels/config-presence.js";
import { listRawChannelPluginCatalogEntries } from "../../../channels/plugins/catalog.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { resolveConfiguredChannelPresencePolicy } from "../../../plugins/channel-plugin-ids.js";
@@ -17,6 +13,7 @@ import {
resolveWebSearchInstallCatalogEntriesForEnv,
resolveWebSearchInstallCatalogEntry,
} from "../../../plugins/web-search-install-catalog.js";
import { listDoctorConfiguredChannelIds } from "./configured-channel-ids.js";
import { collectConfiguredProviderPluginIds } from "./configured-provider-plugin-installs.js";
import { collectConfiguredRuntimePluginIds } from "./configured-runtime-plugin-installs.js";
import { asObjectRecord } from "./object.js";
@@ -160,25 +157,22 @@ export function collectConfiguredChannelIds(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): Set<string> {
const ids = new Set<string>();
if (asObjectRecord(cfg.plugins)?.enabled === false) {
return ids;
return new Set();
}
const disabled = new Set(listExplicitlyDisabledChannelIdsForConfig(cfg));
const candidateChannelIds = listRawChannelPluginCatalogEntries({
env,
excludeWorkspace: true,
}).map((entry) => entry.id);
for (const channelId of listPotentialConfiguredChannelIds(cfg, env, {
channelIds: candidateChannelIds,
includePersistedAuthState: false,
})) {
const normalized = channelId.trim();
if (normalized && !disabled.has(normalized.toLowerCase())) {
ids.add(normalized);
}
}
return ids;
return new Set(
listDoctorConfiguredChannelIds(cfg, {
configEntryPolicy: "meaningful",
env: env ?? process.env,
candidateChannelIds,
skipWhenPluginsDisabled: true,
excludeExplicitlyDisabled: true,
}),
);
}
export function collectEffectiveConfiguredChannelOwnerPluginIds(params: {
@@ -1141,6 +1141,15 @@ describe("repairMissingConfiguredPluginInstalls", () => {
it.each([
["enabled-only disabled stub", { channels: { matrix: { enabled: false } } }],
[
"channel metadata",
{
channels: {
modelByChannel: { matrix: { default: "openai/gpt-5.6-luna" } },
" ": { homeserver: "https://matrix.example.org" },
},
},
],
[
"disabled configured channel",
{ channels: { matrix: { enabled: false, homeserver: "https://matrix.example.org" } } },
@@ -4,6 +4,7 @@ import path from "node:path";
import { resolveStateDir } from "../../../config/paths.js";
import type { HealthFinding } from "../../../flows/health-checks.js";
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
import { isPathInside } from "../../../infra/path-safety.js";
import { resolveConfigDir, resolveUserPath } from "../../../utils.js";
import { removeStalePluginRuntimeSymlinks } from "./plugin-runtime-symlinks.js";
@@ -90,11 +91,6 @@ async function isFile(targetPath: string): Promise<boolean> {
return stat?.isFile() === true;
}
function isPathInsideRoot(candidate: string, root: string): boolean {
const relativePath = path.relative(root, candidate);
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
}
async function collectDirectChildren(root: string): Promise<string[]> {
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
return entries.map((entry) => path.join(root, entry.name));
@@ -109,7 +105,7 @@ async function isDirectoryInCleanupRoot(
return false;
}
const realPath = await fs.realpath(candidate).catch(() => null);
return realPath !== null && isPathInsideRoot(realPath, cleanupRootRealPath);
return realPath !== null && isPathInside(cleanupRootRealPath, realPath);
}
async function collectLegacyExtensionDebris(
@@ -253,7 +249,7 @@ function filterLegacyStaleRootCandidates(
warnings.push(`Skipped legacy plugin dependency state ${targetPath}: unexpected path name`);
continue;
}
if (!cleanupRootPaths.some((rootPath) => isPathInsideRoot(targetPath, rootPath))) {
if (!cleanupRootPaths.some((rootPath) => isPathInside(rootPath, targetPath))) {
warnings.push(
`Skipped legacy plugin dependency state ${targetPath}: outside OpenClaw cleanup roots`,
);
@@ -295,7 +291,7 @@ async function resolveSafeRemovalTarget(
}
return { target: targetPath };
}
if (!cleanupRoots.some((root) => isPathInsideRoot(realPath, root.realPath))) {
if (!cleanupRoots.some((root) => isPathInside(root.realPath, realPath))) {
return {
warning: `Skipped legacy plugin dependency state ${targetPath}: resolved outside OpenClaw cleanup roots`,
};
@@ -5,6 +5,7 @@ import { sortUniqueStrings } from "@openclaw/normalization-core/string-normaliza
import { note } from "../../../../packages/terminal-core/src/note.js";
import type { HealthFinding } from "../../../flows/health-checks.js";
import { resolveOpenClawPackageRootSync } from "../../../infra/openclaw-root.js";
import { isPathInside } from "../../../infra/path-safety.js";
import { shortenHomePath } from "../../../utils.js";
const PLUGIN_RUNTIME_DEPS_MARKER = "plugin-runtime-deps";
@@ -184,11 +185,6 @@ function uniqueResolvedRoots(values: readonly string[]): string[] {
return sortUniqueStrings(values.map((value) => path.resolve(value)));
}
function isPathInsideRoot(candidate: string, root: string): boolean {
const relativePath = path.relative(root, candidate);
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
}
async function inspectCandidate(
fullPath: string,
fsApi: FsLike,
@@ -205,7 +201,7 @@ async function inspectCandidate(
const resolvedTarget = path.isAbsolute(target)
? target
: path.resolve(path.dirname(fullPath), target);
if (staleRoots.some((root) => isPathInsideRoot(resolvedTarget, root))) {
if (staleRoots.some((root) => isPathInside(root, resolvedTarget))) {
return resolvedTarget;
}
try {
@@ -527,6 +527,19 @@ describe("configured plugin install release step", () => {
})
).channelIds,
).toStrictEqual([]);
expect(
(
await collectReleaseConfiguredPluginIdsThroughDoctor({
cfg: {
channels: {
Matrix: { enabled: false, accessToken: "test" },
},
},
env: { MATRIX_ACCESS_TOKEN: "test" },
})
).channelIds,
).toStrictEqual([]);
});
it("marks the release step complete when there is nothing to install", async () => {
@@ -1,7 +1,6 @@
// Release-era repair for configs that imply official plugin installs before install records existed.
import { normalizeNullableString as normalizeId } from "@openclaw/normalization-core/string-coerce";
import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-runtimes.js";
import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js";
import { normalizeChatChannelId } from "../../../channels/registry.js";
import { isChannelConfigured } from "../../../config/channel-configured.js";
import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js";
@@ -22,6 +21,7 @@ import {
resolveWebSearchInstallCatalogEntry,
} from "../../../plugins/web-search-install-catalog.js";
import { VERSION } from "../../../version.js";
import { listDoctorConfiguredChannelIds } from "./configured-channel-ids.js";
import { collectConfiguredProviderPluginIds } from "./configured-provider-plugin-installs.js";
import { repairMissingPluginInstallsForIds } from "./missing-configured-plugin-install.js";
import { asObjectRecord } from "./object.js";
@@ -123,31 +123,16 @@ function collectSlotPluginIds(cfg: OpenClawConfig): string[] {
}
function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
const ids = new Set<string>();
const channels = asObjectRecord(cfg.channels);
if (channels) {
for (const [channelId, value] of Object.entries(channels)) {
if (channelId === "defaults" || channelId === "modelByChannel" || !channelId.trim()) {
continue;
}
const entry = asObjectRecord(value);
if (entry?.enabled === false) {
continue;
}
if (entry?.enabled === true || Object.keys(entry ?? {}).some((key) => key !== "enabled")) {
ids.add(channelId.trim());
}
}
}
for (const signal of listPotentialConfiguredChannelPresenceSignals(cfg, env, {
includePersistedAuthState: false,
})) {
const channelId = normalizeChatChannelId(signal.channelId) ?? signal.channelId;
if (!isChannelDisabled(cfg, channelId) && isChannelConfigured(cfg, channelId, env)) {
ids.add(channelId);
}
}
return [...ids].toSorted((left, right) => left.localeCompare(right));
return listDoctorConfiguredChannelIds(cfg, {
configEntryPolicy: "enabled-or-meaningful",
env,
skipWhenPluginsDisabled: true,
excludeExplicitlyDisabled: true,
mapEnvironmentChannelId: (channelId) => normalizeChatChannelId(channelId) ?? channelId,
environmentChannelIsConfigured: (channelId) =>
!isChannelDisabled(cfg, channelId) && isChannelConfigured(cfg, channelId, env),
sort: "locale",
});
}
function collectAgentHarnessRuntimePluginIds(
+9 -2
View File
@@ -3423,15 +3423,22 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
defaults: {
model: "sonnet-4.6",
},
modelByChannel: {
"demo-channel": { default: "openai/gpt-5.6-luna" },
},
" ": { token: "dummy" },
"demo-channel": {
token: "configured",
token: "test-token",
},
" trimmed-channel ": {
token: "test-token",
},
"demo-other-channel": {
enabled: false,
},
},
} as OpenClawConfig),
).toEqual(["demo-channel"]);
).toEqual(["demo-channel", "trimmed-channel"]);
});
it("does not let disabled mixed-case channel config announce ambient matches", () => {
+9 -7
View File
@@ -2,6 +2,7 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isChannelConfigMetadataKey } from "../channels/config-metadata.js";
import {
hasMeaningfulChannelConfig,
listExplicitlyDisabledChannelIdsForConfig,
@@ -28,8 +29,6 @@ import {
import type { PluginManifestRecord } from "./manifest-registry.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js";
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
/** Source classes that can make a channel appear configured for read-only scopes. */
export type ConfiguredChannelPresenceSource =
| "explicit-config"
@@ -110,11 +109,14 @@ export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig
return [];
}
return Object.keys(channels)
.filter(
(channelId) =>
!IGNORED_CHANNEL_CONFIG_KEYS.has(channelId) &&
hasExplicitChannelConfig({ config, channelId }),
)
.flatMap((rawChannelId) => {
const channelId = rawChannelId.trim();
return channelId &&
!isChannelConfigMetadataKey(channelId) &&
hasExplicitChannelConfig({ config, channelId: rawChannelId })
? [channelId]
: [];
})
.toSorted((left, right) => left.localeCompare(right));
}
@@ -627,6 +627,28 @@ describe("doctor-contract-registry module loader", () => {
).toEqual(["ollama-cloud"]);
});
it("excludes channel metadata and blank ids from full and touched doctor scans", () => {
const raw = {
channels: {
defaults: {},
modelByChannel: { discord: "openai/gpt-5.6-luna" },
" ": {},
discord: {},
},
};
expect(collectRelevantDoctorPluginIds(raw)).toEqual(["discord"]);
expect(
collectRelevantDoctorPluginIdsForTouchedPaths({
raw,
touchedPaths: [["channels", "modelByChannel", "discord"]],
}),
).toStrictEqual([]);
expect(
collectRelevantDoctorPluginIdsForTouchedPaths({ raw, touchedPaths: [["channels"]] }),
).toEqual(["discord"]);
});
it("collects provider ids from media model entries", () => {
const raw = {
tools: {
+7 -4
View File
@@ -2,6 +2,7 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { isChannelConfigMetadataKey } from "../channels/config-metadata.js";
import { listBundledChannelLegacyStateMigrationDetectorEntries } from "../channels/plugins/bundled.js";
import type { LegacyConfigRule } from "../config/legacy.shared.js";
import type { OpenClawConfig } from "../config/types.js";
@@ -107,8 +108,9 @@ export function collectRelevantDoctorPluginIds(raw: unknown): string[] {
const channels = asNullableRecord(root.channels);
if (channels) {
for (const channelId of Object.keys(channels)) {
if (channelId !== "defaults") {
for (const rawChannelId of Object.keys(channels)) {
const channelId = rawChannelId.trim();
if (channelId && !isChannelConfigMetadataKey(channelId)) {
ids.add(channelId);
}
}
@@ -153,8 +155,9 @@ export function collectRelevantDoctorPluginIdsForTouchedPaths(params: {
if (!second) {
return collectRelevantDoctorPluginIds(params.raw);
}
if (second !== "defaults") {
ids.add(second);
const channelId = second.trim();
if (channelId && !isChannelConfigMetadataKey(channelId)) {
ids.add(channelId);
}
continue;
}