From 9eed6e674bf6c68823ec4ecb43e712fcf7a4ace1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:09:29 -0700 Subject: [PATCH] fix(plugins): restore provider compatibility fallbacks --- extensions/ollama/index.ts | 3 +- ...ig.providers.cloudflare-ai-gateway.test.ts | 83 +++++++++++++++++++ ....providers.plugin-allowlist-compat.test.ts | 53 ++++++++++++ src/agents/pi-embedded-runner/cache-ttl.ts | 4 +- .../extra-params.kilocode.test.ts | 72 +++++++++++++++- src/plugins/provider-discovery.ts | 5 +- src/plugins/provider-runtime.test.ts | 6 ++ src/plugins/provider-runtime.ts | 7 +- src/plugins/providers.test.ts | 21 +++++ src/plugins/providers.ts | 66 ++++++++++++++- 10 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 src/agents/models-config.providers.plugin-allowlist-compat.test.ts diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 6ba28a3af7c..c0b325e5a64 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -11,6 +11,7 @@ import { type ProviderAuthResult, type ProviderDiscoveryContext, } from "openclaw/plugin-sdk/core"; +import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js"; const PROVIDER_ID = "ollama"; const DEFAULT_API_KEY = "ollama-local"; @@ -72,7 +73,7 @@ const ollamaPlugin = { ...explicit, baseUrl: typeof explicit.baseUrl === "string" && explicit.baseUrl.trim() - ? explicit.baseUrl.trim().replace(/\/+$/, "") + ? resolveOllamaApiBase(explicit.baseUrl) : OLLAMA_DEFAULT_BASE_URL, api: explicit.api ?? "ollama", apiKey: ollamaKey ?? explicit.apiKey ?? DEFAULT_API_KEY, diff --git a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts index dad90c740d2..c6de651e811 100644 --- a/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts +++ b/src/agents/models-config.providers.cloudflare-ai-gateway.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; +import { resolveCloudflareAiGatewayBaseUrl } from "./cloudflare-ai-gateway.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; @@ -73,4 +74,86 @@ describe("cloudflare-ai-gateway profile provenance", () => { const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); }); + + it("keeps Cloudflare gateway metadata and apiKey from the same auth profile", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:key-only": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-first", + }, + "cloudflare-ai-gateway:gateway": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "sk-second", + metadata: { + accountId: "acct_456", + gatewayId: "gateway_789", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("sk-second"); + expect(providers?.["cloudflare-ai-gateway"]?.baseUrl).toBe( + resolveCloudflareAiGatewayBaseUrl({ + accountId: "acct_456", + gatewayId: "gateway_789", + }), + ); + }); + + it("prefers the runtime env marker over stored profile secrets", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["CLOUDFLARE_AI_GATEWAY_API_KEY"]); + process.env.CLOUDFLARE_AI_GATEWAY_API_KEY = "rotated-secret"; // pragma: allowlist secret + + await writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "cloudflare-ai-gateway:default": { + type: "api_key", + provider: "cloudflare-ai-gateway", + key: "stale-stored-secret", + metadata: { + accountId: "acct_123", + gatewayId: "gateway_456", + }, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + try { + const providers = await resolveImplicitProvidersForTest({ agentDir }); + expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY"); + expect(providers?.["cloudflare-ai-gateway"]?.baseUrl).toBe( + resolveCloudflareAiGatewayBaseUrl({ + accountId: "acct_123", + gatewayId: "gateway_456", + }), + ); + } finally { + envSnapshot.restore(); + } + }); }); diff --git a/src/agents/models-config.providers.plugin-allowlist-compat.test.ts b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts new file mode 100644 index 00000000000..594ebce3e2c --- /dev/null +++ b/src/agents/models-config.providers.plugin-allowlist-compat.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { captureEnv } from "../test-utils/env.js"; +import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; + +describe("implicit provider plugin allowlist compatibility", () => { + it("keeps bundled implicit providers discoverable when plugins.allow is set", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["KILOCODE_API_KEY", "MOONSHOT_API_KEY"]); + process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret + process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret + + try { + const providers = await resolveImplicitProvidersForTest({ + agentDir, + config: { + plugins: { + allow: ["openrouter"], + }, + }, + }); + expect(providers?.kilocode).toBeDefined(); + expect(providers?.moonshot).toBeDefined(); + } finally { + envSnapshot.restore(); + } + }); + + it("still honors explicit plugin denies over compat allowlist injection", async () => { + const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); + const envSnapshot = captureEnv(["KILOCODE_API_KEY", "MOONSHOT_API_KEY"]); + process.env.KILOCODE_API_KEY = "test-kilo-key"; // pragma: allowlist secret + process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret + + try { + const providers = await resolveImplicitProvidersForTest({ + agentDir, + config: { + plugins: { + allow: ["openrouter"], + deny: ["kilocode"], + }, + }, + }); + expect(providers?.kilocode).toBeUndefined(); + expect(providers?.moonshot).toBeDefined(); + } finally { + envSnapshot.restore(); + } + }); +}); diff --git a/src/agents/pi-embedded-runner/cache-ttl.ts b/src/agents/pi-embedded-runner/cache-ttl.ts index e971f564edd..02075cd78cf 100644 --- a/src/agents/pi-embedded-runner/cache-ttl.ts +++ b/src/agents/pi-embedded-runner/cache-ttl.ts @@ -25,10 +25,10 @@ export function isCacheTtlEligibleProvider(provider: string, modelId: string): b if (pluginEligibility !== undefined) { return pluginEligibility; } - if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { + if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) { return true; } - if (normalizedProvider === "kilocode" && normalizedModelId.startsWith("anthropic/")) { + if (CACHE_TTL_NATIVE_PROVIDERS.has(normalizedProvider)) { return true; } return false; diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts index 35a6cefcbd4..c4e81d2d804 100644 --- a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts @@ -2,6 +2,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { Context, Model } from "@mariozechner/pi-ai"; import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; import { captureEnv } from "../../test-utils/env.js"; import { applyExtraParamsToAgent } from "./extra-params.js"; @@ -10,10 +11,21 @@ type CapturedCall = { payload?: Record; }; +const TEST_CFG = { + plugins: { + entries: { + kilocode: { + enabled: true, + }, + }, + }, +} satisfies OpenClawConfig; + function applyAndCapture(params: { provider: string; modelId: string; callerHeaders?: Record; + cfg?: OpenClawConfig; }): CapturedCall { const captured: CapturedCall = {}; @@ -24,7 +36,7 @@ function applyAndCapture(params: { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId); + applyExtraParamsToAgent(agent, params.cfg ?? TEST_CFG, params.provider, params.modelId); const model = { api: "openai-completions", @@ -81,6 +93,22 @@ describe("extra-params: Kilocode wrapper", () => { expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw"); }); + it("keeps Kilocode runtime wrapping under restrictive plugins.allow", () => { + delete process.env.KILOCODE_FEATURE; + + const { headers } = applyAndCapture({ + provider: "kilocode", + modelId: "anthropic/claude-sonnet-4", + cfg: { + plugins: { + allow: ["openrouter"], + }, + }, + }); + + expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw"); + }); + it("does not inject header for non-kilocode providers", () => { const { headers } = applyAndCapture({ provider: "openrouter", @@ -104,7 +132,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const agent = { streamFn: baseStreamFn }; // Pass thinking level explicitly (6th parameter) to trigger reasoning injection - applyExtraParamsToAgent(agent, undefined, "kilocode", "kilo/auto", undefined, "high"); + applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "kilo/auto", undefined, "high"); const model = { api: "openai-completions", @@ -133,7 +161,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { applyExtraParamsToAgent( agent, - undefined, + TEST_CFG, "kilocode", "anthropic/claude-sonnet-4", undefined, @@ -153,6 +181,42 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); }); + it("still normalizes reasoning for Kilocode under restrictive plugins.allow", () => { + let capturedPayload: Record | undefined; + + const baseStreamFn: StreamFn = (model, _context, options) => { + const payload: Record = {}; + options?.onPayload?.(payload, model); + capturedPayload = payload; + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent( + agent, + { + plugins: { + allow: ["openrouter"], + }, + }, + "kilocode", + "anthropic/claude-sonnet-4", + undefined, + "high", + ); + + const model = { + api: "openai-completions", + provider: "kilocode", + id: "anthropic/claude-sonnet-4", + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, {}); + + expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); + }); + it("does not inject reasoning.effort for x-ai models", () => { let capturedPayload: Record | undefined; @@ -164,7 +228,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "kilocode", "x-ai/grok-3", undefined, "high"); + applyExtraParamsToAgent(agent, TEST_CFG, "kilocode", "x-ai/grok-3", undefined, "high"); const model = { api: "openai-completions", diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index ccecd889fa3..e249bf6e45a 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -15,7 +15,10 @@ export function resolvePluginDiscoveryProviders(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin[] { - return resolvePluginProviders(params).filter((provider) => resolveProviderCatalogHook(provider)); + return resolvePluginProviders({ + ...params, + bundledProviderAllowlistCompat: true, + }).filter((provider) => resolveProviderCatalogHook(provider)); } export function groupPluginDiscoveryProvidersByOrder( diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 9db3ef3e002..723c5344bb4 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -51,6 +51,12 @@ describe("provider-runtime", () => { const plugin = resolveProviderRuntimePlugin({ provider: "Open Router" }); expect(plugin?.id).toBe("openrouter"); + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "Open Router", + bundledProviderAllowlistCompat: true, + }), + ); }); it("dispatches runtime hooks for the matched provider", async () => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index ca44f33a8ba..a96cc7a0569 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -29,9 +29,10 @@ export function resolveProviderRuntimePlugin(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderPlugin | undefined { - return resolvePluginProviders(params).find((plugin) => - matchesProviderId(plugin, params.provider), - ); + return resolvePluginProviders({ + ...params, + bundledProviderAllowlistCompat: true, + }).find((plugin) => matchesProviderId(plugin, params.provider)); } export function runProviderDynamicModel(params: { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 26c70df090a..7df6432b4c3 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -31,4 +31,25 @@ describe("resolvePluginProviders", () => { }), ); }); + + it("can augment restrictive allowlists for bundled provider compatibility", () => { + resolvePluginProviders({ + config: { + plugins: { + allow: ["openrouter"], + }, + }, + bundledProviderAllowlistCompat: true, + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + allow: expect.arrayContaining(["openrouter", "kilocode", "moonshot"]), + }), + }), + }), + ); + }); }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 4847a61935b..dda000e2641 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -4,15 +4,79 @@ import { createPluginLoaderLogger } from "./logger.js"; import type { ProviderPlugin } from "./types.js"; const log = createSubsystemLogger("plugins"); +const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [ + "byteplus", + "cloudflare-ai-gateway", + "copilot-proxy", + "github-copilot", + "google-gemini-cli-auth", + "huggingface", + "kilocode", + "kimi-coding", + "minimax", + "minimax-portal-auth", + "modelstudio", + "moonshot", + "nvidia", + "ollama", + "openai-codex", + "openrouter", + "qianfan", + "qwen-portal-auth", + "sglang", + "synthetic", + "together", + "venice", + "vercel-ai-gateway", + "volcengine", + "vllm", + "xiaomi", +] as const; + +function withBundledProviderAllowlistCompat( + config: PluginLoadOptions["config"], +): PluginLoadOptions["config"] { + const allow = config?.plugins?.allow; + if (!Array.isArray(allow) || allow.length === 0) { + return config; + } + + const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); + let changed = false; + for (const pluginId of BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS) { + if (!allowSet.has(pluginId)) { + allowSet.add(pluginId); + changed = true; + } + } + + if (!changed) { + return config; + } + + return { + ...config, + plugins: { + ...config?.plugins, + // Backward compat: bundled implicit providers historically stayed + // available even when operators kept a restrictive plugin allowlist. + allow: [...allowSet], + }, + }; +} export function resolvePluginProviders(params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: PluginLoadOptions["env"]; + bundledProviderAllowlistCompat?: boolean; }): ProviderPlugin[] { + const config = params.bundledProviderAllowlistCompat + ? withBundledProviderAllowlistCompat(params.config) + : params.config; const registry = loadOpenClawPlugins({ - config: params.config, + config, workspaceDir: params.workspaceDir, env: params.env, logger: createPluginLoaderLogger(log),