From 7641aa80c23bea636d4ce0c8c2ac8f939521e508 Mon Sep 17 00:00:00 2001 From: ooiuuii Date: Fri, 17 Jul 2026 12:38:45 +0800 Subject: [PATCH] fix: plugins update rewrites config for install-record-only updates (#98422) * Avoid config rewrites for plugin index updates * Preserve config freshness for records-only plugin updates * Validate records-only plugin update config * fix(plugins): reload index-only updates Co-authored-by: luyifan * chore(protocol): refresh generated clients Co-authored-by: luyifan * fix(gateway): wire plugin refresh handler --------- Co-authored-by: Peter Steinberger --- .../openclaw/app/gateway/GatewayProtocol.kt | 1 + .../OpenClawProtocol/GatewayModels.swift | 16 + packages/gateway-protocol/src/index.ts | 8 + .../src/plugins-validators.test.ts | 9 + .../gateway-protocol/src/schema/plugins.ts | 10 + .../src/schema/protocol-schemas.ts | 4 + src/cli/plugins-cli-test-helpers.ts | 8 + src/cli/plugins-cli.update.test.ts | 337 +++++++++++++++++- src/cli/plugins-update-command.ts | 110 +++++- src/cli/plugins-update-gateway-signal.test.ts | 30 ++ src/cli/plugins-update-gateway-signal.ts | 34 ++ src/gateway/config-reload.test.ts | 40 +++ src/gateway/config-reload.ts | 18 +- src/gateway/local-request-context.ts | 1 + src/gateway/methods/core-descriptors.ts | 1 + .../methods/plugins-descriptors.test.ts | 5 + src/gateway/server-methods.ts | 1 + src/gateway/server-methods/plugins.test.ts | 15 +- src/gateway/server-methods/plugins.ts | 8 + src/gateway/server-methods/shared-types.ts | 1 + src/gateway/server-reload-handlers.ts | 3 +- src/gateway/server-request-context.test.ts | 17 +- src/gateway/server-request-context.ts | 2 + src/gateway/server-runtime-handles.ts | 6 +- src/plugins/install-record-commit.ts | 17 + 25 files changed, 674 insertions(+), 28 deletions(-) create mode 100644 src/cli/plugins-update-gateway-signal.test.ts create mode 100644 src/cli/plugins-update-gateway-signal.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 1813f56efe0..f2502244d62 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -356,6 +356,7 @@ enum class GatewayMethod( PluginsInstall("plugins.install"), PluginsSetEnabled("plugins.setEnabled"), PluginsUninstall("plugins.uninstall"), + PluginsRefresh("plugins.refresh"), ControlUiSessionPullRequests("controlUi.sessionPullRequests"), GatewaySuspendPrepare("gateway.suspend.prepare"), GatewaySuspendStatus("gateway.suspend.status"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index e140b1b4128..bc4a579f848 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -12724,6 +12724,22 @@ public struct PluginsListResult: Codable, Sendable { } } +public struct PluginsRefreshParams: Codable, Sendable {} + +public struct PluginsRefreshResult: Codable, Sendable { + public let ok: Bool + + public init( + ok: Bool) + { + self.ok = ok + } + + private enum CodingKeys: String, CodingKey { + case ok + } +} + public struct PluginsSearchParams: Codable, Sendable { public let query: String public let limit: Int? diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index ba6b969fb17..775c4960802 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -187,6 +187,8 @@ import { PluginsInstallResultSchema, PluginsListParamsSchema, PluginsListResultSchema, + PluginsRefreshParamsSchema, + PluginsRefreshResultSchema, PluginsSearchParamsSchema, PluginsSearchResultSchema, PluginsSessionActionParamsSchema, @@ -817,6 +819,8 @@ export const validatePluginApprovalRequestParams = lazyCompile(PluginApprovalReq export const validatePluginApprovalResolveParams = lazyCompile(PluginApprovalResolveParamsSchema); export const validatePluginsListParams = lazyCompile(PluginsListParamsSchema); export const validatePluginsListResult = lazyCompile(PluginsListResultSchema); +export const validatePluginsRefreshParams = lazyCompile(PluginsRefreshParamsSchema); +export const validatePluginsRefreshResult = lazyCompile(PluginsRefreshResultSchema); export const validatePluginsSearchParams = lazyCompile(PluginsSearchParamsSchema); export const validatePluginsSearchResult = lazyCompile(PluginsSearchResultSchema); export const validatePluginsInstallParams = lazyCompile(PluginsInstallParamsSchema); @@ -1157,6 +1161,8 @@ export { PluginsInstallResultSchema, PluginsListParamsSchema, PluginsListResultSchema, + PluginsRefreshParamsSchema, + PluginsRefreshResultSchema, PluginsSearchParamsSchema, PluginsSearchResultSchema, PluginsSessionActionParamsSchema, @@ -1475,6 +1481,8 @@ export type { PluginsInstallResult, PluginsListParams, PluginsListResult, + PluginsRefreshParams, + PluginsRefreshResult, PluginsSearchParams, PluginsSearchResult, PluginsSessionActionParams, diff --git a/packages/gateway-protocol/src/plugins-validators.test.ts b/packages/gateway-protocol/src/plugins-validators.test.ts index 262f9481b49..08b0f3f451e 100644 --- a/packages/gateway-protocol/src/plugins-validators.test.ts +++ b/packages/gateway-protocol/src/plugins-validators.test.ts @@ -4,6 +4,8 @@ import { validatePluginsInstallResult, validatePluginsListParams, validatePluginsListResult, + validatePluginsRefreshParams, + validatePluginsRefreshResult, validatePluginsSearchParams, validatePluginsSearchResult, validatePluginsSetEnabledParams, @@ -31,6 +33,13 @@ const installedPlugin = { } as const; describe("plugin lifecycle protocol validators", () => { + it("validates plugin metadata refresh payloads", () => { + expect(validatePluginsRefreshParams({})).toBe(true); + expect(validatePluginsRefreshParams({ unexpected: true })).toBe(false); + expect(validatePluginsRefreshResult({ ok: true })).toBe(true); + expect(validatePluginsRefreshResult({ ok: false })).toBe(false); + }); + it("accepts cold catalog payloads and rejects runtime-only states", () => { expect(validatePluginsListParams({})).toBe(true); expect(validatePluginsListParams({ unexpected: true })).toBe(false); diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index f638d815a86..a09ce4849a2 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -185,6 +185,14 @@ export const PluginsInstallResultSchema = closedObject({ warnings: Type.Optional(Type.Array(Type.String())), }); +/** Internal signal that persisted plugin metadata changed outside the Gateway process. */ +export const PluginsRefreshParamsSchema = closedObject({}); + +/** Successful plugin metadata refresh admission. */ +export const PluginsRefreshResultSchema = closedObject({ + ok: Type.Literal(true), +}); + /** Request payload for removing one installed plugin and its managed files. */ export const PluginsUninstallParamsSchema = closedObject({ pluginId: NonEmptyString, @@ -220,6 +228,8 @@ export type PluginsSearchParams = Static; export type PluginsSearchResult = Static; export type PluginsInstallParams = Static; export type PluginsInstallResult = Static; +export type PluginsRefreshParams = Static; +export type PluginsRefreshResult = Static; export type PluginsUninstallParams = Static; export type PluginsUninstallResult = Static; export type PluginsSetEnabledParams = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 96a89b65e50..0556df9b57d 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -341,6 +341,8 @@ import { PluginsInstallResultSchema, PluginsListParamsSchema, PluginsListResultSchema, + PluginsRefreshParamsSchema, + PluginsRefreshResultSchema, PluginsSearchParamsSchema, PluginsSearchResultSchema, PluginsSessionActionFailureResultSchema, @@ -929,6 +931,8 @@ export const ProtocolSchemas = { PluginsInstallResult: PluginsInstallResultSchema, PluginsListParams: PluginsListParamsSchema, PluginsListResult: PluginsListResultSchema, + PluginsRefreshParams: PluginsRefreshParamsSchema, + PluginsRefreshResult: PluginsRefreshResultSchema, PluginsSearchParams: PluginsSearchParamsSchema, PluginsSearchResult: PluginsSearchResultSchema, PluginsSessionActionFailureResult: PluginsSessionActionFailureResultSchema, diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 997b5cbda96..545fe206a66 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -86,6 +86,7 @@ export const buildPluginDiagnosticsReport: UnknownMock = vi.fn(); const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); export const inspectPluginRegistry: AsyncUnknownMock = vi.fn(); export const refreshPluginRegistry: AsyncUnknownMock = vi.fn(); +export const notifyGatewayPluginMetadataChanged: AsyncUnknownMock = vi.fn(); export const clearPluginRegistryLoadCache: UnknownMock = vi.fn(); export const applyExclusiveSlotSelection: UnknownMock = vi.fn(); export const planPluginUninstall: UnknownMock = vi.fn(); @@ -184,6 +185,11 @@ vi.mock("../runtime.js", () => ({ runtime.writeJson(value, space), })); +vi.mock("./plugins-update-gateway-signal.js", () => ({ + notifyGatewayPluginMetadataChanged: (...args: unknown[]) => + notifyGatewayPluginMetadataChanged(...args), +})); + vi.mock("../config/config.js", () => ({ assertConfigWriteAllowedInCurrentMode: () => { if (process.env.OPENCLAW_NIX_MODE === "1") { @@ -728,6 +734,7 @@ export function resetPluginsCliTestState() { buildPluginCompatibilityNotices.mockReset(); inspectPluginRegistry.mockReset(); refreshPluginRegistry.mockReset(); + notifyGatewayPluginMetadataChanged.mockReset(); clearPluginRegistryLoadCache.mockReset(); applyExclusiveSlotSelection.mockReset(); planPluginUninstall.mockReset(); @@ -838,6 +845,7 @@ export function resetPluginsCliTestState() { current: defaultRegistryIndex, }); refreshPluginRegistry.mockResolvedValue(defaultRegistryIndex); + notifyGatewayPluginMetadataChanged.mockResolvedValue(true); applyExclusiveSlotSelection.mockImplementation((({ config }: { config: OpenClawConfig }) => ({ config, warnings: [], diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 2f3f8781760..d54d3a50b2c 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -9,6 +9,7 @@ import { hashConfigIncludeRaw } from "../config/includes.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js"; import { loadConfig, + notifyGatewayPluginMetadataChanged, readConfigFileSnapshotForWrite, refreshPluginRegistry, registerPluginsCli, @@ -91,6 +92,7 @@ function expectSingleCallParams(mockFn: ReturnType) { function primeUpdateConfigSnapshot(params: { config: OpenClawConfig; configPath?: string; + hash?: string; loadedConfig?: OpenClawConfig; parsed?: Record; runtimeConfig?: OpenClawConfig; @@ -98,13 +100,12 @@ function primeUpdateConfigSnapshot(params: { valid?: boolean; includeFileHashesForWrite?: Record; includeFileTargetsForWrite?: Record; -}): void { +}) { const configPath = params.configPath ?? path.join(process.cwd(), "openclaw.json5"); const parsed = params.parsed ?? (params.config as Record); const sourceConfig = params.sourceConfig ?? params.config; const runtimeConfig = params.runtimeConfig ?? params.config; - loadConfig.mockReturnValue(params.loadedConfig ?? params.config); - readConfigFileSnapshotForWrite.mockResolvedValue({ + const prepared = { snapshot: { path: configPath, exists: true, @@ -115,7 +116,7 @@ function primeUpdateConfigSnapshot(params: { runtimeConfig, valid: params.valid ?? true, config: runtimeConfig, - hash: "update-config", + hash: params.hash ?? "update-config", issues: [], warnings: [], legacyIssues: [], @@ -127,7 +128,10 @@ function primeUpdateConfigSnapshot(params: { includeFileHashesForWrite: params.includeFileHashesForWrite, includeFileTargetsForWrite: params.includeFileTargetsForWrite, }, - }); + }; + loadConfig.mockReturnValue(params.loadedConfig ?? params.config); + readConfigFileSnapshotForWrite.mockResolvedValue(prepared); + return prepared; } function primeBlockedUpdateConfig(section: "hooks" | "plugins", config: OpenClawConfig): void { @@ -488,7 +492,7 @@ describe("plugins cli update", () => { expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( nextConfig.plugins?.installs, ); - expect(writeConfigFile).toHaveBeenCalledWith(cfg); + expect(writeConfigFile).not.toHaveBeenCalled(); }); it("allows scoped non-npm updates beside include-owned plugin config", async () => { @@ -527,7 +531,326 @@ describe("plugins cli update", () => { expect(runtimeErrors).toEqual([]); expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce(); expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(pluginRecords); - expect(writeConfigFile).toHaveBeenCalledWith(cfg); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not rewrite source config for persisted install record-only updates", async () => { + const cfg = { + gateway: { + mode: "local", + port: 18889, + }, + agents: { + defaults: { + model: "openai/gpt-5.5", + }, + }, + channels: { + discord: { + enabled: true, + }, + }, + plugins: { + entries: { + brave: { enabled: true }, + }, + }, + } as OpenClawConfig; + const sourceCfg = structuredClone(cfg); + delete sourceCfg.gateway; + const previousRecords = { + brave: { + source: "npm", + spec: "@openclaw/brave-plugin@2026.6.11-beta.2", + installPath: "/tmp/brave-beta", + resolvedName: "@openclaw/brave-plugin", + resolvedVersion: "2026.6.11-beta.2", + }, + } as const; + const nextRecords = { + brave: { + ...previousRecords.brave, + spec: "@openclaw/brave-plugin@2026.6.11", + installPath: "/tmp/brave-stable", + resolvedVersion: "2026.6.11", + }, + } as const; + primeUpdateConfigSnapshot({ + config: cfg, + parsed: sourceCfg as Record, + runtimeConfig: cfg, + sourceConfig: sourceCfg, + }); + setInstalledPluginIndexInstallRecords(previousRecords); + updateNpmInstalledPlugins.mockResolvedValue({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + installs: nextRecords, + }, + } as OpenClawConfig, + changed: true, + outcomes: [{ pluginId: "brave", status: "updated", message: "Updated brave." }], + }); + + await runPluginsCommand(["plugins", "update", "brave"]); + + expect(runtimeErrors).toEqual([]); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(nextRecords); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).toHaveBeenCalledWith({ + config: sourceCfg, + installRecords: nextRecords, + reason: "source-changed", + }); + expect(notifyGatewayPluginMetadataChanged).toHaveBeenCalledWith(cfg); + expectRestartNoticeLogged(); + }); + + it("rolls back persisted install records when source config changes during a records-only update", async () => { + const cfg = { + gateway: { + mode: "local", + port: 18889, + }, + plugins: { + entries: { + brave: { enabled: true }, + }, + }, + } as OpenClawConfig; + const changedCfg = { + ...cfg, + gateway: { + ...cfg.gateway, + port: 18890, + }, + } as OpenClawConfig; + const previousRecords = { + brave: { + source: "npm", + spec: "@openclaw/brave-plugin@2026.6.11-beta.2", + installPath: "/tmp/brave-beta", + resolvedName: "@openclaw/brave-plugin", + resolvedVersion: "2026.6.11-beta.2", + }, + } as const; + const nextRecords = { + brave: { + ...previousRecords.brave, + spec: "@openclaw/brave-plugin@2026.6.11", + installPath: "/tmp/brave-stable", + resolvedVersion: "2026.6.11", + }, + } as const; + const initialSnapshot = primeUpdateConfigSnapshot({ config: cfg }); + const changedSnapshot = { + ...initialSnapshot, + snapshot: { + ...initialSnapshot.snapshot, + raw: JSON.stringify(changedCfg), + parsed: changedCfg as Record, + resolved: changedCfg, + sourceConfig: changedCfg, + runtimeConfig: changedCfg, + config: changedCfg, + hash: "changed-config", + }, + }; + readConfigFileSnapshotForWrite + .mockResolvedValueOnce(initialSnapshot) + .mockResolvedValueOnce(changedSnapshot); + setInstalledPluginIndexInstallRecords(previousRecords); + updateNpmInstalledPlugins.mockResolvedValue({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + installs: nextRecords, + }, + } as OpenClawConfig, + changed: true, + outcomes: [{ pluginId: "brave", status: "updated", message: "Updated brave." }], + }); + + await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow( + "config changed since last load", + ); + + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith( + 1, + nextRecords, + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith( + 2, + previousRecords, + ); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(notifyGatewayPluginMetadataChanged).not.toHaveBeenCalled(); + }); + + it("rolls back persisted install records when included config changes during a records-only update", async () => { + const includePath = "/tmp/plugins.json5"; + const includeTarget = "/tmp/plugins.json5"; + const cfg = { + plugins: { + entries: { + brave: { enabled: true }, + }, + }, + } as OpenClawConfig; + const previousRecords = { + brave: { + source: "npm", + spec: "@openclaw/brave-plugin@2026.6.11-beta.2", + installPath: "/tmp/brave-beta", + resolvedName: "@openclaw/brave-plugin", + resolvedVersion: "2026.6.11-beta.2", + }, + } as const; + const nextRecords = { + brave: { + ...previousRecords.brave, + spec: "@openclaw/brave-plugin@2026.6.11", + installPath: "/tmp/brave-stable", + resolvedVersion: "2026.6.11", + }, + } as const; + const initialSnapshot = primeUpdateConfigSnapshot({ + config: cfg, + parsed: { + plugins: { + $include: includePath, + }, + }, + includeFileHashesForWrite: { + [includePath]: "include-start", + }, + includeFileTargetsForWrite: { + [includePath]: includeTarget, + }, + }); + const changedSnapshot = { + ...initialSnapshot, + writeOptions: { + ...initialSnapshot.writeOptions, + includeFileHashesForWrite: { + [includePath]: "include-changed", + }, + }, + }; + readConfigFileSnapshotForWrite + .mockResolvedValueOnce(initialSnapshot) + .mockResolvedValueOnce(changedSnapshot); + setInstalledPluginIndexInstallRecords(previousRecords); + updateNpmInstalledPlugins.mockResolvedValue({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + installs: nextRecords, + }, + } as OpenClawConfig, + changed: true, + outcomes: [{ pluginId: "brave", status: "updated", message: "Updated brave." }], + }); + + await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow( + "included config changed since last load", + ); + + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith( + 1, + nextRecords, + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith( + 2, + previousRecords, + ); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + }); + + it("rolls back persisted install records when records-only update invalidates config", async () => { + const cfg = { + plugins: { + entries: { + brave: { + enabled: true, + config: { + oldOption: true, + }, + }, + }, + }, + } as OpenClawConfig; + const previousRecords = { + brave: { + source: "npm", + spec: "@openclaw/brave-plugin@2026.6.11-beta.2", + installPath: "/tmp/brave-beta", + resolvedName: "@openclaw/brave-plugin", + resolvedVersion: "2026.6.11-beta.2", + }, + } as const; + const nextRecords = { + brave: { + ...previousRecords.brave, + spec: "@openclaw/brave-plugin@2026.6.11", + installPath: "/tmp/brave-stable", + resolvedVersion: "2026.6.11", + }, + } as const; + const initialSnapshot = primeUpdateConfigSnapshot({ config: cfg }); + const invalidSnapshot = { + ...initialSnapshot, + snapshot: { + ...initialSnapshot.snapshot, + valid: false, + issues: [ + { + path: "plugins.entries.brave.config.oldOption", + message: "invalid config for plugin brave: must NOT have additional properties", + }, + ], + }, + }; + readConfigFileSnapshotForWrite + .mockResolvedValueOnce(initialSnapshot) + .mockResolvedValueOnce(invalidSnapshot); + setInstalledPluginIndexInstallRecords(previousRecords); + updateNpmInstalledPlugins.mockResolvedValue({ + config: { + ...cfg, + plugins: { + ...cfg.plugins, + installs: nextRecords, + }, + } as OpenClawConfig, + changed: true, + outcomes: [{ pluginId: "brave", status: "updated", message: "Updated brave." }], + }); + + await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow( + "invalid config for plugin brave", + ); + + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith( + 1, + nextRecords, + ); + expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith( + 2, + previousRecords, + ); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); }); it("blocks legacy plugin id migration before updater side effects", async () => { diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 6bccde87ad3..e0b0052e87c 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -1,4 +1,5 @@ // `openclaw plugins update` command implementation for tracked npm plugins and hook packs. +import { isDeepStrictEqual } from "node:util"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { assertConfigWriteAllowedInCurrentMode, @@ -6,8 +7,14 @@ import { readConfigFileSnapshotForWrite, replaceConfigFile, } from "../config/config.js"; +import { + createInvalidConfigError, + formatInvalidConfigDetails, +} from "../config/io.invalid-config.js"; +import type { ConfigWriteOptions } from "../config/io.js"; import { createMergePatch } from "../config/io.write-prepare.js"; import { applyMergePatch } from "../config/merge-patch.js"; +import { ConfigMutationConflictError } from "../config/mutate.js"; import { extractShippedPluginInstallConfigRecords } from "../config/plugin-install-config-migration.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; @@ -19,7 +26,10 @@ import { resolveInstallConfigMutationPreflights, selectInstallMutationWriteOptions, } from "../plugins/install-persistence.js"; -import { commitPluginInstallRecordsWithConfig } from "../plugins/install-record-commit.js"; +import { + commitPluginInstallRecordsOnly, + commitPluginInstallRecordsWithConfig, +} from "../plugins/install-record-commit.js"; import { loadInstalledPluginIndexInstallRecords, withoutPluginInstallRecords, @@ -34,6 +44,7 @@ import { import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; +import { notifyGatewayPluginMetadataChanged } from "./plugins-update-gateway-signal.js"; import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js"; import { resolveHookPackUpdateSelection, @@ -100,6 +111,65 @@ function projectUpdaterResultOntoSourceConfig(params: { return applyMergePatch(params.sourceBase, updatePatch) as OpenClawConfig; } +function assertWriteOptionRecordFresh(params: { + currentHash: string | null; + current?: Record; + expected?: Record; + message: string; +}): void { + if (!isDeepStrictEqual(params.current ?? {}, params.expected ?? {})) { + throw new ConfigMutationConflictError(params.message, { + currentHash: params.currentHash, + }); + } +} + +async function assertRecordsOnlyUpdateConfigFresh(params: { + baseHash?: string; + writeOptions?: ConfigWriteOptions; +}): Promise { + const prepared = await readConfigFileSnapshotForWrite(params.writeOptions); + const writeOptions = { + ...prepared.writeOptions, + ...params.writeOptions, + }; + const currentHash = prepared.snapshot.hash ?? null; + + writeOptions.assertConfigPathForWrite?.(); + if ( + writeOptions.expectedConfigPath !== undefined && + writeOptions.expectedConfigPath !== prepared.snapshot.path + ) { + throw new ConfigMutationConflictError("config path changed since last load", { + currentHash, + retryable: false, + }); + } + if (params.baseHash !== undefined && params.baseHash !== currentHash) { + throw new ConfigMutationConflictError("config changed since last load", { + currentHash, + }); + } + assertWriteOptionRecordFresh({ + currentHash, + current: prepared.writeOptions.includeFileTargetsForWrite, + expected: params.writeOptions?.includeFileTargetsForWrite, + message: "included config target changed since last load", + }); + assertWriteOptionRecordFresh({ + currentHash, + current: prepared.writeOptions.includeFileHashesForWrite, + expected: params.writeOptions?.includeFileHashesForWrite, + message: "included config changed since last load", + }); + if (!prepared.snapshot.valid) { + throw createInvalidConfigError( + prepared.snapshot.path, + formatInvalidConfigDetails(prepared.snapshot.issues), + ); + } +} + /** Run plugin/hook-pack updates, persist changed install records, and refresh runtime registry. */ export async function runPluginUpdateCommand(params: { id?: string; @@ -346,17 +416,32 @@ export async function runPluginUpdateCommand(params: { sourceConfig: sourceSnapshot?.snapshot.sourceConfig ?? {}, }), }); + let recordsOnlyPluginUpdate = false; if (shouldPersistPluginInstallIndex) { - await commitPluginInstallRecordsWithConfig({ - previousInstallRecords: persistedPluginInstallRecords, - nextInstallRecords: nextPluginInstallRecords, - nextConfig, - baseHash: sourceSnapshot?.snapshot.hash, - writeOptions: { - ...sourceSnapshot?.writeOptions, - afterWrite: { mode: "restart", reason: "plugin source changed" }, - }, - }); + if (isDeepStrictEqual(nextConfig, sourceSnapshot?.snapshot.sourceConfig ?? sourceCfg)) { + await commitPluginInstallRecordsOnly({ + previousInstallRecords: persistedPluginInstallRecords, + nextInstallRecords: nextPluginInstallRecords, + verifyConfigFresh: async () => { + await assertRecordsOnlyUpdateConfigFresh({ + baseHash: sourceSnapshot?.snapshot.hash, + writeOptions: sourceSnapshot?.writeOptions, + }); + }, + }); + recordsOnlyPluginUpdate = pluginResult.changed; + } else { + await commitPluginInstallRecordsWithConfig({ + previousInstallRecords: persistedPluginInstallRecords, + nextInstallRecords: nextPluginInstallRecords, + nextConfig, + baseHash: sourceSnapshot?.snapshot.hash, + writeOptions: { + ...sourceSnapshot?.writeOptions, + afterWrite: { mode: "restart", reason: "plugin source changed" }, + }, + }); + } } else { await replaceConfigFile({ nextConfig, @@ -372,6 +457,9 @@ export async function runPluginUpdateCommand(params: { invalidateRuntimeCache: false, logger, }); + if (recordsOnlyPluginUpdate) { + await notifyGatewayPluginMetadataChanged(cfg); + } } defaultRuntime.log("Restart the gateway to load plugins and hooks."); } diff --git a/src/cli/plugins-update-gateway-signal.test.ts b/src/cli/plugins-update-gateway-signal.test.ts new file mode 100644 index 00000000000..0f4a48c97ba --- /dev/null +++ b/src/cli/plugins-update-gateway-signal.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import { notifyGatewayPluginMetadataChanged } from "./plugins-update-gateway-signal.js"; + +describe("notifyGatewayPluginMetadataChanged", () => { + it("signals only the local configured Gateway", async () => { + const callGateway = vi.fn(async () => ({ ok: true })); + const config = { gateway: { port: 19_001 } }; + + await expect(notifyGatewayPluginMetadataChanged(config, { callGateway })).resolves.toBe(true); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config, + method: "plugins.refresh", + params: {}, + localPortOverride: 19_001, + ignoreEnvUrlOverride: true, + requiredMethods: ["plugins.refresh"], + scopes: ["operator.admin"], + }), + ); + }); + + it("leaves offline Gateway recovery to the restart instruction", async () => { + const callGateway = vi.fn(async () => { + throw new Error("offline"); + }); + + await expect(notifyGatewayPluginMetadataChanged({}, { callGateway })).resolves.toBe(false); + }); +}); diff --git a/src/cli/plugins-update-gateway-signal.ts b/src/cli/plugins-update-gateway-signal.ts new file mode 100644 index 00000000000..834e6fd0faf --- /dev/null +++ b/src/cli/plugins-update-gateway-signal.ts @@ -0,0 +1,34 @@ +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../packages/gateway-protocol/src/client-info.js"; +import { resolveGatewayPort } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { callGateway, type CallGatewayOptions } from "../gateway/call.js"; + +type PluginMetadataGatewayCall = (opts: CallGatewayOptions) => Promise; + +/** Notifies the local Gateway that persisted plugin metadata changed without config writes. */ +export async function notifyGatewayPluginMetadataChanged( + config: OpenClawConfig, + deps: { callGateway?: PluginMetadataGatewayCall } = {}, +): Promise { + try { + await (deps.callGateway ?? callGateway)({ + config, + method: "plugins.refresh", + params: {}, + timeoutMs: 1_000, + localPortOverride: resolveGatewayPort(config), + ignoreEnvUrlOverride: true, + requiredMethods: ["plugins.refresh"], + scopes: ["operator.admin"], + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }); + return true; + } catch { + // An offline or older Gateway is expected; the command still prints the restart instruction. + return false; + } +} diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 4baa85d88a7..54ee16dad7a 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -3431,6 +3431,46 @@ describe("startGatewayConfigReloader", () => { await harness.reloader.stop(); }); + it("reloads explicitly signaled plugin metadata when config bytes stay identical", async () => { + const activeConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + }; + const readSnapshot = vi.fn(async () => + makeSnapshot({ + sourceConfig: activeConfig, + runtimeConfig: activeConfig, + config: activeConfig, + hash: "unchanged-config", + }), + ); + const readPluginInstallRecords = vi.fn(async () => ({ + brave: { + source: "npm" as const, + spec: "@openclaw/brave", + installPath: "/tmp/openclaw/plugins/brave", + }, + })); + const harness = createReloaderHarness(readSnapshot, { + initialConfig: activeConfig, + initialCompareConfig: activeConfig, + initialInternalWriteHash: "unchanged-config", + initialPluginInstallRecords: {}, + readPluginInstallRecords, + }); + + harness.reloader.notifyPluginMetadataChanged(); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).toHaveBeenCalledOnce(); + expect(readPluginInstallRecords).toHaveBeenCalledOnce(); + const [plan, nextConfig] = getOnlyRestartCall(harness); + expect(plan.changedPaths).toEqual(["plugins.installs.brave"]); + expect(plan.restartReasons).toEqual(["plugins.installs.brave"]); + expect(nextConfig).toBe(activeConfig); + + await harness.reloader.stop(); + }); + it("keeps external plugin policy-only writes on the hot reload path", async () => { const previousConfig: OpenClawConfig = { gateway: { reload: { debounceMs: 0 } }, diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index ac2d38131f0..66c9d35a51f 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -9,6 +9,7 @@ import type { RuntimeConfigSnapshotRefreshOptions } from "../config/runtime-snap import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { + clearLoadInstalledPluginIndexInstallRecordsCache, loadInstalledPluginIndexInstallRecords, loadInstalledPluginIndexInstallRecordsSync, } from "../plugins/installed-plugin-index-records.js"; @@ -88,6 +89,7 @@ function isNoopReloadPlan(plan: GatewayReloadPlan): boolean { type GatewayConfigReloader = { stop: () => Promise; hotReloadStatus: () => GatewayHotReloadStatus; + notifyPluginMetadataChanged: () => void; }; type PluginInstallRecords = Record; @@ -847,7 +849,7 @@ export function startGatewayConfigReloader(opts: { ); } - const scheduleFromWatcher = () => { + const scheduleExternalRefresh = () => { opts.onConfigCandidateObserved?.(); // Revoke the transaction synchronously. The debounced reread owns this new // epoch; a slow prior reload must not publish after a newer disk write. @@ -911,9 +913,9 @@ export function startGatewayConfigReloader(opts: { awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, usePolling, }); - next.on("add", scheduleFromWatcher); - next.on("change", scheduleFromWatcher); - next.on("unlink", scheduleFromWatcher); + next.on("add", scheduleExternalRefresh); + next.on("change", scheduleExternalRefresh); + next.on("unlink", scheduleExternalRefresh); next.on("error", (err) => { handleWatcherError(next, err); }); @@ -971,6 +973,14 @@ export function startGatewayConfigReloader(opts: { createWatcher(); return { + notifyPluginMetadataChanged: () => { + // The signal carries a metadata change while config bytes stay identical. + // Clear both metadata and config-echo caches before scheduling the shared diff path. + clearLoadInstalledPluginIndexInstallRecordsCache(); + startupInternalWriteHash = null; + lastAppliedWriteHash = null; + scheduleExternalRefresh(); + }, stop: async () => { stopped = true; if (debounceTimer) { diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index cf9ef2c60fa..cf60d3eef2d 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -78,6 +78,7 @@ function createLocalGatewayRequestContext( cron: unavailableCron, cronStorePath: "", getRuntimeConfig: params.getRuntimeConfig, + notifyPluginMetadataChanged: () => {}, resolveTerminalLaunchPolicy: () => ({ ok: false, block: { kind: "disabled" } }), isTerminalEnabled: () => false, loadGatewayModelCatalog: async () => diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 3f0074521e9..acad6c0baa2 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -308,6 +308,7 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "plugins.install", scope: "operator.admin", controlPlaneWrite: true }, { name: "plugins.setEnabled", scope: "operator.admin", controlPlaneWrite: true }, { name: "plugins.uninstall", scope: "operator.admin", controlPlaneWrite: true }, + { name: "plugins.refresh", scope: "operator.admin", controlPlaneWrite: true }, // Session PR chips read the session's own checkout metadata, matching the // sessions.files.* trusted-operator read domain. { name: "controlUi.sessionPullRequests", scope: "operator.read" }, diff --git a/src/gateway/methods/plugins-descriptors.test.ts b/src/gateway/methods/plugins-descriptors.test.ts index a3b0e1f626b..6966b7a8679 100644 --- a/src/gateway/methods/plugins-descriptors.test.ts +++ b/src/gateway/methods/plugins-descriptors.test.ts @@ -13,6 +13,7 @@ describe("plugin management gateway descriptors", () => { "plugins.install": handler, "plugins.setEnabled": handler, "plugins.uninstall": handler, + "plugins.refresh": handler, }); const byName = new Map(descriptors.map((descriptor) => [descriptor.name, descriptor])); @@ -30,5 +31,9 @@ describe("plugin management gateway descriptors", () => { scope: "operator.admin", controlPlaneWrite: true, }); + expect(byName.get("plugins.refresh")).toMatchObject({ + scope: "operator.admin", + controlPlaneWrite: true, + }); }); }); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 0b6e193d4dd..6270057f7b2 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -504,6 +504,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { "plugins.install", "plugins.setEnabled", "plugins.uninstall", + "plugins.refresh", ], loadHandlers: loadPluginsHandlers, }), diff --git a/src/gateway/server-methods/plugins.test.ts b/src/gateway/server-methods/plugins.test.ts index 1a68b215acf..e117179ef17 100644 --- a/src/gateway/server-methods/plugins.test.ts +++ b/src/gateway/server-methods/plugins.test.ts @@ -68,7 +68,10 @@ async function callHandler( req: {} as never, client: null as never, isWebchatConnect: () => false, - context: { getRuntimeConfig: () => runtimeConfig } as never, + context: { + getRuntimeConfig: () => runtimeConfig, + notifyPluginMetadataChanged: pluginMetadataChanged, + } as never, respond: (success, result, requestError) => { ok = success; response = result; @@ -78,6 +81,8 @@ async function callHandler( return { ok, response, error }; } +const pluginMetadataChanged = vi.fn(); + const workboard = { id: "workboard", name: "Workboard", @@ -90,6 +95,7 @@ const workboard = { describe("plugin management Gateway handlers", () => { beforeEach(() => { + pluginMetadataChanged.mockReset(); managementMocks.install.mockReset(); managementMocks.list.mockReset(); managementMocks.setEnabled.mockReset(); @@ -97,6 +103,13 @@ describe("plugin management Gateway handlers", () => { searchMock.mockReset(); }); + it("signals the config reloader after persisted plugin metadata changes", async () => { + const result = await callHandler("plugins.refresh", {}); + + expect(pluginMetadataChanged).toHaveBeenCalledOnce(); + expect(result).toEqual({ ok: true, response: { ok: true }, error: undefined }); + }); + it("returns cold Workboard inventory without claiming runtime loaded state", async () => { managementMocks.list.mockResolvedValue({ plugins: [workboard], diff --git a/src/gateway/server-methods/plugins.ts b/src/gateway/server-methods/plugins.ts index 42942e96cbe..f16081ea124 100644 --- a/src/gateway/server-methods/plugins.ts +++ b/src/gateway/server-methods/plugins.ts @@ -6,6 +6,7 @@ import { isClawHubTrustErrorCode, validatePluginsInstallParams, validatePluginsListParams, + validatePluginsRefreshParams, validatePluginsSearchParams, validatePluginsSetEnabledParams, validatePluginsUninstallParams, @@ -36,6 +37,13 @@ function pluginPolicyRestartRequired(params: { /** Gateway handlers for plugin inventory, ClawHub search, install, and policy state. */ export const pluginsHandlers: GatewayRequestHandlers = { + "plugins.refresh": async ({ params, respond, context }) => { + if (!assertValidParams(params, validatePluginsRefreshParams, "plugins.refresh", respond)) { + return; + } + context.notifyPluginMetadataChanged(); + respond(true, { ok: true }, undefined); + }, "plugins.list": async ({ params, respond, context }) => { if (!assertValidParams(params, validatePluginsListParams, "plugins.list", respond)) { return; diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 7cede87f854..5a47e00de3f 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -117,6 +117,7 @@ export type GatewayRequestContext = { cron: GatewayCronServiceContract; cronStorePath: string; getRuntimeConfig: () => OpenClawConfig; + notifyPluginMetadataChanged: () => void; getMcpAppSandboxPort?: () => number | undefined; resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution; isTerminalEnabled: () => boolean; diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 84628fff79b..fc1bd1ec0f4 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -1534,7 +1534,7 @@ export function startManagedGatewayConfigReloader( params: ManagedGatewayConfigReloaderParams, ): GatewayConfigReloaderHandle { if (params.minimalTestGateway) { - return { stop: async () => {} }; + return { stop: async () => {}, notifyPluginMetadataChanged: () => {} }; } const prepareRuntimeCandidate = ( @@ -2234,6 +2234,7 @@ export function startManagedGatewayConfigReloader( await configReloader.stop(); }, hotReloadStatus: configReloader.hotReloadStatus, + notifyPluginMetadataChanged: configReloader.notifyPluginMetadataChanged, }; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 1aedadb66b7..add170b9c01 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -21,7 +21,10 @@ function makeContextParams( storePath: "/tmp/cron", cronEnabled: true, }, - configReloader: { stop: vi.fn(async () => {}) }, + configReloader: { + stop: vi.fn(async () => {}), + notifyPluginMetadataChanged: vi.fn(), + }, }; return { deps: {} as never, @@ -131,7 +134,10 @@ describe("createGatewayRequestContext", () => { storePath: "/tmp/cron-a", cronEnabled: true, }, - configReloader: { stop: vi.fn(async () => {}) }, + configReloader: { + stop: vi.fn(async () => {}), + notifyPluginMetadataChanged: vi.fn(), + }, }; const context = createGatewayRequestContext(makeContextParams({ runtimeState })); @@ -156,7 +162,10 @@ describe("createGatewayRequestContext", () => { storePath: "/tmp/cron", cronEnabled: true, }, - configReloader: { stop: vi.fn(async () => {}) }, + configReloader: { + stop: vi.fn(async () => {}), + notifyPluginMetadataChanged: vi.fn(), + }, }; const context = createGatewayRequestContext(makeContextParams({ runtimeState })); @@ -166,12 +175,14 @@ describe("createGatewayRequestContext", () => { runtimeState.configReloader = { stop: vi.fn(async () => {}), hotReloadStatus: () => "active", + notifyPluginMetadataChanged: vi.fn(), }; expect(context.getConfigReloaderHotReloadStatus?.()).toBe("active"); runtimeState.configReloader = { stop: vi.fn(async () => {}), hotReloadStatus: () => "disabled", + notifyPluginMetadataChanged: vi.fn(), }; expect(context.getConfigReloaderHotReloadStatus?.()).toBe("disabled"); }); diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 42deca7efc5..19040c38a54 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -154,6 +154,8 @@ export function createGatewayRequestContext( return params.runtimeState.cronState.storePath; }, getRuntimeConfig: params.getRuntimeConfig, + notifyPluginMetadataChanged: () => + params.runtimeState.configReloader.notifyPluginMetadataChanged(), getMcpAppSandboxPort: params.getMcpAppSandboxPort, resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy, isTerminalEnabled: params.isTerminalEnabled, diff --git a/src/gateway/server-runtime-handles.ts b/src/gateway/server-runtime-handles.ts index 27718eda5ef..cb86a8650bb 100644 --- a/src/gateway/server-runtime-handles.ts +++ b/src/gateway/server-runtime-handles.ts @@ -14,6 +14,7 @@ import type { GatewayPostReadySidecarHandle } from "./server-startup-post-attach export type GatewayConfigReloaderHandle = { stop: () => Promise; hotReloadStatus?: () => GatewayHotReloadStatus; + notifyPluginMetadataChanged: () => void; }; /** Mutable handles owned by a running gateway server process. */ @@ -75,7 +76,10 @@ export function createGatewayServerMutableState(): GatewayServerMutableState { channelHealthMonitor: null as ChannelHealthMonitor | null, stopModelPricingRefresh: () => {}, mcpServer: undefined as { port: number; close: () => Promise } | undefined, - configReloader: { stop: async () => {} } satisfies GatewayConfigReloaderHandle, + configReloader: { + stop: async () => {}, + notifyPluginMetadataChanged: () => {}, + } satisfies GatewayConfigReloaderHandle, agentUnsub: null as (() => Promise | void) | null, heartbeatUnsub: null as (() => void) | null, transcriptUnsub: null as (() => void) | null, diff --git a/src/plugins/install-record-commit.ts b/src/plugins/install-record-commit.ts index 5a1ec985a92..5faa1064f1b 100644 --- a/src/plugins/install-record-commit.ts +++ b/src/plugins/install-record-commit.ts @@ -347,6 +347,23 @@ export async function commitPluginInstallRecordsWithConfig(params: { }); } +/** Persist plugin install records without rewriting the user-authored config file. */ +export async function commitPluginInstallRecordsOnly(params: { + previousInstallRecords?: Record; + nextInstallRecords: Record; + verifyConfigFresh?: () => Promise; +}): Promise { + await commitPluginInstallRecordsWithWriter({ + previousInstallRecords: params.previousInstallRecords, + nextInstallRecords: params.nextInstallRecords, + nextConfig: {}, + commit: async () => { + await params.verifyConfigFresh?.(); + return undefined; + }, + }); +} + /** Commit config while migrating any pending install records into the install index. */ export async function commitConfigWriteWithPendingPluginInstalls(params: { nextConfig: OpenClawConfig;