fix(plugins): remove xai boundary leaks

This commit is contained in:
Vincent Koc
2026-04-06 11:51:22 +01:00
parent 57f9f0a08d
commit 209786bb2d
7 changed files with 169 additions and 134 deletions

View File

@@ -93,4 +93,34 @@ describe("models-config provider auth provenance", () => {
profileId: "openai:default",
});
});
it("resolves plugin-owned synthetic auth through the provider hook", () => {
const auth = createProviderAuthResolver(
{} as NodeJS.ProcessEnv,
{
version: 1,
profiles: {},
},
{
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key",
},
},
},
},
},
},
);
expect(auth("xai")).toEqual({
apiKey: NON_ENV_SECRETREF_MARKER,
discoveryApiKey: "xai-plugin-key",
mode: "api_key",
source: "none",
});
});
});

View File

@@ -1,6 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveProviderWebSearchPluginConfig } from "../plugin-sdk/provider-web-search.js";
import { resolveProviderSyntheticAuthWithPlugin } from "../plugins/provider-runtime.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { listProfilesForProvider } from "./auth-profiles/profiles.js";
@@ -439,16 +438,15 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op
// Providers own any provider-specific fallback auth logic via
// resolveSyntheticAuth(...). Discovery/bootstrap callers may consume
// non-secret markers from source config, but must never persist plaintext.
const synthetic =
resolveProviderSyntheticAuthWithPlugin({
provider: params.provider,
const synthetic = resolveProviderSyntheticAuthWithPlugin({
provider: params.provider,
config: params.config,
context: {
config: params.config,
context: {
config: params.config,
provider: params.provider,
providerConfig: params.config?.models?.providers?.[params.provider],
},
}) ?? resolveXaiConfigFallbackAuth(params);
provider: params.provider,
providerConfig: params.config?.models?.providers?.[params.provider],
},
});
const apiKey = synthetic?.apiKey?.trim();
if (!apiKey) {
return undefined;
@@ -467,74 +465,3 @@ function resolveConfigBackedProviderAuth(params: { provider: string; config?: Op
source: "config",
};
}
function resolveXaiConfigFallbackAuth(params: { provider: string; config?: OpenClawConfig }):
| {
apiKey: string;
source: string;
mode: "api-key";
}
| undefined {
if (params.provider.trim().toLowerCase() !== "xai") {
return undefined;
}
const xaiPluginEntry = params.config?.plugins?.entries?.xai;
if (xaiPluginEntry?.enabled === false) {
return undefined;
}
const pluginApiKey = normalizeOptionalSecretInput(
resolveProviderWebSearchPluginConfig(
params.config as Record<string, unknown> | undefined,
"xai",
)?.apiKey,
);
if (pluginApiKey) {
return {
apiKey: pluginApiKey,
source: "plugins.entries.xai.config.webSearch.apiKey",
mode: "api-key",
};
}
const pluginApiKeyRef = coerceSecretRef(
resolveProviderWebSearchPluginConfig(
params.config as Record<string, unknown> | undefined,
"xai",
)?.apiKey,
);
if (pluginApiKeyRef) {
return {
apiKey:
pluginApiKeyRef.source === "env"
? pluginApiKeyRef.id.trim()
: resolveNonEnvSecretRefApiKeyMarker(pluginApiKeyRef.source),
source: "plugins.entries.xai.config.webSearch.apiKey",
mode: "api-key",
};
}
const legacyGrokApiKey = normalizeOptionalSecretInput(
(params.config?.tools?.web?.search as { grok?: { apiKey?: unknown } } | undefined | null)?.grok
?.apiKey,
);
if (legacyGrokApiKey) {
return {
apiKey: legacyGrokApiKey,
source: "tools.web.search.grok.apiKey",
mode: "api-key",
};
}
const legacyGrokApiKeyRef = coerceSecretRef(
(params.config?.tools?.web?.search as { grok?: { apiKey?: unknown } } | undefined | null)?.grok
?.apiKey,
);
if (legacyGrokApiKeyRef) {
return {
apiKey:
legacyGrokApiKeyRef.source === "env"
? legacyGrokApiKeyRef.id.trim()
: resolveNonEnvSecretRefApiKeyMarker(legacyGrokApiKeyRef.source),
source: "tools.web.search.grok.apiKey",
mode: "api-key",
};
}
return undefined;
}

View File

@@ -209,6 +209,44 @@ describe("applyPluginAutoEnable providers", () => {
expect(result.changes).toContain("acme web search configured, enabled automatically.");
});
it("auto-enables third-party plugins when manifest-owned tool config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
acme: {
config: {
acmeTool: {
enabled: true,
},
},
},
},
},
},
env: makeIsolatedEnv(),
manifestRegistry: makeRegistry([
{
id: "acme",
channels: [],
contracts: {
tools: ["acme_tool"],
},
configSchema: {
type: "object",
properties: {
webSearch: { type: "object" },
acmeTool: { type: "object" },
},
},
},
]),
});
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
expect(result.changes).toContain("acme tool configured, enabled automatically.");
});
it("auto-enables acpx plugin when ACP is configured", () => {
const result = applyPluginAutoEnable({
config: {

View File

@@ -7,6 +7,7 @@ import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry
import {
loadPluginManifestRegistry,
resolveManifestContractOwnerPluginId,
type PluginManifestRecord,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { resolveOwningPluginIdsForModelRef } from "../plugins/providers.js";
@@ -178,40 +179,47 @@ function hasPluginOwnedWebFetchConfig(cfg: OpenClawConfig, pluginId: string): bo
return isRecord(pluginConfig) && isRecord(pluginConfig.webFetch);
}
function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean {
const pluginConfig = cfg.plugins?.entries?.xai?.config;
const web = cfg.tools?.web as Record<string, unknown> | undefined;
return (
pluginId === "xai" &&
Boolean(
isRecord(web?.x_search) ||
(isRecord(pluginConfig) &&
(isRecord(pluginConfig.xSearch) || isRecord(pluginConfig.codeExecution))),
)
);
function resolvePluginOwnedToolConfigKeys(plugin: PluginManifestRecord): string[] {
if ((plugin.contracts?.tools?.length ?? 0) === 0) {
return [];
}
const properties = isRecord(plugin.configSchema) ? plugin.configSchema.properties : undefined;
if (!isRecord(properties)) {
return [];
}
return Object.keys(properties).filter((key) => key !== "webSearch" && key !== "webFetch");
}
function hasPluginOwnedToolConfig(cfg: OpenClawConfig, plugin: PluginManifestRecord): boolean {
const pluginConfig = cfg.plugins?.entries?.[plugin.id]?.config;
if (!isRecord(pluginConfig)) {
return false;
}
return resolvePluginOwnedToolConfigKeys(plugin).some((key) => pluginConfig[key] !== undefined);
}
function resolveProviderPluginsWithOwnedWebSearch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
return new Set(
registry.plugins
.filter((plugin) => plugin.providers.length > 0)
.filter((plugin) => (plugin.contracts?.webSearchProviders?.length ?? 0) > 0)
.map((plugin) => plugin.id),
);
): PluginManifestRecord[] {
return registry.plugins
.filter((plugin) => plugin.providers.length > 0)
.filter((plugin) => (plugin.contracts?.webSearchProviders?.length ?? 0) > 0);
}
function resolveProviderPluginsWithOwnedWebFetch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
return new Set(
registry.plugins
.filter((plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0)
.map((plugin) => plugin.id),
): PluginManifestRecord[] {
return registry.plugins.filter(
(plugin) => (plugin.contracts?.webFetchProviders?.length ?? 0) > 0,
);
}
function resolvePluginsWithOwnedToolConfig(
registry: PluginManifestRegistry,
): PluginManifestRecord[] {
return registry.plugins.filter((plugin) => (plugin.contracts?.tools?.length ?? 0) > 0);
}
function resolvePluginIdForConfiguredWebFetchProvider(
providerId: string | undefined,
env: NodeJS.ProcessEnv,
@@ -275,6 +283,15 @@ function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
);
}
function hasConfiguredPluginConfigEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
return (
!!entries &&
typeof entries === "object" &&
Object.values(entries).some((entry) => isRecord(entry) && isRecord(entry.config))
);
}
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
const pluginEntries = cfg.plugins?.entries;
if (
@@ -311,6 +328,9 @@ export function configMayNeedPluginAutoEnable(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): boolean {
if (hasConfiguredPluginConfigEntry(cfg)) {
return true;
}
if (hasPotentialConfiguredChannels(cfg, env)) {
return true;
}
@@ -323,12 +343,7 @@ export function configMayNeedPluginAutoEnable(
if (collectModelRefs(cfg).length > 0) {
return true;
}
const web = cfg.tools?.web as Record<string, unknown> | undefined;
if (
isRecord(web?.x_search) ||
hasConfiguredWebSearchPluginEntry(cfg) ||
hasConfiguredWebFetchPluginEntry(cfg)
) {
if (hasConfiguredWebSearchPluginEntry(cfg) || hasConfiguredWebFetchPluginEntry(cfg)) {
return true;
}
return (
@@ -416,16 +431,22 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: {
});
}
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(params.registry)) {
for (const plugin of resolveProviderPluginsWithOwnedWebSearch(params.registry)) {
const pluginId = plugin.id;
if (hasPluginOwnedWebSearchConfig(params.config, pluginId)) {
changes.push({ pluginId, kind: "plugin-web-search-configured" });
}
if (hasPluginOwnedToolConfig(params.config, pluginId)) {
}
for (const plugin of resolvePluginsWithOwnedToolConfig(params.registry)) {
const pluginId = plugin.id;
if (hasPluginOwnedToolConfig(params.config, plugin)) {
changes.push({ pluginId, kind: "plugin-tool-configured" });
}
}
for (const pluginId of resolveProviderPluginsWithOwnedWebFetch(params.registry)) {
for (const plugin of resolveProviderPluginsWithOwnedWebFetch(params.registry)) {
const pluginId = plugin.id;
if (hasPluginOwnedWebFetchConfig(params.config, pluginId)) {
changes.push({ pluginId, kind: "plugin-web-fetch-configured" });
}

View File

@@ -61,8 +61,9 @@ export function makeRegistry(
channels: string[];
autoEnableWhenConfiguredProviders?: string[];
modelSupport?: { modelPrefixes?: string[]; modelPatterns?: string[] };
contracts?: { webSearchProviders?: string[]; webFetchProviders?: string[] };
contracts?: { webSearchProviders?: string[]; webFetchProviders?: string[]; tools?: string[] };
providers?: string[];
configSchema?: Record<string, unknown>;
channelConfigs?: Record<string, { schema: Record<string, unknown>; preferOver?: string[] }>;
}>,
): PluginManifestRegistry {
@@ -73,6 +74,7 @@ export function makeRegistry(
autoEnableWhenConfiguredProviders: plugin.autoEnableWhenConfiguredProviders,
modelSupport: plugin.modelSupport,
contracts: plugin.contracts,
configSchema: plugin.configSchema,
channelConfigs: plugin.channelConfigs,
providers: plugin.providers ?? [],
skills: [],

View File

@@ -88,6 +88,24 @@ describe("model-pricing-cache", () => {
expect(new Set(refs).size).toBe(refs.length);
});
it("collects manifest-owned web search plugin model refs without a hardcoded plugin list", () => {
const refs = collectConfiguredModelPricingRefs({
plugins: {
entries: {
tavily: {
config: {
webSearch: {
model: "tavily/search-preview",
},
},
},
},
},
} as OpenClawConfig).map((ref) => modelKey(ref.provider, ref.model));
expect(refs).toContain("tavily/search-preview");
});
it("loads openrouter pricing and maps provider aliases, wrappers, and anthropic dotted ids", async () => {
const config = {
agents: {

View File

@@ -10,6 +10,7 @@ import {
import type { OpenClawConfig } from "../config/config.js";
import { resolvePluginWebSearchConfig } from "../config/plugin-web-search-config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveManifestContractPluginIds } from "../plugins/manifest-registry.js";
import { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js";
import {
clearGatewayModelPricingCacheState,
@@ -250,6 +251,23 @@ function addProviderModelPair(params: {
params.refs.set(modelKey(normalized.provider, normalized.model), normalized);
}
function addConfiguredWebSearchPluginModels(params: {
config: OpenClawConfig;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
refs: Map<string, ModelRef>;
}): void {
for (const pluginId of resolveManifestContractPluginIds({
contract: "webSearchProviders",
config: params.config,
})) {
addResolvedModelRef({
raw: resolvePluginWebSearchConfig(params.config, pluginId)?.model as string | undefined,
aliasIndex: params.aliasIndex,
refs: params.refs,
});
}
}
export function collectConfiguredModelPricingRefs(config: OpenClawConfig): ModelRef[] {
const refs = new Map<string, ModelRef>();
const aliasIndex = buildModelAliasIndex({
@@ -289,26 +307,7 @@ export function collectConfiguredModelPricingRefs(config: OpenClawConfig): Model
}
}
addResolvedModelRef({
raw: resolvePluginWebSearchConfig(config, "google")?.model as string | undefined,
aliasIndex,
refs,
});
addResolvedModelRef({
raw: resolvePluginWebSearchConfig(config, "xai")?.model as string | undefined,
aliasIndex,
refs,
});
addResolvedModelRef({
raw: resolvePluginWebSearchConfig(config, "moonshot")?.model as string | undefined,
aliasIndex,
refs,
});
addResolvedModelRef({
raw: resolvePluginWebSearchConfig(config, "perplexity")?.model as string | undefined,
aliasIndex,
refs,
});
addConfiguredWebSearchPluginModels({ config, aliasIndex, refs });
for (const entry of config.tools?.media?.models ?? []) {
addProviderModelPair({ provider: entry.provider, model: entry.model, refs });