mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 16:44:45 +00:00
fix(update): skip plugin validation during package repair reads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user