Skip to content

Commit f784349

Browse files
committed
fix(update): skip plugin validation during package repair reads
1 parent 33685e1 commit f784349

10 files changed

Lines changed: 168 additions & 16 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222
- 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.
2323
- MS Teams/media: sniff inline `data:image/*` attachment bytes before staging them, skipping payloads that are not actually images.
2424
- Update: let package-swap `doctor --fix` persist core config repairs while plugin schemas are still converging, preventing update failures on externalized channel configs.
25+
- Update: carry plugin-validation bypasses into config mutation pre-write reads, so package update doctor repairs can finish while externalized plugin schemas are converging.
2526
- Agents/subagents: warn and continue completion announce cleanup when lifecycle cleanup fails, preventing ended subagent runs from becoming silent ghosts. Fixes #82306. Thanks @SebTardif.
2627
- 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.
2728
- Agents/timeouts: clarify model idle-timeout errors and docs so provider `timeoutSeconds` is shown as bounded by the whole agent/run timeout ceiling.

src/cli/update-cli.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,14 @@ describe("update-cli", () => {
736736
tempDirsToCleanup.clear();
737737
});
738738

739+
it("reads the initial update config without plugin schema validation", async () => {
740+
await updateCommand({ yes: true, restart: false });
741+
742+
expect(vi.mocked(readConfigFileSnapshot).mock.calls[0]?.[0]).toEqual({
743+
skipPluginValidation: true,
744+
});
745+
});
746+
739747
it("bounds completion cache refresh during update follow-up", async () => {
740748
const root = createCaseDir("openclaw-completion-timeout");
741749
pathExists.mockResolvedValue(true);
@@ -1158,6 +1166,11 @@ describe("update-cli", () => {
11581166
},
11591167
baseHash: "stable-hash",
11601168
});
1169+
expect(mutateConfigFileWithRetry).toHaveBeenCalledWith(
1170+
expect.objectContaining({
1171+
writeOptions: { skipPluginValidation: true },
1172+
}),
1173+
);
11611174
expect(syncPluginCall()?.channel).toBe("dev");
11621175
expect(syncPluginCall()?.config?.update?.channel).toBe("dev");
11631176
});

src/cli/update-cli/update-command.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1825,7 +1825,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
18251825
assertConfigWriteAllowedInCurrentMode();
18261826

18271827
const root = await resolveUpdateRoot();
1828-
let configSnapshot = await readConfigFileSnapshot();
1828+
let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
18291829
const requestedChannel = normalizeUpdateChannel(opts.channel);
18301830
if (opts.channel && !requestedChannel) {
18311831
defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`);
@@ -1927,6 +1927,7 @@ async function persistRequestedUpdateChannel(params: {
19271927
const requestedChannel = params.requestedChannel;
19281928

19291929
const mutation = await mutateConfigFileWithRetry({
1930+
writeOptions: { skipPluginValidation: true },
19301931
mutate: (draft) => {
19311932
draft.update = {
19321933
...draft.update,
@@ -2337,7 +2338,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
23372338
return;
23382339
}
23392340

2340-
let configSnapshot = await readConfigFileSnapshot();
2341+
let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
23412342
if (opts.channel && !opts.dryRun && !configSnapshot.valid) {
23422343
configSnapshot = await maybeRepairLegacyConfigForUpdateChannel({
23432344
configSnapshot,

src/commands/doctor-config-preflight.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,30 @@ import fs from "node:fs/promises";
22
import { describe, expect, it } from "vitest";
33
import { promoteConfigSnapshotToLastKnownGood, readConfigFileSnapshot } from "../config/config.js";
44
import { withTempHome, writeOpenClawConfig } from "../config/test-helpers.js";
5-
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
5+
import {
6+
runDoctorConfigPreflight,
7+
shouldSkipPluginValidationForDoctorConfigPreflight,
8+
} from "./doctor-config-preflight.js";
69

710
describe("runDoctorConfigPreflight", () => {
11+
it("skips plugin schema validation while doctor is running inside update", () => {
12+
expect(
13+
shouldSkipPluginValidationForDoctorConfigPreflight({
14+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
15+
} as NodeJS.ProcessEnv),
16+
).toBe(true);
17+
expect(
18+
shouldSkipPluginValidationForDoctorConfigPreflight({
19+
OPENCLAW_UPDATE_IN_PROGRESS: "true",
20+
} as NodeJS.ProcessEnv),
21+
).toBe(true);
22+
expect(
23+
shouldSkipPluginValidationForDoctorConfigPreflight({
24+
OPENCLAW_UPDATE_IN_PROGRESS: "0",
25+
} as NodeJS.ProcessEnv),
26+
).toBe(false);
27+
});
28+
829
it("collects legacy config issues outside the normal config read path", async () => {
930
await withTempHome(async (home) => {
1031
await writeOpenClawConfig(home, {

src/commands/doctor-config-preflight.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { formatConfigIssueLines } from "../config/issue-format.js";
99
import type { LegacyConfigIssue } from "../config/types.js";
1010
import type { OpenClawConfig } from "../config/types.openclaw.js";
11+
import { isTruthyEnvValue } from "../infra/env.js";
1112
import { note } from "../terminal/note.js";
1213
import { resolveHomeDir } from "../utils.js";
1314
import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js";
@@ -82,6 +83,12 @@ function addDoctorLegacyIssues(
8283
return { ...snapshot, legacyIssues };
8384
}
8485

86+
export function shouldSkipPluginValidationForDoctorConfigPreflight(
87+
env: NodeJS.ProcessEnv = process.env,
88+
): boolean {
89+
return isTruthyEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS);
90+
}
91+
8592
export async function runDoctorConfigPreflight(
8693
options: {
8794
migrateState?: boolean;
@@ -108,19 +115,22 @@ export async function runDoctorConfigPreflight(
108115
}
109116
}
110117

111-
let snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot());
118+
const readOptions = {
119+
skipPluginValidation: shouldSkipPluginValidationForDoctorConfigPreflight(),
120+
};
121+
let snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions));
112122
if (options.repairPrefixedConfig === true && snapshot.exists && !snapshot.valid) {
113123
if (await recoverConfigFromJsonRootSuffix(snapshot)) {
114124
note("Removed non-JSON prefix from openclaw.json; original saved as .clobbered.*.", "Config");
115-
snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot());
125+
snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions));
116126
} else if (
117127
await recoverConfigFromLastKnownGood({ snapshot, reason: "doctor-invalid-config" })
118128
) {
119129
note(
120130
"Restored openclaw.json from last-known-good; original saved as .clobbered.*.",
121131
"Config",
122132
);
123-
snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot());
133+
snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions));
124134
}
125135
}
126136
const invalidConfigNote =

src/config/io.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2407,8 +2407,12 @@ export async function readSourceConfigSnapshot(): Promise<ConfigFileSnapshot> {
24072407
return await readConfigFileSnapshot();
24082408
}
24092409

2410-
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
2411-
return await createConfigIO().readConfigFileSnapshotForWrite();
2410+
export async function readConfigFileSnapshotForWrite(options?: {
2411+
skipPluginValidation?: boolean;
2412+
}): Promise<ReadConfigFileSnapshotForWriteResult> {
2413+
return await createConfigIO(
2414+
options?.skipPluginValidation ? { pluginValidation: "skip" } : {},
2415+
).readConfigFileSnapshotForWrite();
24122416
}
24132417

24142418
export async function readSourceConfigSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {

src/config/mutate.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,35 @@ describe("config mutate helpers", () => {
331331
);
332332
});
333333

334+
it("uses skipPluginValidation for replace pre-write snapshots", async () => {
335+
const snapshot = createSnapshot({
336+
hash: "hash-1",
337+
sourceConfig: { plugins: { entries: { "strict-plugin": { enabled: true } } } },
338+
});
339+
ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
340+
snapshot,
341+
writeOptions: { expectedConfigPath: snapshot.path },
342+
});
343+
344+
await replaceConfigFile({
345+
nextConfig: { plugins: { entries: { "strict-plugin": { enabled: false } } } },
346+
writeOptions: { skipPluginValidation: true },
347+
});
348+
349+
expect(ioMocks.readConfigFileSnapshotForWrite).toHaveBeenCalledWith({
350+
skipPluginValidation: true,
351+
});
352+
expect(ioMocks.writeConfigFile).toHaveBeenCalledWith(
353+
{ plugins: { entries: { "strict-plugin": { enabled: false } } } },
354+
{
355+
baseSnapshot: snapshot,
356+
expectedConfigPath: snapshot.path,
357+
skipPluginValidation: true,
358+
afterWrite: { mode: "auto" },
359+
},
360+
);
361+
});
362+
334363
it("returns explicit restart follow-up intent for replace writes", async () => {
335364
const snapshot = createSnapshot({
336365
hash: "hash-restart",

src/config/mutate.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,21 @@ function markActiveConfigMutationPath(configPath: string): void {
185185
activeConfigMutationLocks.getStore()?.add(path.resolve(configPath));
186186
}
187187

188+
async function readConfigSnapshotForMutation(params: {
189+
io?: ConfigMutationIO;
190+
writeOptions?: ConfigWriteOptions;
191+
}): Promise<{
192+
snapshot: ConfigFileSnapshot;
193+
writeOptions: ConfigWriteOptions;
194+
}> {
195+
if (params.io) {
196+
return await params.io.readConfigFileSnapshotForWrite();
197+
}
198+
return await readConfigFileSnapshotForWrite({
199+
skipPluginValidation: params.writeOptions?.skipPluginValidation,
200+
});
201+
}
202+
188203
function getChangedTopLevelKeys(base: unknown, next: unknown): string[] {
189204
if (!isRecord(base) || !isRecord(next)) {
190205
return isDeepStrictEqual(base, next) ? [] : ["<root>"];
@@ -372,7 +387,10 @@ async function replaceConfigFileUnlocked(params: {
372387
const prepared =
373388
params.snapshot && params.writeOptions
374389
? { snapshot: params.snapshot, writeOptions: params.writeOptions }
375-
: await (params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite)();
390+
: await readConfigSnapshotForMutation({
391+
io: params.io,
392+
writeOptions: params.writeOptions,
393+
});
376394
const { snapshot, writeOptions } = prepared;
377395
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
378396
markActiveConfigMutationPath(snapshot.path);
@@ -433,9 +451,10 @@ async function transformConfigFileAttempt<T>(
433451
params: TransformConfigFileParams<T>,
434452
attempt: number,
435453
): Promise<ConfigMutationResult<T>> {
436-
const { snapshot, writeOptions } = await (
437-
params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite
438-
)();
454+
const { snapshot, writeOptions } = await readConfigSnapshotForMutation({
455+
io: params.io,
456+
writeOptions: params.writeOptions,
457+
});
439458
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
440459
markActiveConfigMutationPath(snapshot.path);
441460
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);

src/flows/doctor-health-contributions.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ const mocks = vi.hoisted(() => ({
1010
maybeRunConfiguredPluginInstallReleaseStep: vi.fn(),
1111
note: vi.fn(),
1212
replaceConfigFile: vi.fn().mockResolvedValue(undefined),
13+
readConfigFileSnapshot: vi.fn().mockResolvedValue({
14+
exists: true,
15+
valid: true,
16+
config: {},
17+
issues: [],
18+
}),
1319
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
1420
logConfigUpdated: vi.fn(),
1521
shortenHomePath: vi.fn((p: string) => p),
@@ -31,6 +37,7 @@ vi.mock("../version.js", () => ({
3137
vi.mock("../config/config.js", () => ({
3238
CONFIG_PATH: "/tmp/fake-openclaw.json",
3339
replaceConfigFile: mocks.replaceConfigFile,
40+
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
3441
}));
3542

3643
vi.mock("../commands/onboard-helpers.js", () => ({
@@ -80,6 +87,13 @@ describe("doctor health contributions", () => {
8087
beforeEach(() => {
8188
mocks.maybeRunConfiguredPluginInstallReleaseStep.mockReset();
8289
mocks.note.mockReset();
90+
mocks.readConfigFileSnapshot.mockReset();
91+
mocks.readConfigFileSnapshot.mockResolvedValue({
92+
exists: true,
93+
valid: true,
94+
config: {},
95+
issues: [],
96+
});
8397
});
8498

8599
afterEach(() => {
@@ -280,6 +294,44 @@ describe("doctor health contributions", () => {
280294
);
281295
});
282296

297+
it("skips plugin schema validation for final validation during update doctor runs", async () => {
298+
const contribution = requireDoctorContribution("doctor:final-config-validation");
299+
300+
await contribution.run({
301+
cfg: {},
302+
configResult: { cfg: {} },
303+
sourceConfigValid: true,
304+
prompter: buildDoctorPrompter(true),
305+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
306+
options: {},
307+
env: {
308+
OPENCLAW_UPDATE_IN_PROGRESS: "1",
309+
},
310+
} as Parameters<(typeof contribution)["run"]>[0]);
311+
312+
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({
313+
skipPluginValidation: true,
314+
});
315+
});
316+
317+
it("keeps plugin schema validation for ordinary doctor final validation", async () => {
318+
const contribution = requireDoctorContribution("doctor:final-config-validation");
319+
320+
await contribution.run({
321+
cfg: {},
322+
configResult: { cfg: {} },
323+
sourceConfigValid: true,
324+
prompter: buildDoctorPrompter(true),
325+
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
326+
options: {},
327+
env: {},
328+
} as Parameters<(typeof contribution)["run"]>[0]);
329+
330+
expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({
331+
skipPluginValidation: false,
332+
});
333+
});
334+
283335
it("allows allowConfigSizeDrop when not in update", async () => {
284336
const ctx = buildWriteConfigCtx({});
285337
await writeConfigContribution.run(ctx);

src/flows/doctor-health-contributions.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -650,14 +650,16 @@ async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Prom
650650
}
651651
}
652652

653-
async function runFinalConfigValidationHealth(_ctx: DoctorHealthFlowContext): Promise<void> {
653+
async function runFinalConfigValidationHealth(ctx: DoctorHealthFlowContext): Promise<void> {
654654
const { readConfigFileSnapshot } = await import("../config/config.js");
655-
const finalSnapshot = await readConfigFileSnapshot();
655+
const finalSnapshot = await readConfigFileSnapshot({
656+
skipPluginValidation: isUpdateDoctorRun(ctx.env ?? process.env),
657+
});
656658
if (finalSnapshot.exists && !finalSnapshot.valid) {
657-
_ctx.runtime.error("Invalid config:");
659+
ctx.runtime.error("Invalid config:");
658660
for (const issue of finalSnapshot.issues) {
659661
const path = issue.path || "<root>";
660-
_ctx.runtime.error(`- ${path}: ${issue.message}`);
662+
ctx.runtime.error(`- ${path}: ${issue.message}`);
661663
}
662664
}
663665
}

0 commit comments

Comments
 (0)