fix(update): skip plugin validation during package repair reads

This commit is contained in:
Vincent Koc
2026-05-16 12:44:33 +08:00
parent 33685e1474
commit f78434985a
10 changed files with 168 additions and 16 deletions

View File

@@ -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.

View File

@@ -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");
});

View File

@@ -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<void> {
return;
}
let configSnapshot = await readConfigFileSnapshot();
let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
if (opts.channel && !opts.dryRun && !configSnapshot.valid) {
configSnapshot = await maybeRepairLegacyConfigForUpdateChannel({
configSnapshot,

View File

@@ -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, {

View File

@@ -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 =

View File

@@ -2407,8 +2407,12 @@ export async function readSourceConfigSnapshot(): Promise<ConfigFileSnapshot> {
return await readConfigFileSnapshot();
}
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
return await createConfigIO().readConfigFileSnapshotForWrite();
export async function readConfigFileSnapshotForWrite(options?: {
skipPluginValidation?: boolean;
}): Promise<ReadConfigFileSnapshotForWriteResult> {
return await createConfigIO(
options?.skipPluginValidation ? { pluginValidation: "skip" } : {},
).readConfigFileSnapshotForWrite();
}
export async function readSourceConfigSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {

View File

@@ -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",

View File

@@ -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) ? [] : ["<root>"];
@@ -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<T>(
params: TransformConfigFileParams<T>,
attempt: number,
): Promise<ConfigMutationResult<T>> {
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);

View File

@@ -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);

View File

@@ -650,14 +650,16 @@ async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Prom
}
}
async function runFinalConfigValidationHealth(_ctx: DoctorHealthFlowContext): Promise<void> {
async function runFinalConfigValidationHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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 || "<root>";
_ctx.runtime.error(`- ${path}: ${issue.message}`);
ctx.runtime.error(`- ${path}: ${issue.message}`);
}
}
}