diff --git a/src/config/io.ts b/src/config/io.ts index 1876d74dbb3..5c7fbe66890 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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 | null = null; let changedPaths: Set | null = null; if (snapshot.valid && snapshot.exists) { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 35aaee5a74c..31963adf9af 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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);