fix(config): preserve plugin metadata for missing config (#119421)

Restore plugin metadata for valid missing-config snapshot reads while preserving scan-free plain reads and fail-closed invalid snapshots.
This commit is contained in:
Vincent Koc
2026-08-05 11:22:42 +08:00
committed by GitHub
parent ab7b3ffd1f
commit 9371dddcdb
3 changed files with 202 additions and 11 deletions
@@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -20,14 +21,48 @@ const STARTUP_RECOVERY =
'Run "openclaw doctor --fix" against the same state/config, then restart the gateway.';
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function runIsolatedModuleScript(env: NodeJS.ProcessEnv, script: string) {
return spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", script], {
cwd: path.resolve("."),
encoding: "utf8",
env,
maxBuffer: 4 * 1024 * 1024,
timeout: 30_000,
});
function runIsolatedModuleScript(
env: NodeJS.ProcessEnv,
script: string,
options: { runtimeRoot?: string; timeoutMs?: number } = {},
) {
return spawnSync(
process.execPath,
[
...(options.runtimeRoot ? ["--preserve-symlinks"] : []),
"--import",
"tsx",
"--input-type=module",
"--eval",
script,
],
{
cwd: options.runtimeRoot ?? path.resolve("."),
encoding: "utf8",
env,
maxBuffer: 4 * 1024 * 1024,
timeout: options.timeoutMs ?? 30_000,
},
);
}
function createSourceRuntime(root: string): string {
const runtimeRoot = path.join(root, "runtime");
fs.mkdirSync(path.join(runtimeRoot, "dist"), { recursive: true });
for (const dirname of ["node_modules", "packages", "scripts", "src"]) {
fs.symlinkSync(
path.resolve(dirname),
path.join(runtimeRoot, dirname),
process.platform === "win32" ? "junction" : "dir",
);
}
fs.copyFileSync(path.resolve("package.json"), path.join(runtimeRoot, "package.json"));
fs.copyFileSync(path.resolve("tsconfig.json"), path.join(runtimeRoot, "tsconfig.json"));
fs.writeFileSync(
path.join(runtimeRoot, "dist", "build-info.json"),
JSON.stringify({ builtAt: "2026-08-05T00:00:00.000Z" }),
);
return runtimeRoot;
}
function seedPluginStateConflict(stateDir: string): void {
@@ -141,6 +176,80 @@ describe("gateway startup-migration refusal", () => {
}
}, 45_000);
it("reuses the state-migration checkpoint when the config file remains absent", async () => {
const root = await fs.promises.realpath(tempDirs.make("openclaw-configless-checkpoint-"));
const runtimeRoot = createSourceRuntime(root);
const stateDir = path.join(root, "state");
const configPath = path.join(root, "openclaw.json");
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "bundled"),
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_TEST_FAST: "1",
NO_COLOR: "1",
};
delete env.NODE_ENV;
delete env.OPENCLAW_HOME;
delete env.VITEST;
delete env.VITEST_POOL_ID;
delete env.VITEST_WORKER_ID;
const preflightUrl = pathToFileURL(
path.join(runtimeRoot, "src", "commands", "doctor-config-preflight.ts"),
).href;
const checkpointUrl = pathToFileURL(
path.join(runtimeRoot, "src", "infra", "startup-migration-checkpoint.ts"),
).href;
const script = `
const steps = [];
const { runDoctorConfigPreflight } = await import(${JSON.stringify(preflightUrl)});
const { hasActiveStartupMigrationLease } = await import(${JSON.stringify(checkpointUrl)});
await runDoctorConfigPreflight({
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
requireStateMigrationCheckpoint: true,
measure: async (name, run) => {
steps.push(name);
return await run();
},
});
console.log("__RESULT__" + JSON.stringify({
activeLease: hasActiveStartupMigrationLease({ env: process.env }),
stateMigrationsImported: steps.includes(
"doctor.config-preflight.state-migrations-import",
),
}));
`;
const run = () =>
runIsolatedModuleScript(env, script, {
runtimeRoot,
timeoutMs: 60_000,
});
const readResult = (result: ReturnType<typeof runIsolatedModuleScript>) => {
expect(result.error, `${result.stderr}\n${result.stdout}`).toBeUndefined();
expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0);
expect(result.signal, `${result.stderr}\n${result.stdout}`).toBeNull();
const resultLine = result.stdout.split("\n").find((line) => line.startsWith("__RESULT__"));
expect(resultLine, `${result.stderr}\n${result.stdout}`).toBeDefined();
return JSON.parse(resultLine!.slice("__RESULT__".length)) as {
activeLease: boolean;
stateMigrationsImported: boolean;
};
};
const first = readResult(run());
const second = readResult(run());
expect(first).toEqual({ activeLease: false, stateMigrationsImported: true });
expect(second).toEqual({ activeLease: false, stateMigrationsImported: false });
expect(fs.existsSync(configPath)).toBe(false);
}, 150_000);
it("persists a refreshed legacy plugin index for the next process", async () => {
const root = await fs.promises.realpath(tempDirs.make("openclaw-plugin-index-checkpoint-"));
const stateDir = path.join(root, "state");
+73
View File
@@ -0,0 +1,73 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { createConfigIoContext } from "./io.context.js";
import {
readConfigFileSnapshotFromContext,
readConfigFileSnapshotWithPluginMetadataFromContext,
} from "./io.snapshot.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
clearPluginMetadataLifecycleCaches();
closeOpenClawStateDatabaseForTest();
});
function createContext(root: string) {
const configPath = path.join(root, "openclaw.json");
const env: NodeJS.ProcessEnv = {
HOME: root,
USERPROFILE: root,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: path.join(root, "state"),
VITEST: "true",
};
return createConfigIoContext({
configPath,
env,
homedir: () => root,
observe: false,
});
}
describe("config snapshot plugin metadata", () => {
it("loads metadata for an explicit valid missing-config read without changing plain reads", async () => {
const root = tempDirs.make("openclaw-config-snapshot-metadata-");
const context = createContext(root);
const loader = vi.spyOn(context, "createValidationPluginMetadataSnapshotLoader");
const plainSnapshot = await readConfigFileSnapshotFromContext(context);
expect(plainSnapshot).toMatchObject({ exists: false, valid: true });
expect(loader).not.toHaveBeenCalled();
const result = await readConfigFileSnapshotWithPluginMetadataFromContext(context);
expect(result.snapshot).toMatchObject({ exists: false, valid: true });
expect(loader).toHaveBeenCalledOnce();
expect(result.pluginMetadataSnapshot?.configFingerprint).toMatch(/^[a-f0-9]{64}$/u);
expect(result.pluginMetadataSnapshot?.index).toMatchObject({
version: 1,
hostContractVersion: expect.any(String),
plugins: expect.any(Array),
});
});
it("does not invent plugin metadata for invalid snapshots", async () => {
const root = tempDirs.make("openclaw-config-snapshot-invalid-");
const context = createContext(root);
fs.writeFileSync(context.configPath, "{ invalid", "utf8");
const loader = vi.spyOn(context, "createValidationPluginMetadataSnapshotLoader");
const result = await readConfigFileSnapshotWithPluginMetadataFromContext(context);
expect(result.snapshot.valid).toBe(false);
expect(result.pluginMetadataSnapshot).toBeUndefined();
expect(loader).not.toHaveBeenCalled();
});
});
+12 -3
View File
@@ -370,11 +370,20 @@ export async function readConfigFileSnapshotWithPluginMetadataFromContext(
recoverSuspicious: options.recoverSuspicious === true,
allowSuspiciousRecovery: options.allowSuspiciousRecovery,
});
const pluginMetadataSnapshot =
result.pluginMetadataSnapshot ??
(result.snapshot.valid
? context
.createValidationPluginMetadataSnapshotLoader({
effectiveConfigRaw: result.snapshot.sourceConfig,
env: context.deps.env,
allowCurrentPluginMetadata: options.allowCurrentPluginMetadata,
})
.load(result.snapshot.sourceConfig)
: undefined);
return {
snapshot: result.snapshot,
...(result.pluginMetadataSnapshot
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
: {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}