fix(plugins): avoid source rebuilds for policy toggles

Reuse current installed-plugin registry records for policy-only enable and disable refreshes.\n\nThanks @vincentkoc
This commit is contained in:
Vincent Koc
2026-05-01 09:01:13 -07:00
committed by GitHub
parent 575854c096
commit 579acc3a91
10 changed files with 256 additions and 69 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Onboarding/configure: avoid staging every default plugin runtime dependency after config writes, so skipped setup flows only prepare config-selected plugin deps instead of pulling broad feature-plugin packages. Thanks @vincentkoc.
- Thinking/providers: resolve bundled provider thinking profiles through lightweight provider policy artifacts when startup-lazy providers are not active, so OpenAI Codex GPT-5.x keeps xhigh available in Gateway session validation. Fixes #74796. Thanks @maxschachere.
- Security/Windows: ignore workspace `.env` system-path variables and resolve stale-process `taskkill.exe` from the validated Windows install root, preventing repository-local env files from redirecting cleanup helpers. Thanks @pgondhi987.
- CLI/plugins: refresh persisted plugin registry policy in place for `plugins enable` and `plugins disable`, so routine toggles no longer rebuild and hash every plugin source when the target is already indexed. Thanks @vincentkoc.
- CLI/plugins: scope install and enable slot selection to the selected plugin manifest/runtime fallback, so plugin installs no longer load every plugin runtime or broad status snapshot just to update memory/context slots. Thanks @vincentkoc.
- Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc.
- Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf.

View File

@@ -607,9 +607,11 @@ export function resetPluginsCliTestState() {
ok: false,
error: "marketplace install failed",
});
enablePluginInConfig.mockImplementation(((cfg: OpenClawConfig) => ({ config: cfg })) as (
...args: unknown[]
) => unknown);
enablePluginInConfig.mockImplementation(((cfg: OpenClawConfig, pluginId: string) => ({
config: cfg,
enabled: true,
pluginId,
})) as (...args: unknown[]) => unknown);
recordPluginInstall.mockImplementation(
((cfg: OpenClawConfig) => cfg) as (...args: unknown[]) => unknown,
);

View File

@@ -26,6 +26,7 @@ describe("plugins cli policy mutations", () => {
enablePluginInConfig.mockReturnValue({
config: enabledConfig,
enabled: true,
pluginId: "alpha",
});
await runPluginsCommand(["plugins", "enable", "alpha"]);
@@ -34,6 +35,7 @@ describe("plugins cli policy mutations", () => {
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: enabledConfig,
installRecords: {},
policyPluginIds: ["alpha"],
reason: "policy-changed",
});
});
@@ -54,6 +56,7 @@ describe("plugins cli policy mutations", () => {
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: nextConfig,
installRecords: {},
policyPluginIds: ["alpha"],
reason: "policy-changed",
});
});

View File

@@ -132,6 +132,7 @@ export function registerPluginsCli(program: Command) {
await refreshPluginRegistryAfterConfigMutation({
config: next,
reason: "policy-changed",
policyPluginIds: [enableResult.pluginId],
logger: {
warn: (message) => defaultRuntime.log(theme.warn(message)),
},
@@ -166,6 +167,7 @@ export function registerPluginsCli(program: Command) {
await refreshPluginRegistryAfterConfigMutation({
config: next,
reason: "policy-changed",
policyPluginIds: [id],
logger: {
warn: (message) => defaultRuntime.log(theme.warn(message)),
},

View File

@@ -15,6 +15,7 @@ export async function refreshPluginRegistryAfterConfigMutation(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
installRecords?: Awaited<ReturnType<typeof loadInstalledPluginIndexInstallRecords>>;
policyPluginIds?: readonly string[];
traceCommand?: string;
logger?: PluginRegistryRefreshLogger;
}): Promise<void> {
@@ -33,6 +34,7 @@ export async function refreshPluginRegistryAfterConfigMutation(params: {
config: params.config,
reason: params.reason,
installRecords,
...(params.policyPluginIds ? { policyPluginIds: params.policyPluginIds } : {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(params.env ? { env: params.env } : {}),
}),

View File

@@ -32,9 +32,10 @@ vi.mock("../plugins/install.js", () => ({
}));
const enablePluginInConfig = vi.hoisted(() =>
vi.fn<(cfg: OpenClawConfig, pluginId: string) => PluginEnableResult>((cfg) => ({
vi.fn<(cfg: OpenClawConfig, pluginId: string) => PluginEnableResult>((cfg, pluginId) => ({
config: cfg,
enabled: true,
pluginId,
})),
);
vi.mock("../plugins/enable.js", () => ({
@@ -342,6 +343,7 @@ describe("ensureOnboardingPluginInstalled", () => {
enablePluginInConfig.mockReturnValueOnce({
config: {},
enabled: false,
pluginId: "demo",
reason: "blocked by allowlist",
});
const note = vi.fn(async () => {});
@@ -484,65 +486,62 @@ describe("ensureOnboardingPluginInstalled", () => {
});
it("hides the npm download option for bundled plugins so the menu matches non-npm channels", async () => {
await withTempDir(
{ prefix: "openclaw-onboarding-install-bundled-prompt-" },
async (temp) => {
const bundledDir = path.join(temp, "dist", "extensions", "tlon");
await fs.mkdir(bundledDir, { recursive: true });
const realBundledDir = await fs.realpath(bundledDir);
// Both code paths that surface a bundled plugin to the install
// pipeline must agree on the local path: the catalog-driven
// resolver (used when an npm spec is present) and the pluginId
// fallback. We stub both so the prompt sees a stable bundled path.
resolveBundledInstallPlanForCatalogEntry.mockReturnValue({
bundledSource: { localPath: realBundledDir },
});
findBundledPluginSourceInMap.mockReturnValue({ localPath: realBundledDir });
await withTempDir({ prefix: "openclaw-onboarding-install-bundled-prompt-" }, async (temp) => {
const bundledDir = path.join(temp, "dist", "extensions", "tlon");
await fs.mkdir(bundledDir, { recursive: true });
const realBundledDir = await fs.realpath(bundledDir);
// Both code paths that surface a bundled plugin to the install
// pipeline must agree on the local path: the catalog-driven
// resolver (used when an npm spec is present) and the pluginId
// fallback. We stub both so the prompt sees a stable bundled path.
resolveBundledInstallPlanForCatalogEntry.mockReturnValue({
bundledSource: { localPath: realBundledDir },
});
findBundledPluginSourceInMap.mockReturnValue({ localPath: realBundledDir });
let captured:
| {
message: string;
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
initialValue: "npm" | "local" | "skip";
}
| undefined;
let captured:
| {
message: string;
options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>;
initialValue: "npm" | "local" | "skip";
}
| undefined;
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "tlon",
label: "Tlon",
install: {
npmSpec: "@openclaw/tlon",
defaultChoice: "npm",
},
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "tlon",
label: "Tlon",
install: {
npmSpec: "@openclaw/tlon",
defaultChoice: "npm",
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip";
}),
} as never,
runtime: {} as never,
});
},
prompter: {
select: vi.fn(async (input) => {
captured = input;
return "skip";
}),
} as never,
runtime: {} as never,
});
expect(captured).toBeDefined();
// "Download from npm (@openclaw/tlon)" must NOT appear: the bundled
// copy is what gets enabled, so the npm hint would only confuse
// users into thinking the plugin is missing.
expect(captured?.options).toEqual([
{
value: "local",
label: "Use local plugin path",
hint: realBundledDir,
},
{ value: "skip", label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("local");
findBundledPluginSourceInMap.mockReset();
resolveBundledInstallPlanForCatalogEntry.mockReset();
},
);
expect(captured).toBeDefined();
// "Download from npm (@openclaw/tlon)" must NOT appear: the bundled
// copy is what gets enabled, so the npm hint would only confuse
// users into thinking the plugin is missing.
expect(captured?.options).toEqual([
{
value: "local",
label: "Use local plugin path",
hint: realBundledDir,
},
{ value: "skip", label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("local");
findBundledPluginSourceInMap.mockReset();
resolveBundledInstallPlanForCatalogEntry.mockReset();
});
});
it("enables bundled plugins without adding their bundled directory as a local install", async () => {
@@ -564,6 +563,7 @@ describe("ensureOnboardingPluginInstalled", () => {
},
},
enabled: true,
pluginId: "discord",
});
const result = await ensureOnboardingPluginInstalled({

View File

@@ -5,6 +5,7 @@ import { setPluginEnabledInConfig } from "./toggle-config.js";
export type PluginEnableResult = {
config: OpenClawConfig;
enabled: boolean;
pluginId: string;
reason?: string;
};
@@ -16,10 +17,10 @@ export function enablePluginInConfig(
const builtInChannelId = normalizeChatChannelId(pluginId);
const resolvedId = builtInChannelId ?? pluginId;
if (cfg.plugins?.enabled === false) {
return { config: cfg, enabled: false, reason: "plugins disabled" };
return { config: cfg, enabled: false, pluginId: resolvedId, reason: "plugins disabled" };
}
if (cfg.plugins?.deny?.includes(pluginId) || cfg.plugins?.deny?.includes(resolvedId)) {
return { config: cfg, enabled: false, reason: "blocked by denylist" };
return { config: cfg, enabled: false, pluginId: resolvedId, reason: "blocked by denylist" };
}
const allow = cfg.plugins?.allow;
if (
@@ -28,10 +29,11 @@ export function enablePluginInConfig(
!allow.includes(pluginId) &&
!allow.includes(resolvedId)
) {
return { config: cfg, enabled: false, reason: "blocked by allowlist" };
return { config: cfg, enabled: false, pluginId: resolvedId, reason: "blocked by allowlist" };
}
return {
config: setPluginEnabledInConfig(cfg, resolvedId, true, options),
enabled: true,
pluginId: resolvedId,
};
}

View File

@@ -54,7 +54,8 @@ function createIndex(overrides: Partial<InstalledPluginIndex> = {}): InstalledPl
};
}
function createCandidate(rootDir: string): PluginCandidate {
function createCandidate(rootDir: string, options: { id?: string } = {}): PluginCandidate {
const id = options.id ?? "demo";
fs.writeFileSync(
path.join(rootDir, "index.ts"),
"throw new Error('runtime entry should not load while persisting installed plugin index');\n",
@@ -63,15 +64,15 @@ function createCandidate(rootDir: string): PluginCandidate {
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "demo",
name: "Demo",
id,
name: id === "demo" ? "Demo" : "Next Demo",
configSchema: { type: "object" },
providers: ["demo"],
providers: [id],
}),
"utf8",
);
return {
idHint: "demo",
idHint: id,
source: path.join(rootDir, "index.ts"),
rootDir,
origin: "global",
@@ -278,6 +279,99 @@ describe("installed plugin index persistence", () => {
});
});
it("refreshes policy state from the persisted registry without rebuilding source records", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");
fs.mkdirSync(pluginDir, { recursive: true });
const candidate = createCandidate(pluginDir);
const env = {
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
};
const initial = await refreshPersistedInstalledPluginIndex({
reason: "manual",
stateDir,
candidates: [candidate],
env,
});
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: "demo",
name: "Demo",
configSchema: { type: "object" },
providers: ["demo", "changed"],
}),
"utf8",
);
const refreshed = await refreshPersistedInstalledPluginIndex({
reason: "policy-changed",
stateDir,
candidates: [candidate],
env,
config: {
plugins: {
entries: {
demo: {
enabled: false,
},
},
},
},
policyPluginIds: ["demo"],
});
expect(refreshed.plugins).toHaveLength(initial.plugins.length);
expect(refreshed.plugins[0]).toMatchObject({
pluginId: "demo",
enabled: false,
manifestHash: initial.plugins[0]?.manifestHash,
});
expect(refreshed.policyHash).not.toBe(initial.policyHash);
});
it("falls back to a source rebuild when a policy refresh target is missing", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");
const nextPluginDir = path.join(stateDir, "plugins", "next-demo");
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(nextPluginDir, { recursive: true });
const candidate = createCandidate(pluginDir);
const nextCandidate = createCandidate(nextPluginDir, { id: "next-demo" });
const env = {
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
};
await refreshPersistedInstalledPluginIndex({
reason: "manual",
stateDir,
candidates: [candidate],
env,
});
const refreshed = await refreshPersistedInstalledPluginIndex({
reason: "policy-changed",
stateDir,
candidates: [candidate, nextCandidate],
env,
config: {
plugins: {
entries: {
"next-demo": {
enabled: false,
},
},
},
},
policyPluginIds: ["next-demo"],
});
expect(refreshed.plugins.map((plugin) => plugin.pluginId)).toContain("next-demo");
});
it("preserves existing install records when refreshing the manifest cache", async () => {
const stateDir = makeTempDir();
await writePersistedInstalledPluginIndex(

View File

@@ -3,7 +3,11 @@ import { saveJsonFile } from "../infra/json-file.js";
import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-files.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { safeParseWithSchema } from "../utils/zod-parse.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { clearCurrentPluginMetadataSnapshotState } from "./current-plugin-metadata-state.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import { resolveCompatRegistryVersion } from "./installed-plugin-index-policy.js";
import {
resolveInstalledPluginIndexStorePath,
type InstalledPluginIndexStoreOptions,
@@ -15,6 +19,7 @@ import {
INSTALLED_PLUGIN_INDEX_VERSION,
INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION,
loadInstalledPluginIndex,
resolveInstalledPluginIndexPolicyHash,
refreshInstalledPluginIndex,
type InstalledPluginIndex,
type InstalledPluginInstallRecordInfo,
@@ -185,6 +190,65 @@ export function writePersistedInstalledPluginIndexSync(
return filePath;
}
function hasPolicyRefreshTargets(
persisted: InstalledPluginIndex,
policyPluginIds: readonly string[] | undefined,
): boolean {
if (!policyPluginIds || policyPluginIds.length === 0) {
return true;
}
const pluginIds = new Set(persisted.plugins.map((plugin) => plugin.pluginId));
return policyPluginIds.every((pluginId) => pluginIds.has(pluginId));
}
function canRefreshPersistedPolicyState(
persisted: InstalledPluginIndex | null,
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
): persisted is InstalledPluginIndex {
if (!persisted || params.reason !== "policy-changed") {
return false;
}
const env = params.env ?? process.env;
if (
persisted.version !== INSTALLED_PLUGIN_INDEX_VERSION ||
persisted.hostContractVersion !== resolveCompatibilityHostVersion(env) ||
persisted.compatRegistryVersion !== resolveCompatRegistryVersion() ||
persisted.migrationVersion !== INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION
) {
return false;
}
if (
params.installRecords &&
hashJson(params.installRecords) !== hashJson(persisted.installRecords ?? {})
) {
return false;
}
return hasPolicyRefreshTargets(persisted, params.policyPluginIds);
}
function refreshPersistedPolicyState(
persisted: InstalledPluginIndex,
params: RefreshInstalledPluginIndexParams,
): InstalledPluginIndex {
const normalizedConfig = normalizePluginsConfig(params.config?.plugins);
return {
...persisted,
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
generatedAtMs: (params.now?.() ?? new Date()).getTime(),
refreshReason: params.reason,
plugins: persisted.plugins.map((plugin) => ({
...plugin,
enabled: resolveEffectiveEnableState({
id: plugin.pluginId,
origin: plugin.origin,
config: normalizedConfig,
rootConfig: params.config,
enabledByDefault: plugin.enabledByDefault,
}).enabled,
})),
};
}
export async function inspectPersistedInstalledPluginIndex(
params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {},
): Promise<InstalledPluginIndexStoreInspection> {
@@ -215,7 +279,15 @@ export async function inspectPersistedInstalledPluginIndex(
export async function refreshPersistedInstalledPluginIndex(
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
): Promise<InstalledPluginIndex> {
const persisted = params.installRecords ? null : await readPersistedInstalledPluginIndex(params);
const persisted =
params.reason === "policy-changed" || !params.installRecords
? await readPersistedInstalledPluginIndex(params)
: null;
if (canRefreshPersistedPolicyState(persisted, params)) {
const index = refreshPersistedPolicyState(persisted, params);
await writePersistedInstalledPluginIndex(index, params);
return index;
}
const index = refreshInstalledPluginIndex({
...params,
installRecords:
@@ -228,7 +300,15 @@ export async function refreshPersistedInstalledPluginIndex(
export function refreshPersistedInstalledPluginIndexSync(
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
): InstalledPluginIndex {
const persisted = params.installRecords ? null : readPersistedInstalledPluginIndexSync(params);
const persisted =
params.reason === "policy-changed" || !params.installRecords
? readPersistedInstalledPluginIndexSync(params)
: null;
if (canRefreshPersistedPolicyState(persisted, params)) {
const index = refreshPersistedPolicyState(persisted, params);
writePersistedInstalledPluginIndexSync(index, params);
return index;
}
const index = refreshInstalledPluginIndex({
...params,
installRecords:

View File

@@ -125,4 +125,5 @@ export type LoadInstalledPluginIndexParams = {
export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & {
reason: InstalledPluginIndexRefreshReason;
policyPluginIds?: readonly string[];
};