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