fix: block config writes when plugin install migration fails

This commit is contained in:
Shakker
2026-04-26 00:17:29 +01:00
parent 194c26bcd2
commit 1848d0dd38
2 changed files with 89 additions and 0 deletions

View File

@@ -1240,11 +1240,54 @@ export function createConfigIO(
err,
)}`,
);
return configRaw;
}
return stripped;
}
function ensureShippedPluginInstallConfigRecordsMigratedForWrite(
snapshot: ConfigFileSnapshot,
): void {
const installRecords = {
...extractShippedPluginInstallConfigRecords(snapshot.sourceConfig),
...extractShippedPluginInstallConfigRecords(snapshot.parsed),
};
if (Object.keys(installRecords).length === 0) {
return;
}
const stateDir = resolveStateDir(deps.env, deps.homedir);
const existingRecords = loadInstalledPluginIndexInstallRecordsSync({
env: deps.env,
stateDir,
});
if (Object.keys(installRecords).every((pluginId) => pluginId in existingRecords)) {
return;
}
try {
writePersistedInstalledPluginIndexInstallRecordsSync(
{
...installRecords,
...existingRecords,
},
{
config: coerceConfig(stripShippedPluginInstallConfigRecords(snapshot.sourceConfig)),
env: deps.env,
stateDir,
},
);
} catch (err) {
throw new Error(
`Config write blocked: shipped plugins.installs records in ${configPath} could not be migrated into the plugin index. Fix state directory permissions or run openclaw plugins registry --refresh, then retry. ${formatErrorMessage(
err,
)}`,
{ cause: err },
);
}
}
function loadConfig(): OpenClawConfig {
try {
maybeLoadDotEnvForConfig(deps.env);
@@ -1677,6 +1720,7 @@ export function createConfigIO(
const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths);
let persistCandidate: unknown = cfg;
const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot;
ensureShippedPluginInstallConfigRecordsMigratedForWrite(snapshot);
let envRefMap: Map<string, string> | null = null;
let changedPaths: Set<string> | null = null;
if (snapshot.valid && snapshot.exists) {

View File

@@ -185,6 +185,51 @@ describe("config io write", () => {
});
});
it("keeps shipped plugin install config records when index migration fails", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const unwritableStatePath = path.join(home, ".openclaw");
const pluginDir = path.join(unwritableStatePath, "plugins", "demo");
const original = {
plugins: {
entries: { demo: { enabled: true } },
installs: {
demo: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
},
},
};
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(original, null, 2)}\n`, "utf-8");
const warn = vi.fn();
const io = createConfigIO({
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: { warn, error: vi.fn() },
});
await fs.writeFile(path.join(unwritableStatePath, "plugins"), "not a directory", "utf-8");
expect(() => io.loadConfig()).toThrow('Unrecognized key: "installs"');
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("could not migrate shipped plugins.installs records"),
);
await expect(io.writeConfigFile({ gateway: { mode: "local" } })).rejects.toThrow(
"Config write blocked: shipped plugins.installs records",
);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as typeof original;
expect(persisted.plugins.installs.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
});
});
const writeGatewayPortAndReadConfig = async (home: string, configPath: string) => {
const io = createFastConfigIO(home);