feat: add bundled Chutes extension (#49136)

* refactor: generalize bundled provider discovery seams

* feat: land chutes extension via plugin-owned auth (#41416) (thanks @Veightor)
This commit is contained in:
Peter Steinberger
2026-03-17 09:35:21 -07:00
committed by GitHub
parent ea15819ecf
commit a724bbce1a
31 changed files with 1856 additions and 171 deletions

View File

@@ -270,4 +270,9 @@ describe("resolveEnableState", () => {
const state = resolveEnableState("google", "bundled", normalizePluginsConfig({}));
expect(state).toEqual({ enabled: true });
});
it("allows bundled plugins to opt into default enablement from manifest metadata", () => {
const state = resolveEnableState("profile-aware", "bundled", normalizePluginsConfig({}), true);
expect(state).toEqual({ enabled: true });
});
});

View File

@@ -274,6 +274,7 @@ export function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
enabledByDefault?: boolean,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
@@ -298,7 +299,7 @@ export function resolveEnableState(
if (entry?.enabled === true) {
return { enabled: true };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
if (origin === "bundled" && (enabledByDefault ?? BUNDLED_ENABLED_BY_DEFAULT.has(id))) {
return { enabled: true };
}
if (origin === "bundled") {
@@ -331,8 +332,9 @@ export function resolveEffectiveEnableState(params: {
origin: PluginRecord["origin"];
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
enabledByDefault?: boolean;
}): { enabled: boolean; reason?: string } {
const base = resolveEnableState(params.id, params.origin, params.config);
const base = resolveEnableState(params.id, params.origin, params.config, params.enabledByDefault);
if (
!base.enabled &&
base.reason === "bundled (disabled by default)" &&

View File

@@ -131,12 +131,30 @@ function runCatalog(params: {
provider: Awaited<ReturnType<typeof requireProvider>>;
env?: NodeJS.ProcessEnv;
resolveProviderApiKey?: () => { apiKey: string | undefined };
resolveProviderAuth?: (
providerId?: string,
options?: { oauthMarker?: string },
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
}) {
return runProviderCatalog({
provider: params.provider,
config: {},
env: params.env ?? ({} as NodeJS.ProcessEnv),
resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })),
resolveProviderAuth:
params.resolveProviderAuth ??
((_, options) => ({
apiKey: options?.oauthMarker,
discoveryApiKey: undefined,
mode: options?.oauthMarker ? "oauth" : "none",
source: options?.oauthMarker ? "profile" : "none",
})),
});
}
@@ -249,6 +267,12 @@ describe("provider discovery contract", () => {
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toMatchObject({
provider: {
@@ -274,6 +298,12 @@ describe("provider discovery contract", () => {
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
@@ -297,6 +327,12 @@ describe("provider discovery contract", () => {
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
}),
resolveProviderAuth: () => ({
apiKey: "VLLM_API_KEY",
discoveryApiKey: "env-vllm-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
@@ -329,6 +365,12 @@ describe("provider discovery contract", () => {
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
}),
resolveProviderAuth: () => ({
apiKey: "SGLANG_API_KEY",
discoveryApiKey: "env-sglang-key",
mode: "api_key",
source: "env",
}),
}),
).resolves.toEqual({
provider: {
@@ -352,6 +394,12 @@ describe("provider discovery contract", () => {
MINIMAX_API_KEY: "minimax-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: "minimax-key" }),
resolveProviderAuth: () => ({
apiKey: "minimax-key",
discoveryApiKey: undefined,
mode: "api_key",
source: "env",
}),
}),
).resolves.toMatchObject({
provider: {
@@ -391,6 +439,13 @@ describe("provider discovery contract", () => {
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: "minimax-oauth",
discoveryApiKey: "access-token",
mode: "oauth",
source: "profile",
profileId: "minimax-portal:default",
}),
}),
).resolves.toMatchObject({
provider: {
@@ -420,6 +475,12 @@ describe("provider discovery contract", () => {
},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toMatchObject({
provider: {
@@ -447,6 +508,12 @@ describe("provider discovery contract", () => {
MODELSTUDIO_API_KEY: "modelstudio-key",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }),
resolveProviderAuth: () => ({
apiKey: "modelstudio-key",
discoveryApiKey: undefined,
mode: "api_key",
source: "env",
}),
}),
).resolves.toMatchObject({
provider: {
@@ -468,6 +535,12 @@ describe("provider discovery contract", () => {
config: {},
env: {} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toBeNull();
});
@@ -504,6 +577,12 @@ describe("provider discovery contract", () => {
CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value",
} as NodeJS.ProcessEnv,
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
}),
).resolves.toEqual({
provider: {

View File

@@ -1,41 +1,18 @@
import amazonBedrockPlugin from "../../../extensions/amazon-bedrock/index.js";
import anthropicPlugin from "../../../extensions/anthropic/index.js";
import bravePlugin from "../../../extensions/brave/index.js";
import byteplusPlugin from "../../../extensions/byteplus/index.js";
import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js";
import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js";
import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js";
import firecrawlPlugin from "../../../extensions/firecrawl/index.js";
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
import googlePlugin from "../../../extensions/google/index.js";
import huggingFacePlugin from "../../../extensions/huggingface/index.js";
import kilocodePlugin from "../../../extensions/kilocode/index.js";
import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js";
import microsoftPlugin from "../../../extensions/microsoft/index.js";
import minimaxPlugin from "../../../extensions/minimax/index.js";
import mistralPlugin from "../../../extensions/mistral/index.js";
import modelStudioPlugin from "../../../extensions/modelstudio/index.js";
import moonshotPlugin from "../../../extensions/moonshot/index.js";
import nvidiaPlugin from "../../../extensions/nvidia/index.js";
import ollamaPlugin from "../../../extensions/ollama/index.js";
import openAIPlugin from "../../../extensions/openai/index.js";
import opencodeGoPlugin from "../../../extensions/opencode-go/index.js";
import opencodePlugin from "../../../extensions/opencode/index.js";
import openRouterPlugin from "../../../extensions/openrouter/index.js";
import perplexityPlugin from "../../../extensions/perplexity/index.js";
import qianfanPlugin from "../../../extensions/qianfan/index.js";
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
import sglangPlugin from "../../../extensions/sglang/index.js";
import syntheticPlugin from "../../../extensions/synthetic/index.js";
import togetherPlugin from "../../../extensions/together/index.js";
import venicePlugin from "../../../extensions/venice/index.js";
import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js";
import vllmPlugin from "../../../extensions/vllm/index.js";
import volcenginePlugin from "../../../extensions/volcengine/index.js";
import xaiPlugin from "../../../extensions/xai/index.js";
import xiaomiPlugin from "../../../extensions/xiaomi/index.js";
import zaiPlugin from "../../../extensions/zai/index.js";
import { createCapturedPluginRegistration } from "../captured-registration.js";
import { resolvePluginProviders } from "../providers.js";
import type {
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
@@ -75,41 +52,6 @@ type PluginRegistrationContractEntry = {
toolNames: string[];
};
const bundledProviderPlugins: RegistrablePlugin[] = [
amazonBedrockPlugin,
anthropicPlugin,
byteplusPlugin,
cloudflareAiGatewayPlugin,
copilotProxyPlugin,
githubCopilotPlugin,
googlePlugin,
huggingFacePlugin,
kilocodePlugin,
kimiCodingPlugin,
minimaxPlugin,
mistralPlugin,
modelStudioPlugin,
moonshotPlugin,
nvidiaPlugin,
ollamaPlugin,
opencodeGoPlugin,
opencodePlugin,
openAIPlugin,
openRouterPlugin,
qianfanPlugin,
qwenPortalPlugin,
sglangPlugin,
syntheticPlugin,
togetherPlugin,
venicePlugin,
vercelAiGatewayPlugin,
vllmPlugin,
volcenginePlugin,
xaiPlugin,
xiaomiPlugin,
zaiPlugin,
];
const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unknown }> = [
{ ...bravePlugin, credentialValue: "BSA-test" },
{ ...firecrawlPlugin, credentialValue: "fc-test" },
@@ -153,10 +95,30 @@ function buildCapabilityContractRegistry<T>(params: {
}
export const providerContractRegistry: ProviderContractEntry[] = buildCapabilityContractRegistry({
plugins: bundledProviderPlugins,
select: (captured) => captured.providers,
plugins: [],
select: () => [],
});
const loadedBundledProviderRegistry: ProviderContractEntry[] = resolvePluginProviders({
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
cache: false,
activate: false,
})
.filter((provider): provider is ProviderPlugin & { pluginId: string } =>
Boolean(provider.pluginId),
)
.map((provider) => ({
pluginId: provider.pluginId,
provider,
}));
providerContractRegistry.splice(
0,
providerContractRegistry.length,
...loadedBundledProviderRegistry,
);
export const uniqueProviderContractProviders: ProviderPlugin[] = [
...new Map(providerContractRegistry.map((entry) => [entry.provider.id, entry.provider])).values(),
];
@@ -234,7 +196,6 @@ export const imageGenerationProviderContractRegistry: ImageGenerationProviderCon
const bundledPluginRegistrationList = [
...new Map(
[
...bundledProviderPlugins,
...bundledSpeechPlugins,
...bundledMediaUnderstandingPlugins,
...bundledImageGenerationPlugins,
@@ -243,18 +204,47 @@ const bundledPluginRegistrationList = [
).values(),
];
export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] =
bundledPluginRegistrationList.map((plugin) => {
const captured = captureRegistrations(plugin);
return {
pluginId: plugin.id,
providerIds: captured.providers.map((provider) => provider.id),
speechProviderIds: captured.speechProviders.map((provider) => provider.id),
mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map(
(provider) => provider.id,
),
imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id),
webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id),
toolNames: captured.tools.map((tool) => tool.name),
};
});
export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry[] = [
...new Map(
providerContractRegistry.map((entry) => [
entry.pluginId,
{
pluginId: entry.pluginId,
providerIds: providerContractRegistry
.filter((candidate) => candidate.pluginId === entry.pluginId)
.map((candidate) => candidate.provider.id),
speechProviderIds: [] as string[],
mediaUnderstandingProviderIds: [] as string[],
imageGenerationProviderIds: [] as string[],
webSearchProviderIds: [] as string[],
toolNames: [] as string[],
},
]),
).values(),
];
for (const plugin of bundledPluginRegistrationList) {
const captured = captureRegistrations(plugin);
const existing = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === plugin.id);
const next = {
pluginId: plugin.id,
providerIds: captured.providers.map((provider) => provider.id),
speechProviderIds: captured.speechProviders.map((provider) => provider.id),
mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map(
(provider) => provider.id,
),
imageGenerationProviderIds: captured.imageGenerationProviders.map((provider) => provider.id),
webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id),
toolNames: captured.tools.map((tool) => tool.name),
};
if (!existing) {
pluginRegistrationContractRegistry.push(next);
continue;
}
existing.providerIds = next.providerIds.length > 0 ? next.providerIds : existing.providerIds;
existing.speechProviderIds = next.speechProviderIds;
existing.mediaUnderstandingProviderIds = next.mediaUnderstandingProviderIds;
existing.imageGenerationProviderIds = next.imageGenerationProviderIds;
existing.webSearchProviderIds = next.webSearchProviderIds;
existing.toolNames = next.toolNames;
}

View File

@@ -2877,6 +2877,44 @@ module.exports = {
}
});
it("loads bundled plugins when manifest metadata opts into default enablement", () => {
const bundledDir = makeTempDir();
const plugin = writePlugin({
id: "profile-aware",
body: `module.exports = { id: "profile-aware", register() {} };`,
dir: bundledDir,
filename: "index.cjs",
});
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "profile-aware",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: bundledDir,
config: {
plugins: {
enabled: true,
},
},
});
const bundledPlugin = registry.plugins.find((entry) => entry.id === "profile-aware");
expect(bundledPlugin?.origin).toBe("bundled");
expect(bundledPlugin?.status).toBe("loaded");
});
it("keeps scoped and unscoped plugin ids distinct", () => {
useNoBundledPlugins();
const scoped = writePlugin({

View File

@@ -1035,6 +1035,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
origin: candidate.origin,
config: normalized,
rootConfig: cfg,
enabledByDefault: manifestRecord.enabledByDefault,
});
const entry = normalized.entries[pluginId];
const record = createPluginRecord({

View File

@@ -203,6 +203,7 @@ describe("loadPluginManifestRegistry", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "openai",
enabledByDefault: true,
providers: ["openai", "openai-codex"],
providerAuthEnvVars: {
openai: ["OPENAI_API_KEY"],
@@ -227,6 +228,7 @@ describe("loadPluginManifestRegistry", () => {
expect(registry.plugins[0]?.providerAuthEnvVars).toEqual({
openai: ["OPENAI_API_KEY"],
});
expect(registry.plugins[0]?.enabledByDefault).toBe(true);
expect(registry.plugins[0]?.providerAuthChoices).toEqual([
{
provider: "openai",

View File

@@ -35,6 +35,7 @@ export type PluginManifestRecord = {
name?: string;
description?: string;
version?: string;
enabledByDefault?: boolean;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
@@ -154,6 +155,7 @@ function buildRecord(params: {
description:
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
enabledByDefault: params.manifest.enabledByDefault === true ? true : undefined,
format: params.candidate.format ?? "openclaw",
bundleFormat: params.candidate.bundleFormat,
kind: params.manifest.kind,

View File

@@ -11,6 +11,7 @@ export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const;
export type PluginManifest = {
id: string;
configSchema: Record<string, unknown>;
enabledByDefault?: boolean;
kind?: PluginKind;
channels?: string[];
providers?: string[];
@@ -180,6 +181,7 @@ export function loadPluginManifest(
}
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
const enabledByDefault = raw.enabledByDefault === true;
const name = typeof raw.name === "string" ? raw.name.trim() : undefined;
const description = typeof raw.description === "string" ? raw.description.trim() : undefined;
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
@@ -199,6 +201,7 @@ export function loadPluginManifest(
manifest: {
id,
configSchema,
...(enabledByDefault ? { enabledByDefault } : {}),
kind,
channels,
providers,

View File

@@ -27,6 +27,11 @@ function createCatalogContext(params: {
resolveProviderApiKey: (providerId) => ({
apiKey: providerId ? params.apiKeys?.[providerId] : undefined,
}),
resolveProviderAuth: (providerId) => ({
apiKey: providerId ? params.apiKeys?.[providerId] : undefined,
mode: providerId && params.apiKeys?.[providerId] ? "api_key" : "none",
source: providerId && params.apiKeys?.[providerId] ? "env" : "none",
}),
};
}

View File

@@ -120,6 +120,12 @@ describe("runProviderCatalog", () => {
config: {},
env: {},
resolveProviderApiKey: () => ({ apiKey: undefined }),
resolveProviderAuth: () => ({
apiKey: undefined,
discoveryApiKey: undefined,
mode: "none",
source: "none",
}),
});
expect(result).toEqual({

View File

@@ -81,6 +81,16 @@ export function runProviderCatalog(params: {
apiKey: string | undefined;
discoveryApiKey?: string;
};
resolveProviderAuth: (
providerId?: string,
options?: { oauthMarker?: string },
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
}) {
return resolveProviderCatalogHook(params.provider)?.run({
config: params.config,
@@ -88,5 +98,6 @@ export function runProviderCatalog(params: {
workspaceDir: params.workspaceDir,
env: params.env,
resolveProviderApiKey: params.resolveProviderApiKey,
resolveProviderAuth: params.resolveProviderAuth,
});
}

View File

@@ -70,6 +70,11 @@ describe("resolvePluginProviders", () => {
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: expect.arrayContaining(["openrouter", "google", "kilocode", "moonshot"]),
entries: expect.objectContaining({
google: { enabled: true },
kilocode: { enabled: true },
moonshot: { enabled: true },
}),
}),
}),
cache: false,
@@ -89,6 +94,10 @@ describe("resolvePluginProviders", () => {
plugins: expect.objectContaining({
enabled: true,
allow: expect.arrayContaining(["google", "moonshot"]),
entries: expect.objectContaining({
google: { enabled: true },
moonshot: { enabled: true },
}),
}),
}),
cache: false,

View File

@@ -1,6 +1,9 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { withBundledPluginAllowlistCompat } from "./bundled-compat.js";
import {
withBundledPluginAllowlistCompat,
withBundledPluginEnablementCompat,
} from "./bundled-compat.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
@@ -165,13 +168,20 @@ export function resolvePluginProviders(params: {
pluginIds: bundledProviderCompatPluginIds,
})
: params.config;
const config = params.bundledProviderVitestCompat
const maybeVitestCompat = params.bundledProviderVitestCompat
? withBundledProviderVitestCompat({
config: maybeAllowlistCompat,
pluginIds: bundledProviderCompatPluginIds,
env: params.env,
})
: maybeAllowlistCompat;
const config =
params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat
? withBundledPluginEnablementCompat({
config: maybeVitestCompat,
pluginIds: bundledProviderCompatPluginIds,
})
: maybeVitestCompat;
const registry = loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,

View File

@@ -246,6 +246,18 @@ export type ProviderCatalogContext = {
apiKey: string | undefined;
discoveryApiKey?: string;
};
resolveProviderAuth: (
providerId?: string,
options?: {
oauthMarker?: string;
},
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
};
export type ProviderCatalogResult =