From bf6a02c6da8e6fb511f9797b0e5dd85f21898803 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 07:10:33 +0100 Subject: [PATCH] fix: stabilize release validation --- extensions/feishu/openclaw.plugin.json | 44 +++++++++++++++++++ .../runtime-smoke.mjs | 4 +- scripts/e2e/lib/fixtures/plugins.mjs | 8 ++-- .../lib/kitchen-sink-plugin/assertions.mjs | 1 + src/commands/agent.test.ts | 30 ++++++------- src/plugins/installed-plugin-index.test.ts | 40 +++++++++++++++-- src/plugins/installed-plugin-index.ts | 5 ++- 7 files changed, 107 insertions(+), 25 deletions(-) diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json index cfe2ed3bb06..8295baeeee3 100644 --- a/extensions/feishu/openclaw.plugin.json +++ b/extensions/feishu/openclaw.plugin.json @@ -22,6 +22,50 @@ "feishu_wiki" ] }, + "toolMetadata": { + "feishu_app_scopes": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_create_app": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_create_field": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_create_record": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_get_meta": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_get_record": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_list_fields": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_list_records": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_bitable_update_record": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_chat": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_doc": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_drive": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_perm": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + }, + "feishu_wiki": { + "configSignals": [{ "rootPath": "channels.feishu", "required": ["appId", "appSecret"] }] + } + }, "channelEnvVars": { "feishu": [ "FEISHU_APP_ID", diff --git a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs index ad7a0bf5da5..b9c1c9e785b 100644 --- a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs +++ b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs @@ -109,12 +109,14 @@ function buildPluginPlan(manifest) { ? contracts.speechProviders.filter(isNonEmptyString) : []; const tools = Array.isArray(contracts.tools) ? contracts.tools.filter(isNonEmptyString) : []; + const toolMetadata = + manifest.toolMetadata && typeof manifest.toolMetadata === "object" ? manifest.toolMetadata : {}; const activeInThisProbe = manifest.activation?.onStartup === true || channels.length > 0 || speechProviders.length > 0; return { channels, speechProviders, - tools, + tools: tools.filter((tool) => !toolMetadata[tool]), activeInThisProbe, runtimeSlashAliases: commandAliases .filter((alias) => alias?.kind === "runtime-slash") diff --git a/scripts/e2e/lib/fixtures/plugins.mjs b/scripts/e2e/lib/fixtures/plugins.mjs index f54650d0fee..b61b53b9928 100644 --- a/scripts/e2e/lib/fixtures/plugins.mjs +++ b/scripts/e2e/lib/fixtures/plugins.mjs @@ -1,8 +1,8 @@ import path from "node:path"; import { requireArg, write, writeJson } from "./common.mjs"; -function writePluginManifest(file, id) { - writeJson(file, { id, configSchema: { type: "object", properties: {} } }); +function writePluginManifest(file, id, extra = {}) { + writeJson(file, { id, ...extra, configSchema: { type: "object", properties: {} } }); } function writeFakeIsNumberPackage(dir) { @@ -19,7 +19,9 @@ function writePluginDemo([dir]) { path.join(requireArg(dir, "dir"), "index.js"), 'module.exports = { id: "demo-plugin", name: "Demo Plugin", description: "Docker E2E demo plugin", register(api) { api.registerTool(() => null, { name: "demo_tool" }); api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); api.registerCli(() => {}, { commands: ["demo"] }); api.registerService({ id: "demo-service", start: () => {} }); }, };\n', ); - writePluginManifest(path.join(dir, "openclaw.plugin.json"), "demo-plugin"); + writePluginManifest(path.join(dir, "openclaw.plugin.json"), "demo-plugin", { + contracts: { tools: ["demo_tool"] }, + }); } function writePlugin([dir, id, version, method, name]) { diff --git a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs index 41a64527d86..26e3e6def68 100644 --- a/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs +++ b/scripts/e2e/lib/kitchen-sink-plugin/assertions.mjs @@ -152,6 +152,7 @@ function assertExpectedDiagnostics(surfaceMode, errorMessages) { 'channel "kitchen-sink-channel-probe" registration missing required config helpers', "cli registration missing explicit commands metadata", "only bundled plugins can register Codex app-server extension factories", + "only bundled plugins can register agent tool result middleware", 'compaction provider "kitchen-sink-compaction-provider" registration missing summarize', "context engine registration missing id", "http route registration missing or invalid auth: /kitchen-sink/http-route", diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 820ceb1d688..ab8f3d9bf66 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -301,6 +301,11 @@ async function runAgentWithSessionKey(sessionKey: string): Promise { await agentCommand({ message: "hi", sessionKey }, runtime); } +function mockModelCatalogOnce(entries: ReturnType): void { + vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(entries); + vi.mocked(loadModelCatalog).mockResolvedValueOnce(entries); +} + beforeEach(() => { vi.clearAllMocks(); clearSessionStoreCacheForTest(); @@ -309,6 +314,7 @@ beforeEach(() => { acpManagerTesting.resetAcpSessionManagerForTests(); runtimeSnapshotModule.clearRuntimeConfigSnapshot(); vi.mocked(runEmbeddedPiAgent).mockResolvedValue(createDefaultAgentResult()); + vi.mocked(loadManifestModelCatalog).mockReturnValue([]); vi.mocked(loadModelCatalog).mockResolvedValue([]); vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); configIoMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ @@ -607,13 +613,11 @@ describe("agentCommand", () => { }, }); - const catalog = [ + mockModelCatalogOnce([ { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-5.4", name: "GPT-5.2", provider: "openai" }, - ]; - vi.mocked(loadModelCatalog).mockResolvedValueOnce(catalog); - vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(catalog); + ]); vi.mocked(runEmbeddedPiAgent) .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 })) .mockResolvedValueOnce({ @@ -667,13 +671,11 @@ describe("agentCommand", () => { }, }); - const catalog = [ + mockModelCatalogOnce([ { id: "qwen3.5:27b", name: "Qwen 3.5", provider: "ollama" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, { id: "gpt-5.4", name: "GPT-5.4", provider: "openai" }, - ]; - vi.mocked(loadModelCatalog).mockResolvedValueOnce(catalog); - vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(catalog); + ]); vi.mocked(runEmbeddedPiAgent).mockRejectedValueOnce(new Error("connect ECONNREFUSED")); await expect( @@ -718,12 +720,10 @@ describe("agentCommand", () => { }, }); - const catalog = [ + mockModelCatalogOnce([ { id: "claude-opus-4-6", name: "Opus", provider: "anthropic" }, { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, - ]; - vi.mocked(loadModelCatalog).mockResolvedValueOnce(catalog); - vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(catalog); + ]); await runAgentWithSessionKey("agent:main:subagent:clear-overrides"); @@ -877,16 +877,14 @@ describe("agentCommand", () => { "openai/gpt-4.1-mini": {}, }, }); - const catalog = [ + mockModelCatalogOnce([ { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai", reasoning: true, }, - ]; - vi.mocked(loadModelCatalog).mockResolvedValueOnce(catalog); - vi.mocked(loadManifestModelCatalog).mockReturnValueOnce(catalog); + ]); await agentCommand({ message: "hi", to: "+1555" }, runtime); diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 7e1ac7337eb..7300fbea3d1 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -90,15 +90,16 @@ function createPluginCandidate(params: { }; } -function createRichPluginFixture(params: { packageVersion?: string } = {}) { +function createRichPluginFixture(params: { id?: string; packageVersion?: string } = {}) { const rootDir = makeTempDir(); + const id = params.id ?? "demo"; writeRuntimeEntry(rootDir); writePackageJson(rootDir, { - name: "@vendor/demo-plugin", + name: `@vendor/${id}`, version: params.packageVersion ?? "1.2.3", }); writePluginManifest(rootDir, { - id: "demo", + id, name: "Demo", configSchema: { type: "object" }, providers: ["demo"], @@ -566,6 +567,39 @@ describe("installed plugin index", () => { expect(isInstalledPluginEnabled(index, "demo", config)).toBe(false); }); + it("keeps an index-disabled plugin disabled when config only enables another plugin", () => { + const enabledFixture = createRichPluginFixture({ id: "enabled-demo" }); + const disabledFixture = createRichPluginFixture({ id: "disabled-demo" }); + const index = loadInstalledPluginIndex({ + candidates: [enabledFixture.candidate, disabledFixture.candidate], + config: { + plugins: { + entries: { + "disabled-demo": { + enabled: false, + }, + }, + }, + }, + env: hermeticEnv(), + }); + + expect(index.plugins.find((plugin) => plugin.pluginId === "disabled-demo")?.enabled).toBe( + false, + ); + expect( + isInstalledPluginEnabled(index, "disabled-demo", { + plugins: { + entries: { + "enabled-demo": { + enabled: true, + }, + }, + }, + }), + ).toBe(false); + }); + it("uses runtime plugin id normalization for legacy enablement aliases", () => { const rootDir = makeTempDir(); writeRuntimeEntry(rootDir); diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index b2545c9b9f0..abcb4ff765b 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -136,11 +136,12 @@ export function isInstalledPluginEnabled( return record.enabled; } const normalizedConfig = normalizePluginsConfig(config?.plugins); - return resolveEffectiveEnableState({ + const state = resolveEffectiveEnableState({ id: record.pluginId, origin: record.origin, config: normalizedConfig, rootConfig: config, enabledByDefault: record.enabledByDefault, - }).enabled; + }); + return state.enabled && (record.enabled || state.explicitlyEnabled === true); }