diff --git a/CHANGELOG.md b/CHANGELOG.md index bd938f5eda9..525e1e9ef49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 15e95eb7733..a0bbb9f6c9d 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -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, ); diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index 14165bba8dd..687d9b87d68 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -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", }); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 8410816c5f7..30dbc7d34a7 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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)), }, diff --git a/src/cli/plugins-registry-refresh.ts b/src/cli/plugins-registry-refresh.ts index 11307f8ce5e..64c3eec4e4c 100644 --- a/src/cli/plugins-registry-refresh.ts +++ b/src/cli/plugins-registry-refresh.ts @@ -15,6 +15,7 @@ export async function refreshPluginRegistryAfterConfigMutation(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; installRecords?: Awaited>; + policyPluginIds?: readonly string[]; traceCommand?: string; logger?: PluginRegistryRefreshLogger; }): Promise { @@ -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 } : {}), }), diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index ef11d3e08de..4248f0b7566 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -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({ diff --git a/src/plugins/enable.ts b/src/plugins/enable.ts index ba4b4dac07b..bd9de644103 100644 --- a/src/plugins/enable.ts +++ b/src/plugins/enable.ts @@ -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, }; } diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index ddfe4ff599d..28d37c3495d 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -54,7 +54,8 @@ function createIndex(overrides: Partial = {}): 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( diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 6bc1d979e3a..3374adc2e7c 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -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 { @@ -215,7 +279,15 @@ export async function inspectPersistedInstalledPluginIndex( export async function refreshPersistedInstalledPluginIndex( params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions, ): Promise { - 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: diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts index 4f64d54323e..4d1e9bcf079 100644 --- a/src/plugins/installed-plugin-index-types.ts +++ b/src/plugins/installed-plugin-index-types.ts @@ -125,4 +125,5 @@ export type LoadInstalledPluginIndexParams = { export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & { reason: InstalledPluginIndexRefreshReason; + policyPluginIds?: readonly string[]; };