mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
parent
32221cbcad
commit
7641aa80c2
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -78,6 +78,7 @@ function createLocalGatewayRequestContext(
|
||||
cron: unavailableCron,
|
||||
cronStorePath: "",
|
||||
getRuntimeConfig: params.getRuntimeConfig,
|
||||
notifyPluginMetadataChanged: () => {},
|
||||
resolveTerminalLaunchPolicy: () => ({ ok: false, block: { kind: "disabled" } }),
|
||||
isTerminalEnabled: () => false,
|
||||
loadGatewayModelCatalog: async () =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,6 +504,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
"plugins.install",
|
||||
"plugins.setEnabled",
|
||||
"plugins.uninstall",
|
||||
"plugins.refresh",
|
||||
],
|
||||
loadHandlers: loadPluginsHandlers,
|
||||
}),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user