diff --git a/src/channels/config-metadata.ts b/src/channels/config-metadata.ts new file mode 100644 index 000000000000..69b24baf2d82 --- /dev/null +++ b/src/channels/config-metadata.ts @@ -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()); +} diff --git a/src/channels/config-presence.test.ts b/src/channels/config-presence.test.ts index 602c2413889b..45c3b066e7cf 100644 --- a/src/channels/config-presence.test.ts +++ b/src/channels/config-presence.test.ts @@ -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; diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 1a5d5d1db329..3fabe1524f18 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -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(); - const addSignal = (channelId: string, source: ChannelPresenceSignalSource) => { + const configuredChannelIds = new Set(); + 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(); 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"); } } diff --git a/src/channels/plugins/legacy-config.test.ts b/src/channels/plugins/legacy-config.test.ts index 490a5d23f85b..702e41053b64 100644 --- a/src/channels/plugins/legacy-config.test.ts +++ b/src/channels/plugins/legacy-config.test.ts @@ -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: [ diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index 68ecee4c4929..66fb451edc38 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -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) { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 609906132fbc..6274dc56a4a0 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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" }, diff --git a/src/commands/doctor/shared/channel-doctor.test.ts b/src/commands/doctor/shared/channel-doctor.test.ts index 31a6f228fcfc..89d389a09900 100644 --- a/src/commands/doctor/shared/channel-doctor.test.ts +++ b/src/commands/doctor/shared/channel-doctor.test.ts @@ -140,6 +140,10 @@ describe("channel doctor compatibility mutations", () => { defaults: { enabled: true, }, + modelByChannel: { + discord: "openai/gpt-5.6-luna", + }, + " ": { token: "dummy" }, }, } as never); diff --git a/src/commands/doctor/shared/channel-doctor.ts b/src/commands/doctor/shared/channel-doctor.ts index 756dc7ef6b4e..155a8711725d 100644 --- a/src/commands/doctor/shared/channel-doctor.ts +++ b/src/commands/doctor/shared/channel-doctor.ts @@ -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; - 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 { diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts index e9ca784da7ac..0ad46b1d2fac 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts @@ -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({ diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.ts index dd88f1dbeb20..a680704b1687 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.ts @@ -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, changes: string[]): void { const channels = isRecord(raw.channels) ? raw.channels : null; if (!channels) { @@ -59,7 +52,7 @@ function migrateHeartbeatVisibility(raw: Record, 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; + 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(); + 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; +} diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index c14bd4115a86..238d1487f7c1 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -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.", - migration: "src/commands/doctor/shared/legacy-web-search-migrate.ts", + migration: "src/commands/doctor/shared/legacy-web-tools-migrate.ts", replacement: "plugins.entries..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", ], }), diff --git a/src/commands/doctor/shared/legacy-config-compatibility-base.ts b/src/commands/doctor/shared/legacy-config-compatibility-base.ts index 992ae9fa0844..984730b29d95 100644 --- a/src/commands/doctor/shared/legacy-config-compatibility-base.ts +++ b/src/commands/doctor/shared/legacy-config-compatibility-base.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( diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index ab36cc53b0d1..759869acfbca 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -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 { - 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; diff --git a/src/commands/doctor/shared/legacy-config-issues.ts b/src/commands/doctor/shared/legacy-config-issues.ts index 1fbe062c7930..2774bbce45bf 100644 --- a/src/commands/doctor/shared/legacy-config-issues.ts +++ b/src/commands/doctor/shared/legacy-config-issues.ts @@ -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 { - 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( diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 85e698264f7b..187040b3d25d 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -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" }, diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts index a5547019cb4c..0d1425088d24 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts @@ -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; -} { - const base = getRecord(entry) ? { ...(entry as Record) } : {}; - 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, pathLabel: string, diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts index 0f5ec2b84240..41a2ed7dfe62 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts @@ -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"; diff --git a/src/commands/doctor/shared/legacy-config-migrations.web-search.ts b/src/commands/doctor/shared/legacy-config-migrations.web-search.ts index dab10a476910..05b505670017 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.web-search.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.web-search.ts @@ -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[] = [ { diff --git a/src/commands/doctor/shared/legacy-oauth-sidecar.ts b/src/commands/doctor/shared/legacy-oauth-sidecar.ts index b33531c078e8..940497238d41 100644 --- a/src/commands/doctor/shared/legacy-oauth-sidecar.ts +++ b/src/commands/doctor/shared/legacy-oauth-sidecar.ts @@ -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[] { 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), ); } diff --git a/src/commands/doctor/shared/legacy-runtime-model-policy.ts b/src/commands/doctor/shared/legacy-runtime-model-policy.ts new file mode 100644 index 000000000000..66fe93462af3 --- /dev/null +++ b/src/commands/doctor/shared/legacy-runtime-model-policy.ts @@ -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 } { + 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 }; +} diff --git a/src/commands/doctor/shared/legacy-web-fetch-migrate.test.ts b/src/commands/doctor/shared/legacy-web-fetch-migrate.test.ts deleted file mode 100644 index 8afe3623abae..000000000000 --- a/src/commands/doctor/shared/legacy-web-fetch-migrate.test.ts +++ /dev/null @@ -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", - }, - }, - }); - }); -}); diff --git a/src/commands/doctor/shared/legacy-web-fetch-migrate.ts b/src/commands/doctor/shared/legacy-web-fetch-migrate.ts deleted file mode 100644 index 63f4b09ffcfb..000000000000 --- a/src/commands/doctor/shared/legacy-web-fetch-migrate.ts +++ /dev/null @@ -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(raw: T): { config: T; changes: string[] } { - if (!isRecord(raw) || !hasMappedLegacyWebFetchConfig(raw)) { - return { config: raw, changes: [] }; - } - return normalizeLegacyWebFetchConfigRecord(raw); -} - -function normalizeLegacyWebFetchConfigRecord( - 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 }; -} diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.test.ts b/src/commands/doctor/shared/legacy-web-search-migrate.test.ts deleted file mode 100644 index 4b96b271b119..000000000000 --- a/src/commands/doctor/shared/legacy-web-search-migrate.test.ts +++ /dev/null @@ -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 & { - tools?: { - web?: { - search?: Record; - }; - }; -}; - -describe("legacy web search config", () => { - it("migrates legacy provider config through bundled web search ownership metadata", () => { - const res = migrateLegacyWebSearchConfig({ - 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({ - tools: { - web: { - search: { grok: { model: "grok-code-fast-1" } }, - }, - }, - }); - const current = migrateLegacyWebSearchConfig({ - 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(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({ - 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({ - 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", - ]); - }); -}); diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.ts b/src/commands/doctor/shared/legacy-web-search-migrate.ts deleted file mode 100644 index 60f7fb759af9..000000000000 --- a/src/commands/doctor/shared/legacy-web-search-migrate.ts +++ /dev/null @@ -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([ - ["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..*` 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 { - return BUNDLED_LEGACY_WEB_SEARCH_OWNERS; -} - -function getLegacyWebSearchProviderIds( - owners: ReadonlyMap = 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): Set { - 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, -): 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, -): { - 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(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( - raw: T, - owners: ReadonlyMap, -): { - 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 }; -} diff --git a/src/commands/doctor/shared/legacy-web-tools-migrate.test.ts b/src/commands/doctor/shared/legacy-web-tools-migrate.test.ts new file mode 100644 index 000000000000..50152726b9d6 --- /dev/null +++ b/src/commands/doctor/shared/legacy-web-tools-migrate.test.ts @@ -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 & { + tools?: { web?: { search?: Record } }; +}; + +describe("legacy web search config", () => { + it("migrates provider config in deterministic owner order", () => { + const result = migrateLegacyWebSearchConfig({ + 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({ + tools: { web: { search: { grok: { model: "grok-code-fast-1" } } } }, + }); + const current = migrateLegacyWebSearchConfig({ + 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({ + 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, + }, + } as OpenClawConfig); + + const web = result.config.tools?.web as Record | 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([]); + }); +}); diff --git a/src/commands/doctor/shared/legacy-web-tools-migrate.ts b/src/commands/doctor/shared/legacy-web-tools-migrate.ts new file mode 100644 index 000000000000..3af2aa71d923 --- /dev/null +++ b/src/commands/doctor/shared/legacy-web-tools-migrate.ts @@ -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([ + ["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): 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( + 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(raw: T): { config: T; changes: string[] } { + return migrateLegacyWebSlot(raw, "search", prepareWebSearch); +} + +/** Move legacy Firecrawl web-fetch config into plugin-owned config. */ +export function migrateLegacyWebFetchConfig(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(raw: T): { config: T; changes: string[] } { + return migrateLegacyWebSlot(raw, "x_search", prepareXSearch); +} diff --git a/src/commands/doctor/shared/legacy-x-search-migrate.test.ts b/src/commands/doctor/shared/legacy-x-search-migrate.test.ts deleted file mode 100644 index 2ddc12f78777..000000000000 --- a/src/commands/doctor/shared/legacy-x-search-migrate.test.ts +++ /dev/null @@ -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, - }, - } as OpenClawConfig); - - expect((res.config.tools?.web as Record | 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, - }, - plugins: { - entries: { - xai: { - enabled: true, - config: { - webSearch: { - apiKey: "plugin-key", - }, - xSearch: { - model: "plugin-model", - }, - }, - }, - }, - }, - } as OpenClawConfig); - - expect((res.config.tools?.web as Record | 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, - }, - } as OpenClawConfig); - - expect((res.config.tools?.web as Record | 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, - }, - } as OpenClawConfig; - - const res = migrateLegacyXSearchConfig(config); - - expect((res.config.tools?.web as Record | 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 | 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: [], - }); - }); -}); diff --git a/src/commands/doctor/shared/legacy-x-search-migrate.ts b/src/commands/doctor/shared/legacy-x-search-migrate.ts deleted file mode 100644 index b77682f88dfa..000000000000 --- a/src/commands/doctor/shared/legacy-x-search-migrate.ts +++ /dev/null @@ -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; - -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(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(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, - }; -} diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ids.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ids.ts index e330ea57ad13..87ae3c31c4f5 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ids.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ids.ts @@ -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 { - const ids = new Set(); 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: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index cd586a8074f6..94a42c4267dd 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -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" } } }, diff --git a/src/commands/doctor/shared/plugin-dependency-cleanup.ts b/src/commands/doctor/shared/plugin-dependency-cleanup.ts index 83b4cd127a83..5787b11d90f5 100644 --- a/src/commands/doctor/shared/plugin-dependency-cleanup.ts +++ b/src/commands/doctor/shared/plugin-dependency-cleanup.ts @@ -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 { 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 { 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`, }; diff --git a/src/commands/doctor/shared/plugin-runtime-symlinks.ts b/src/commands/doctor/shared/plugin-runtime-symlinks.ts index 7640cbea9d1b..59f9033223a4 100644 --- a/src/commands/doctor/shared/plugin-runtime-symlinks.ts +++ b/src/commands/doctor/shared/plugin-runtime-symlinks.ts @@ -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 { diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 35ab0f8d69be..16f02d3ed09a 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -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 () => { diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 8a3990b971e2..35cf26da45dd 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -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(); - 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( diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index dd07de0a580d..12e2fd3fe5c6 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -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", () => { diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index f6279ec451d2..d01673c5c7ca 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -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)); } diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index 785d8739c8bd..dd4fe3c685bc 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -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: { diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 84a457409ede..fc7896e4caf6 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -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; }