From f78434985af9bd76cd5dea6489e18881c2b359da Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 12:44:33 +0800 Subject: [PATCH] fix(update): skip plugin validation during package repair reads --- CHANGELOG.md | 1 + src/cli/update-cli.test.ts | 13 +++++ src/cli/update-cli/update-command.ts | 5 +- src/commands/doctor-config-preflight.test.ts | 23 +++++++- src/commands/doctor-config-preflight.ts | 16 ++++-- src/config/io.ts | 8 ++- src/config/mutate.test.ts | 29 +++++++++++ src/config/mutate.ts | 27 ++++++++-- src/flows/doctor-health-contributions.test.ts | 52 +++++++++++++++++++ src/flows/doctor-health-contributions.ts | 10 ++-- 10 files changed, 168 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d6df4de270..563259169a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Telegram/Gateway: route targeted Telegram `/stop@bot` messages onto the control lane without cached bot metadata and match gateway stop requests across raw/canonical session aliases. (#82298) Thanks @VACInc. - MS Teams/media: sniff inline `data:image/*` attachment bytes before staging them, skipping payloads that are not actually images. - Update: let package-swap `doctor --fix` persist core config repairs while plugin schemas are still converging, preventing update failures on externalized channel configs. +- Update: carry plugin-validation bypasses into config mutation pre-write reads, so package update doctor repairs can finish while externalized plugin schemas are converging. - Agents/subagents: warn and continue completion announce cleanup when lifecycle cleanup fails, preventing ended subagent runs from becoming silent ghosts. Fixes #82306. Thanks @SebTardif. - Telegram: let authorized text `/stop` commands use the fast-abort path before queued agent work, so active turns stop immediately instead of processing the abort after the turn finishes; foreign-bot `/stop@otherbot` mentions now stay on the regular topic lane instead of being routed into our control lane. Fixes #82162. Thanks @civiltox. - Agents/timeouts: clarify model idle-timeout errors and docs so provider `timeoutSeconds` is shown as bounded by the whole agent/run timeout ceiling. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 1d404b52443..3ebde926523 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -736,6 +736,14 @@ describe("update-cli", () => { tempDirsToCleanup.clear(); }); + it("reads the initial update config without plugin schema validation", async () => { + await updateCommand({ yes: true, restart: false }); + + expect(vi.mocked(readConfigFileSnapshot).mock.calls[0]?.[0]).toEqual({ + skipPluginValidation: true, + }); + }); + it("bounds completion cache refresh during update follow-up", async () => { const root = createCaseDir("openclaw-completion-timeout"); pathExists.mockResolvedValue(true); @@ -1158,6 +1166,11 @@ describe("update-cli", () => { }, baseHash: "stable-hash", }); + expect(mutateConfigFileWithRetry).toHaveBeenCalledWith( + expect.objectContaining({ + writeOptions: { skipPluginValidation: true }, + }), + ); expect(syncPluginCall()?.channel).toBe("dev"); expect(syncPluginCall()?.config?.update?.channel).toBe("dev"); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index e816bb235d8..d3bf8f695f4 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1825,7 +1825,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis assertConfigWriteAllowedInCurrentMode(); const root = await resolveUpdateRoot(); - let configSnapshot = await readConfigFileSnapshot(); + let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); const requestedChannel = normalizeUpdateChannel(opts.channel); if (opts.channel && !requestedChannel) { defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`); @@ -1927,6 +1927,7 @@ async function persistRequestedUpdateChannel(params: { const requestedChannel = params.requestedChannel; const mutation = await mutateConfigFileWithRetry({ + writeOptions: { skipPluginValidation: true }, mutate: (draft) => { draft.update = { ...draft.update, @@ -2337,7 +2338,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - let configSnapshot = await readConfigFileSnapshot(); + let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); if (opts.channel && !opts.dryRun && !configSnapshot.valid) { configSnapshot = await maybeRepairLegacyConfigForUpdateChannel({ configSnapshot, diff --git a/src/commands/doctor-config-preflight.test.ts b/src/commands/doctor-config-preflight.test.ts index a9913e5dfac..42486971ee3 100644 --- a/src/commands/doctor-config-preflight.test.ts +++ b/src/commands/doctor-config-preflight.test.ts @@ -2,9 +2,30 @@ import fs from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { promoteConfigSnapshotToLastKnownGood, readConfigFileSnapshot } from "../config/config.js"; import { withTempHome, writeOpenClawConfig } from "../config/test-helpers.js"; -import { runDoctorConfigPreflight } from "./doctor-config-preflight.js"; +import { + runDoctorConfigPreflight, + shouldSkipPluginValidationForDoctorConfigPreflight, +} from "./doctor-config-preflight.js"; describe("runDoctorConfigPreflight", () => { + it("skips plugin schema validation while doctor is running inside update", () => { + expect( + shouldSkipPluginValidationForDoctorConfigPreflight({ + OPENCLAW_UPDATE_IN_PROGRESS: "1", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect( + shouldSkipPluginValidationForDoctorConfigPreflight({ + OPENCLAW_UPDATE_IN_PROGRESS: "true", + } as NodeJS.ProcessEnv), + ).toBe(true); + expect( + shouldSkipPluginValidationForDoctorConfigPreflight({ + OPENCLAW_UPDATE_IN_PROGRESS: "0", + } as NodeJS.ProcessEnv), + ).toBe(false); + }); + it("collects legacy config issues outside the normal config read path", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 8e532c21134..4ba5128c52c 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -8,6 +8,7 @@ import { import { formatConfigIssueLines } from "../config/issue-format.js"; import type { LegacyConfigIssue } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { note } from "../terminal/note.js"; import { resolveHomeDir } from "../utils.js"; import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js"; @@ -82,6 +83,12 @@ function addDoctorLegacyIssues( return { ...snapshot, legacyIssues }; } +export function shouldSkipPluginValidationForDoctorConfigPreflight( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return isTruthyEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS); +} + export async function runDoctorConfigPreflight( options: { migrateState?: boolean; @@ -108,11 +115,14 @@ export async function runDoctorConfigPreflight( } } - let snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot()); + const readOptions = { + skipPluginValidation: shouldSkipPluginValidationForDoctorConfigPreflight(), + }; + let snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions)); if (options.repairPrefixedConfig === true && snapshot.exists && !snapshot.valid) { if (await recoverConfigFromJsonRootSuffix(snapshot)) { note("Removed non-JSON prefix from openclaw.json; original saved as .clobbered.*.", "Config"); - snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot()); + snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions)); } else if ( await recoverConfigFromLastKnownGood({ snapshot, reason: "doctor-invalid-config" }) ) { @@ -120,7 +130,7 @@ export async function runDoctorConfigPreflight( "Restored openclaw.json from last-known-good; original saved as .clobbered.*.", "Config", ); - snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot()); + snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions)); } } const invalidConfigNote = diff --git a/src/config/io.ts b/src/config/io.ts index 8159424c793..3285d0b220b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -2407,8 +2407,12 @@ export async function readSourceConfigSnapshot(): Promise { return await readConfigFileSnapshot(); } -export async function readConfigFileSnapshotForWrite(): Promise { - return await createConfigIO().readConfigFileSnapshotForWrite(); +export async function readConfigFileSnapshotForWrite(options?: { + skipPluginValidation?: boolean; +}): Promise { + return await createConfigIO( + options?.skipPluginValidation ? { pluginValidation: "skip" } : {}, + ).readConfigFileSnapshotForWrite(); } export async function readSourceConfigSnapshotForWrite(): Promise { diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index a30133dc3a4..cb6ca246912 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -331,6 +331,35 @@ describe("config mutate helpers", () => { ); }); + it("uses skipPluginValidation for replace pre-write snapshots", async () => { + const snapshot = createSnapshot({ + hash: "hash-1", + sourceConfig: { plugins: { entries: { "strict-plugin": { enabled: true } } } }, + }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + await replaceConfigFile({ + nextConfig: { plugins: { entries: { "strict-plugin": { enabled: false } } } }, + writeOptions: { skipPluginValidation: true }, + }); + + expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledWith({ + skipPluginValidation: true, + }); + expect(ioMocks.writeConfigFile).toHaveBeenCalledWith( + { plugins: { entries: { "strict-plugin": { enabled: false } } } }, + { + baseSnapshot: snapshot, + expectedConfigPath: snapshot.path, + skipPluginValidation: true, + afterWrite: { mode: "auto" }, + }, + ); + }); + it("returns explicit restart follow-up intent for replace writes", async () => { const snapshot = createSnapshot({ hash: "hash-restart", diff --git a/src/config/mutate.ts b/src/config/mutate.ts index cd0a056bf72..37b3482c35c 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -185,6 +185,21 @@ function markActiveConfigMutationPath(configPath: string): void { activeConfigMutationLocks.getStore()?.add(path.resolve(configPath)); } +async function readConfigSnapshotForMutation(params: { + io?: ConfigMutationIO; + writeOptions?: ConfigWriteOptions; +}): Promise<{ + snapshot: ConfigFileSnapshot; + writeOptions: ConfigWriteOptions; +}> { + if (params.io) { + return await params.io.readConfigFileSnapshotForWrite(); + } + return await readConfigFileSnapshotForWrite({ + skipPluginValidation: params.writeOptions?.skipPluginValidation, + }); +} + function getChangedTopLevelKeys(base: unknown, next: unknown): string[] { if (!isRecord(base) || !isRecord(next)) { return isDeepStrictEqual(base, next) ? [] : [""]; @@ -372,7 +387,10 @@ async function replaceConfigFileUnlocked(params: { const prepared = params.snapshot && params.writeOptions ? { snapshot: params.snapshot, writeOptions: params.writeOptions } - : await (params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite)(); + : await readConfigSnapshotForMutation({ + io: params.io, + writeOptions: params.writeOptions, + }); const { snapshot, writeOptions } = prepared; assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path }); markActiveConfigMutationPath(snapshot.path); @@ -433,9 +451,10 @@ async function transformConfigFileAttempt( params: TransformConfigFileParams, attempt: number, ): Promise> { - const { snapshot, writeOptions } = await ( - params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite - )(); + const { snapshot, writeOptions } = await readConfigSnapshotForMutation({ + io: params.io, + writeOptions: params.writeOptions, + }); assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path }); markActiveConfigMutationPath(snapshot.path); const previousHash = assertBaseHashMatches(snapshot, params.baseHash); diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index b0a84feb10e..279c20ae1f8 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -10,6 +10,12 @@ const mocks = vi.hoisted(() => ({ maybeRunConfiguredPluginInstallReleaseStep: vi.fn(), note: vi.fn(), replaceConfigFile: vi.fn().mockResolvedValue(undefined), + readConfigFileSnapshot: vi.fn().mockResolvedValue({ + exists: true, + valid: true, + config: {}, + issues: [], + }), applyWizardMetadata: vi.fn((cfg: unknown) => cfg), logConfigUpdated: vi.fn(), shortenHomePath: vi.fn((p: string) => p), @@ -31,6 +37,7 @@ vi.mock("../version.js", () => ({ vi.mock("../config/config.js", () => ({ CONFIG_PATH: "/tmp/fake-openclaw.json", replaceConfigFile: mocks.replaceConfigFile, + readConfigFileSnapshot: mocks.readConfigFileSnapshot, })); vi.mock("../commands/onboard-helpers.js", () => ({ @@ -80,6 +87,13 @@ describe("doctor health contributions", () => { beforeEach(() => { mocks.maybeRunConfiguredPluginInstallReleaseStep.mockReset(); mocks.note.mockReset(); + mocks.readConfigFileSnapshot.mockReset(); + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + config: {}, + issues: [], + }); }); afterEach(() => { @@ -280,6 +294,44 @@ describe("doctor health contributions", () => { ); }); + it("skips plugin schema validation for final validation during update doctor runs", async () => { + const contribution = requireDoctorContribution("doctor:final-config-validation"); + + await contribution.run({ + cfg: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + }, + } as Parameters<(typeof contribution)["run"]>[0]); + + expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({ + skipPluginValidation: true, + }); + }); + + it("keeps plugin schema validation for ordinary doctor final validation", async () => { + const contribution = requireDoctorContribution("doctor:final-config-validation"); + + await contribution.run({ + cfg: {}, + configResult: { cfg: {} }, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + env: {}, + } as Parameters<(typeof contribution)["run"]>[0]); + + expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({ + skipPluginValidation: false, + }); + }); + it("allows allowConfigSizeDrop when not in update", async () => { const ctx = buildWriteConfigCtx({}); await writeConfigContribution.run(ctx); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index e2d1669d18e..f9860fce195 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -650,14 +650,16 @@ async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Prom } } -async function runFinalConfigValidationHealth(_ctx: DoctorHealthFlowContext): Promise { +async function runFinalConfigValidationHealth(ctx: DoctorHealthFlowContext): Promise { const { readConfigFileSnapshot } = await import("../config/config.js"); - const finalSnapshot = await readConfigFileSnapshot(); + const finalSnapshot = await readConfigFileSnapshot({ + skipPluginValidation: isUpdateDoctorRun(ctx.env ?? process.env), + }); if (finalSnapshot.exists && !finalSnapshot.valid) { - _ctx.runtime.error("Invalid config:"); + ctx.runtime.error("Invalid config:"); for (const issue of finalSnapshot.issues) { const path = issue.path || ""; - _ctx.runtime.error(`- ${path}: ${issue.message}`); + ctx.runtime.error(`- ${path}: ${issue.message}`); } } }