diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd70dee1cc..b34cf87224e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams. - Agents/commands: add `/steer ` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934) - Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions. +- Doctor/config: `doctor --fix` now commits safe legacy migrations even when unrelated validation issues (e.g. a missing plugin) prevent full validation from passing, so `agents.defaults.llm` and other known-legacy keys are always cleaned up by `doctor --fix` regardless of other config problems. (#76798) - Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan. - Discord/status: let explicit reaction tool calls opt into tracking subsequent tool progress on the reacted message with `trackToolCalls: true`, and use the shared tool display emoji table for status reactions. - Gateway/config: stop Gateway startup and hot reload from auto-restoring invalid config; invalid config now fails closed and `openclaw doctor --fix` owns last-known-good repair. diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index e5d95c1cb61..d63fe7a995a 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -196,11 +196,17 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { return changes.length > 0 ? { next, changes } : { next: null, changes: [] }; } + let partiallyValidOverride: boolean | undefined; + return { migrate, migrateLegacyConfig: (raw: unknown) => { const { next, changes } = migrate(raw); - return { config: next, changes }; + const partiallyValid = partiallyValidOverride; + return { config: next, changes, ...(partiallyValid ? { partiallyValid } : {}) }; + }, + setPartiallyValidOverride(value: boolean | undefined) { + partiallyValidOverride = value; }, }; }); @@ -595,7 +601,7 @@ vi.mock("./doctor/shared/channel-legacy-config-migrate.js", () => ({ })); vi.mock("./doctor/shared/legacy-config-migrate.js", () => ({ - migrateLegacyConfig: legacyConfigMigrationForTest.migrateLegacyConfig, + migrateLegacyConfig: (raw: unknown) => legacyConfigMigrationForTest.migrateLegacyConfig(raw), })); vi.mock("./doctor/shared/bundled-plugin-load-paths.js", () => ({ @@ -2643,4 +2649,23 @@ describe("doctor config flow", () => { { skipSessionCleanup: true }, ); }); + + it("sets skipPluginValidationOnWrite when legacy migration is only partially valid (#76800)", async () => { + legacyConfigMigrationForTest.setPartiallyValidOverride(true); + try { + const result = await runDoctorConfigWithInput({ + config: { + heartbeat: { model: "openai/gpt-4o", every: 60 }, + tools: { web: { search: { provider: "brave" } } }, + }, + repair: true, + preflightMode: "compat", + run: ({ options, confirm }) => + loadAndMaybeMigrateDoctorConfig({ options, confirm: async () => confirm() }), + }); + expect(result.skipPluginValidationOnWrite).toBe(true); + } finally { + legacyConfigMigrationForTest.setPartiallyValidOverride(undefined); + } + }); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index b89c5c54eec..0f834da73d6 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -87,6 +87,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { doctorFixCommand, }); ({ cfg, candidate, pendingChanges, fixHints } = legacyStep.state); + const legacyMigrationPartiallyValid = legacyStep.partiallyValid === true; const pluginLegacyIssues = await (async () => { if (snapshot.parsed === snapshot.sourceConfig) { return []; @@ -280,5 +281,6 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { shouldWriteConfig: finalized.shouldWriteConfig, sourceConfigValid: snapshot.valid, ...(sourceLastTouchedVersion ? { sourceLastTouchedVersion } : {}), + ...(legacyMigrationPartiallyValid ? { skipPluginValidationOnWrite: true } : {}), }; } diff --git a/src/commands/doctor/shared/config-flow-steps.test.ts b/src/commands/doctor/shared/config-flow-steps.test.ts index ab1aa49f956..77f9e48c41c 100644 --- a/src/commands/doctor/shared/config-flow-steps.test.ts +++ b/src/commands/doctor/shared/config-flow-steps.test.ts @@ -101,6 +101,44 @@ describe("doctor config flow steps", () => { ); }); + it("commits migration even when post-migration validation has unrelated issues (#76798)", () => { + const migratedConfig = { agents: { defaults: { model: { primary: "openai/gpt-5.4" } } } }; + migrateLegacyConfigMock.mockReturnValueOnce({ + config: migratedConfig, + changes: ["Removed agents.defaults.llm; model idle timeout now follows models.providers."], + partiallyValid: true, + }); + + const result = createLegacyStepResult({ + exists: true, + parsed: { + agents: { + defaults: { llm: { idleTimeoutSeconds: 120 }, model: { primary: "openai/gpt-5.4" } }, + }, + tools: { web: { search: { provider: "brave" } } }, + }, + legacyIssues: [{ path: "agents.defaults.llm", message: "deprecated key" }], + path: "/tmp/config.json", + valid: false, + issues: [ + { + path: "tools.web.search.provider", + message: "web_search provider is not available: brave", + }, + ], + raw: "{}", + resolved: {}, + sourceConfig: {}, + config: {}, + runtimeConfig: {}, + warnings: [], + } satisfies DoctorConfigPreflightResult["snapshot"]); + + expect(result.state.candidate).toEqual(migratedConfig); + expect(result.state.cfg).toEqual(migratedConfig); + expect(result.state.pendingChanges).toBe(true); + }); + it("removes unknown keys and adds preview hint", () => { stripUnknownConfigKeysMock.mockReturnValueOnce({ config: {}, diff --git a/src/commands/doctor/shared/config-flow-steps.ts b/src/commands/doctor/shared/config-flow-steps.ts index 922f9952eac..79f17abe604 100644 --- a/src/commands/doctor/shared/config-flow-steps.ts +++ b/src/commands/doctor/shared/config-flow-steps.ts @@ -13,6 +13,7 @@ export function applyLegacyCompatibilityStep(params: { state: DoctorConfigMutationState; issueLines: string[]; changeLines: string[]; + partiallyValid?: boolean; } { if (params.snapshot.legacyIssues.length === 0) { return { @@ -23,7 +24,7 @@ export function applyLegacyCompatibilityStep(params: { } const issueLines = formatConfigIssueLines(params.snapshot.legacyIssues, "-"); - const { config: migrated, changes } = migrateLegacyConfig(params.snapshot.parsed); + const { config: migrated, changes, partiallyValid } = migrateLegacyConfig(params.snapshot.parsed); if (!migrated) { return { state: { @@ -45,6 +46,9 @@ export function applyLegacyCompatibilityStep(params: { state: { // Doctor should keep using the best-effort migrated shape in memory even // during preview mode; confirmation only controls whether we write it. + // When partiallyValid, the migration succeeded but unrelated validation issues + // remain — still commit the migration so doctor --fix always applies safe migrations + // even when other problems prevent full validation from passing. cfg: migrated, candidate: migrated, // The read path can normalize legacy config into the snapshot before @@ -55,11 +59,12 @@ export function applyLegacyCompatibilityStep(params: { ? params.state.fixHints : [ ...params.state.fixHints, - `Run "${params.doctorFixCommand}" to migrate legacy config keys.`, + `Run "${params.doctorFixCommand}" to ${partiallyValid ? "finish fixing" : "migrate"} legacy config keys.`, ], }, issueLines, changeLines: changes, + partiallyValid: partiallyValid === true ? true : undefined, }; } diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 8e422ce2647..f6a7e548bb7 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../config/types.js"; +import { migrateLegacyConfig } from "./legacy-config-migrate.js"; import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js"; function migrateLegacyConfigForTest(raw: unknown): { @@ -210,6 +211,38 @@ describe("legacy migrate mention routing", () => { }); describe("legacy migrate sandbox scope aliases", () => { + it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => { + const res = migrateLegacyConfig({ + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + llm: { idleTimeoutSeconds: 120 }, + }, + }, + plugins: { + entries: { + brave: { + enabled: true, + config: { webSearch: { mode: "definitely-invalid" } }, + }, + }, + }, + tools: { web: { search: { provider: "brave" } } }, + }); + + expect(res.partiallyValid).toBe(true); + expect(res.changes).toContain( + "Removed agents.defaults.llm; model idle timeout now follows models.providers..timeoutSeconds.", + ); + expect(res.changes).toContain( + "Migration applied; other validation issues remain — run doctor to review.", + ); + expect(res.config?.agents?.defaults).toEqual({ + model: { primary: "openai/gpt-5.5" }, + }); + expect(res.config?.tools?.web?.search?.provider).toBe("brave"); + }); + it("removes legacy agents.defaults.llm timeout config", () => { const res = migrateLegacyConfigForTest({ agents: { diff --git a/src/commands/doctor/shared/legacy-config-migrate.ts b/src/commands/doctor/shared/legacy-config-migrate.ts index e1707269b51..987c62bcd9a 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.ts @@ -5,6 +5,7 @@ import { applyLegacyDoctorMigrations } from "./legacy-config-compat.js"; export function migrateLegacyConfig(raw: unknown): { config: OpenClawConfig | null; changes: string[]; + partiallyValid?: boolean; } { const { next, changes } = applyLegacyDoctorMigrations(raw); if (!next) { @@ -12,8 +13,8 @@ export function migrateLegacyConfig(raw: unknown): { } const validated = validateConfigObjectWithPlugins(next); if (!validated.ok) { - changes.push("Migration applied, but config still invalid; fix remaining issues manually."); - return { config: null, changes }; + changes.push("Migration applied; other validation issues remain — run doctor to review."); + return { config: next as OpenClawConfig, changes, partiallyValid: true }; } return { config: validated.config, changes }; } diff --git a/src/config/io.ts b/src/config/io.ts index 50d92955096..39aa174114c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -224,6 +224,12 @@ export type ConfigWriteOptions = { * Omitted means the observer should use its normal reload plan. */ afterWrite?: ConfigWriteAfterWrite; + /** + * Skip plugin-aware validation before writing. Use only for safe partial + * migrations (e.g. legacy key removal) where the base schema is valid but + * an unrelated plugin rule prevents the full write from succeeding. + */ + skipPluginValidation?: boolean; }; export type ReadConfigFileSnapshotForWriteResult = { @@ -2016,7 +2022,10 @@ export function createConfigIO( persistCandidate = applyUnsetPathsForWrite(persistCandidate as OpenClawConfig, unsetPaths); - const validated = validateConfigObjectRawWithPlugins(persistCandidate, { env: deps.env }); + const validated = validateConfigObjectRawWithPlugins(persistCandidate, { + env: deps.env, + pluginValidation: options.skipPluginValidation ? "skip" : "full", + }); if (!validated.ok) { const issue = validated.issues[0]; const pathLabel = issue?.path ? issue.path : ""; @@ -2421,7 +2430,7 @@ export async function writeConfigFile( cfg: OpenClawConfig, options: ConfigWriteOptions = {}, ): Promise { - const io = createConfigIO(); + const io = createConfigIO(options.skipPluginValidation ? { pluginValidation: "skip" } : {}); let nextCfg = cfg; const runtimeConfigSnapshot = getRuntimeConfigSnapshotState(); const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState(); @@ -2442,6 +2451,7 @@ export async function writeConfigFile( allowConfigSizeDrop: options.allowConfigSizeDrop, skipRuntimeSnapshotRefresh: options.skipRuntimeSnapshotRefresh, skipOutputLogs: options.skipOutputLogs, + skipPluginValidation: options.skipPluginValidation, }); if ( options.skipRuntimeSnapshotRefresh && diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 99652897681..ba1d1c46a82 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1219,6 +1219,69 @@ describe("config io write", () => { }); }); + it("skipPluginValidation bypasses plugin schema rejection on writeConfigFile (#76800)", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, "{}\n", "utf-8"); + mockLoadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "strict-plugin", + origin: "bundled", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + rootDir: "/tmp/openclaw-test-strict-plugin", + source: "/tmp/openclaw-test-strict-plugin/index.ts", + manifestPath: "/tmp/openclaw-test-strict-plugin/openclaw.plugin.json", + configSchema: { + type: "object", + properties: { token: { type: "string" } }, + required: ["token"], + additionalProperties: false, + }, + }, + ], + } satisfies PluginManifestRegistry); + + try { + // Plugin is enabled but missing required "token" — validation fails without skip. + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }] }, + plugins: { entries: { "strict-plugin": { enabled: true } } }, + }; + + await expect(writeConfigFile(cfg, { skipPluginValidation: true })).resolves.not.toThrow(); + await expect(fs.readFile(configPath, "utf-8")).resolves.toContain('"strict-plugin"'); + + await expect(writeConfigFile(cfg, { skipPluginValidation: false })).rejects.toThrow( + /Config validation failed/, + ); + await expect( + writeConfigFile({ agents: { list: "not-array" } } as unknown as OpenClawConfig, { + skipPluginValidation: true, + }), + ).rejects.toThrow(/Config validation failed/); + } finally { + mockLoadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [], + } satisfies PluginManifestRegistry); + if (previousConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + } + } + }); + }); + it("preserves authored tilde paths when runtime-shaped writes hand back absolute paths", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/config/mutate.ts b/src/config/mutate.ts index f81493a533d..95019d8f3da 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -152,6 +152,12 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { } const nextConfigRecord = nextConfig as Record; + if (params.writeOptions?.skipPluginValidation) { + // Skip the include fast path so the root writer handles the write with + // plugin validation disabled end-to-end (including the post-write readback). + return false; + } + const validated = validateConfigObjectWithPlugins(nextConfig); if (!validated.ok) { throw createInvalidConfigError( diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 9ef4ecf9424..8b64d9a1ef0 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -14,6 +14,7 @@ type DoctorConfigResult = { shouldWriteConfig?: boolean; sourceConfigValid?: boolean; sourceLastTouchedVersion?: string; + skipPluginValidationOnWrite?: boolean; }; type DoctorHealthFlowContext = { @@ -566,6 +567,7 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise afterWrite: { mode: "auto" }, writeOptions: { allowConfigSizeDrop: ctx.configResult.shouldWriteConfig === true, + skipPluginValidation: ctx.configResult.skipPluginValidationOnWrite === true, }, }); logConfigUpdated(ctx.runtime);