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 <al3060388206@gmail.com>

* chore(protocol): refresh generated clients

Co-authored-by: luyifan <al3060388206@gmail.com>

* fix(gateway): wire plugin refresh handler

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ooiuuii
2026-07-16 21:38:45 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 32221cbcad
commit 7641aa80c2
25 changed files with 674 additions and 28 deletions
@@ -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"),
@@ -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?
+8
View File
@@ -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,
@@ -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);
@@ -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<typeof PluginsSearchParamsSchema>;
export type PluginsSearchResult = Static<typeof PluginsSearchResultSchema>;
export type PluginsInstallParams = Static<typeof PluginsInstallParamsSchema>;
export type PluginsInstallResult = Static<typeof PluginsInstallResultSchema>;
export type PluginsRefreshParams = Static<typeof PluginsRefreshParamsSchema>;
export type PluginsRefreshResult = Static<typeof PluginsRefreshResultSchema>;
export type PluginsUninstallParams = Static<typeof PluginsUninstallParamsSchema>;
export type PluginsUninstallResult = Static<typeof PluginsUninstallResultSchema>;
export type PluginsSetEnabledParams = Static<typeof PluginsSetEnabledParamsSchema>;
@@ -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,
+8
View File
@@ -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: [],
+330 -7
View File
@@ -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<typeof vi.fn>) {
function primeUpdateConfigSnapshot(params: {
config: OpenClawConfig;
configPath?: string;
hash?: string;
loadedConfig?: OpenClawConfig;
parsed?: Record<string, unknown>;
runtimeConfig?: OpenClawConfig;
@@ -98,13 +100,12 @@ function primeUpdateConfigSnapshot(params: {
valid?: boolean;
includeFileHashesForWrite?: Record<string, string>;
includeFileTargetsForWrite?: Record<string, string>;
}): void {
}) {
const configPath = params.configPath ?? path.join(process.cwd(), "openclaw.json5");
const parsed = params.parsed ?? (params.config as Record<string, unknown>);
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<string, unknown>,
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<string, unknown>,
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 () => {
+99 -11
View File
@@ -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<string, string>;
expected?: Record<string, string>;
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<void> {
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.");
}
@@ -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);
});
});
+34
View File
@@ -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<unknown>;
/** Notifies the local Gateway that persisted plugin metadata changed without config writes. */
export async function notifyGatewayPluginMetadataChanged(
config: OpenClawConfig,
deps: { callGateway?: PluginMetadataGatewayCall } = {},
): Promise<boolean> {
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;
}
}
+40
View File
@@ -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 } },
+14 -4
View File
@@ -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<void>;
hotReloadStatus: () => GatewayHotReloadStatus;
notifyPluginMetadataChanged: () => void;
};
type PluginInstallRecords = Record<string, PluginInstallRecord>;
@@ -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) {
+1
View File
@@ -78,6 +78,7 @@ function createLocalGatewayRequestContext(
cron: unavailableCron,
cronStorePath: "",
getRuntimeConfig: params.getRuntimeConfig,
notifyPluginMetadataChanged: () => {},
resolveTerminalLaunchPolicy: () => ({ ok: false, block: { kind: "disabled" } }),
isTerminalEnabled: () => false,
loadGatewayModelCatalog: async () =>
+1
View File
@@ -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" },
@@ -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,
});
});
});
+1
View File
@@ -504,6 +504,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
"plugins.install",
"plugins.setEnabled",
"plugins.uninstall",
"plugins.refresh",
],
loadHandlers: loadPluginsHandlers,
}),
+14 -1
View File
@@ -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],
+8
View File
@@ -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;
@@ -117,6 +117,7 @@ export type GatewayRequestContext = {
cron: GatewayCronServiceContract;
cronStorePath: string;
getRuntimeConfig: () => OpenClawConfig;
notifyPluginMetadataChanged: () => void;
getMcpAppSandboxPort?: () => number | undefined;
resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution;
isTerminalEnabled: () => boolean;
+2 -1
View File
@@ -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. */
+14 -3
View File
@@ -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");
});
+2
View File
@@ -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,
+5 -1
View File
@@ -14,6 +14,7 @@ import type { GatewayPostReadySidecarHandle } from "./server-startup-post-attach
export type GatewayConfigReloaderHandle = {
stop: () => Promise<void>;
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<void> } | undefined,
configReloader: { stop: async () => {} } satisfies GatewayConfigReloaderHandle,
configReloader: {
stop: async () => {},
notifyPluginMetadataChanged: () => {},
} satisfies GatewayConfigReloaderHandle,
agentUnsub: null as (() => Promise<void> | void) | null,
heartbeatUnsub: null as (() => void) | null,
transcriptUnsub: null as (() => void) | null,
+17
View File
@@ -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<string, PluginInstallRecord>;
nextInstallRecords: Record<string, PluginInstallRecord>;
verifyConfigFresh?: () => Promise<void>;
}): Promise<void> {
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;