fix: gate plugin tools from manifest availability

This commit is contained in:
Shakker
2026-05-02 05:00:32 +01:00
parent 854323a124
commit 3cf1dd982b
14 changed files with 990 additions and 243 deletions

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei.
- macOS/config: preserve existing `gateway.auth` and unrelated config keys during app fallback writes, so dashboard or Talk settings changes cannot strand Control UI clients by dropping persisted auth. Fixes #75631. Thanks @Fuma2013.
- Control UI/TUI: keep reconnecting chat sends bound to the same backing session id and let TUI relaunches resume the last selected session, avoiding silent fresh sessions after refresh, reconnect, or terminal restart. Fixes #63195, #68162, and #73546. Thanks @bond260312-cmyk, @zhong18804784882, and @mtuwei.
- Plugins/tools: let plugin manifests declare static tool availability so reply startup skips unavailable plugin tool runtimes instead of importing factories that only return `null`. Thanks @shakkernerd.
- Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120.
- CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw.
- Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury.

View File

@@ -178,6 +178,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
| `imageGenerationProviderMetadata` | No | `Record<string, object>` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `videoGenerationProviderMetadata` | No | `Record<string, object>` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `musicGenerationProviderMetadata` | No | `Record<string, object>` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. |
| `toolMetadata` | No | `Record<string, object>` | Cheap availability metadata for plugin-owned tools declared in `contracts.tools`. Use it when a tool should not load runtime unless config, env, or auth evidence exists. |
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
@@ -280,6 +281,45 @@ Each `providerBaseUrl` guard supports:
| `defaultBaseUrl` | No | `string` | Base URL to assume when the provider config omits `baseUrl`. |
| `allowedBaseUrls` | Yes | `string[]` | Allowed base URLs for this auth signal. The signal is ignored when the configured or default base URL does not match one of these normalized values. |
## Tool metadata reference
`toolMetadata` uses the same `configSignals` and `authSignals` shapes as
generation provider metadata, keyed by tool name. `contracts.tools` declares
ownership. `toolMetadata` declares cheap availability evidence so OpenClaw can
avoid importing a plugin runtime just to have its tool factory return `null`.
```json
{
"providerAuthEnvVars": {
"example": ["EXAMPLE_API_KEY"]
},
"contracts": {
"tools": ["example_search"]
},
"toolMetadata": {
"example_search": {
"authSignals": [
{
"provider": "example"
}
],
"configSignals": [
{
"rootPath": "plugins.entries.example.config",
"overlayPath": "search",
"required": ["apiKey"]
}
]
}
}
}
```
If a tool has no `toolMetadata`, OpenClaw preserves the existing behavior and
loads the owning plugin when the tool contract matches policy. For hot-path
tools whose factory depends on auth/config, plugin authors should declare
`toolMetadata` instead of making core import runtime to ask.
## providerAuthChoices reference
Each `providerAuthChoices` entry describes one onboarding or auth choice.

View File

@@ -135,6 +135,44 @@
}
}
},
"toolMetadata": {
"code_execution": {
"authSignals": [
{
"provider": "xai"
}
],
"configSignals": [
{
"rootPath": "plugins.entries.xai.config",
"overlayPath": "webSearch",
"required": ["apiKey"]
},
{
"rootPath": "tools.web.search.grok",
"required": ["apiKey"]
}
]
},
"x_search": {
"authSignals": [
{
"provider": "xai"
}
],
"configSignals": [
{
"rootPath": "plugins.entries.xai.config",
"overlayPath": "webSearch",
"required": ["apiKey"]
},
{
"rootPath": "tools.web.search.grok",
"required": ["apiKey"]
}
]
}
},
"configContracts": {
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
},

View File

@@ -3,6 +3,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import { listProfilesForProvider } from "./auth-profiles.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import {
resolveOpenClawPluginToolInputs,
type OpenClawPluginToolOptions,
@@ -23,6 +25,7 @@ type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
requireExplicitMessageTarget?: boolean;
disableMessageTool?: boolean;
disablePluginTools?: boolean;
authProfileStore?: AuthProfileStore;
};
export function resolveOpenClawPluginToolsForOptions(params: {
@@ -49,6 +52,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
runtimeSourceConfig: currentRuntimeSnapshot?.sourceConfig,
});
};
const authProfileStore = params.options?.authProfileStore;
const pluginTools = resolvePluginTools({
...resolveOpenClawPluginToolInputs({
options: params.options,
@@ -59,6 +63,12 @@ export function resolveOpenClawPluginToolsForOptions(params: {
existingToolNames: params.existingToolNames ?? new Set<string>(),
toolAllowlist: params.options?.pluginToolAllowlist,
allowGatewaySubagentBinding: params.options?.allowGatewaySubagentBinding,
...(authProfileStore
? {
hasAuthForProvider: (providerId) =>
listProfilesForProvider(authProfileStore, providerId).length > 0,
}
: {}),
});
return applyPluginToolDeliveryDefaults({

View File

@@ -565,6 +565,93 @@ describe("optional media tool factory planning", () => {
).not.toEqual(expect.arrayContaining(["image_generate", "video_generate", "music_generate"]));
});
it("counts configured non-env SecretRef config signals without resolving secrets", () => {
const config: OpenClawConfig = {
plugins: {
entries: {
comfy: {
config: {
mode: "cloud",
apiKey: { source: "file", provider: "vault", id: "/comfy/api-key" },
workflow: { "1": { inputs: {} } },
promptNodeId: "1",
},
},
},
},
secrets: {
providers: {
vault: {
source: "file",
path: "/tmp/openclaw-secrets.json",
mode: "json",
},
},
},
};
const configSignals = [
{
rootPath: "plugins.entries.comfy.config",
mode: {
path: "mode",
allowed: ["cloud"],
},
requiredAny: ["workflow", "workflowPath"],
required: ["promptNodeId", "apiKey"],
},
];
installSnapshot(config, [
createPlugin({
id: "comfy",
contracts: {
imageGenerationProviders: ["comfy"],
videoGenerationProviders: ["comfy"],
musicGenerationProviders: ["comfy"],
},
imageGenerationProviderMetadata: {
comfy: { configSignals },
},
videoGenerationProviderMetadata: {
comfy: { configSignals },
},
musicGenerationProviderMetadata: {
comfy: { configSignals },
},
}),
]);
expect(
__testing.resolveOptionalMediaToolFactoryPlan({
config,
authStore: createAuthStore(),
}),
).toMatchObject({
imageGenerate: true,
videoGenerate: true,
musicGenerate: true,
});
});
it("does not register the image tool without cheap vision availability evidence", () => {
const config: OpenClawConfig = {};
installSnapshot(config, [
createPlugin({
id: "media-owner",
contracts: { mediaUnderstandingProviders: ["media-owner"] },
setupProviders: [{ id: "media-owner", envVars: ["MEDIA_OWNER_API_KEY"] }],
}),
]);
expect(
createOpenClawTools({
config,
agentDir: "/tmp/openclaw-agent",
authProfileStore: createAuthStore(),
disablePluginTools: true,
}).map((tool) => tool.name),
).not.toContain("image");
});
it.each([
{
name: "legacy local provider config",
@@ -687,22 +774,20 @@ describe("optional media tool factory planning", () => {
});
});
it("falls back to existing factory checks when snapshot or auth store proof is missing", () => {
expect(__testing.resolveOptionalMediaToolFactoryPlan({ config: {} })).toEqual({
imageGenerate: true,
videoGenerate: true,
musicGenerate: true,
pdf: true,
});
it("does not use a generic factory plan when metadata has no availability proof", () => {
const config: OpenClawConfig = {};
installSnapshot(config, []);
expect(__testing.resolveOptionalMediaToolFactoryPlan({ config })).toEqual({
imageGenerate: true,
videoGenerate: true,
musicGenerate: true,
pdf: true,
expect(
__testing.resolveOptionalMediaToolFactoryPlan({
config,
authStore: createAuthStore(),
}),
).toEqual({
imageGenerate: false,
videoGenerate: false,
musicGenerate: false,
pdf: false,
});
});
});

View File

@@ -1,8 +1,13 @@
import { selectApplicableRuntimeConfig } from "../config/config.js";
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";
import { isEmbeddedMode } from "../infra/embedded-mode.js";
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import {
getActiveRuntimeWebToolsMetadata,
getActiveSecretsRuntimeSnapshot,
} from "../secrets/runtime.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js";
@@ -29,9 +34,9 @@ import { createImageGenerateTool } from "./tools/image-generate-tool.js";
import { coerceImageModelConfig } from "./tools/image-tool.helpers.js";
import { createImageTool } from "./tools/image-tool.js";
import {
getCurrentCapabilityMetadataSnapshot,
hasSnapshotCapabilityAvailability,
hasSnapshotProviderEnvAvailability,
loadCapabilityMetadataSnapshot,
} from "./tools/manifest-capability-availability.js";
import { createMessageTool } from "./tools/message-tool.js";
import { coerceToolModelConfig, hasToolModelConfig } from "./tools/model-config.helpers.js";
@@ -74,6 +79,10 @@ function hasExplicitToolModelConfig(modelConfig: AgentModelConfig | undefined):
return hasToolModelConfig(coerceToolModelConfig(modelConfig));
}
function hasExplicitImageModelConfig(config: OpenClawConfig | undefined): boolean {
return hasToolModelConfig(coerceImageModelConfig(config));
}
function isToolAllowedByFactoryAllowlist(toolName: string, allowlist?: string[]): boolean {
if (!allowlist || allowlist.length === 0) {
return true;
@@ -82,10 +91,40 @@ function isToolAllowedByFactoryAllowlist(toolName: string, allowlist?: string[])
return expanded.has("*") || expanded.has(normalizeToolName(toolName));
}
function resolveImageToolFactoryAvailable(params: {
config?: OpenClawConfig;
agentDir?: string;
modelHasVision?: boolean;
authStore?: AuthProfileStore;
}): boolean {
if (!params.agentDir?.trim()) {
return false;
}
if (params.modelHasVision || hasExplicitImageModelConfig(params.config)) {
return true;
}
const snapshot = loadCapabilityMetadataSnapshot({
config: params.config,
});
return (
hasSnapshotCapabilityAvailability({
snapshot,
authStore: params.authStore,
key: "mediaUnderstandingProviders",
config: params.config,
}) ||
hasConfiguredVisionModelAuthSignal({
config: params.config,
snapshot,
authStore: params.authStore,
})
);
}
function hasConfiguredVisionModelAuthSignal(params: {
config?: OpenClawConfig;
snapshot: NonNullable<ReturnType<typeof getCurrentCapabilityMetadataSnapshot>>;
authStore: AuthProfileStore;
snapshot: Pick<PluginMetadataSnapshot, "index" | "plugins">;
authStore?: AuthProfileStore;
}): boolean {
const providers = params.config?.models?.providers;
if (!providers || typeof providers !== "object") {
@@ -99,7 +138,7 @@ function hasConfiguredVisionModelAuthSignal(params: {
) {
continue;
}
if (listProfilesForProvider(params.authStore, providerId).length > 0) {
if (params.authStore && listProfilesForProvider(params.authStore, providerId).length > 0) {
return true;
}
if (
@@ -141,12 +180,6 @@ function resolveOptionalMediaToolFactoryPlan(params: {
const explicitPdf =
hasToolModelConfig(coercePdfModelConfig(params.config)) ||
hasToolModelConfig(coerceImageModelConfig(params.config));
const fallbackPlan: OptionalMediaToolFactoryPlan = {
imageGenerate: allowImageGenerate,
videoGenerate: allowVideoGenerate,
musicGenerate: allowMusicGenerate,
pdf: allowPdf,
};
if (params.config?.plugins?.enabled === false) {
return {
imageGenerate: false,
@@ -155,16 +188,10 @@ function resolveOptionalMediaToolFactoryPlan(params: {
pdf: false,
};
}
if (!params.authStore) {
return fallbackPlan;
}
const snapshot = getCurrentCapabilityMetadataSnapshot({
const snapshot = loadCapabilityMetadataSnapshot({
config: params.config,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
if (!snapshot) {
return fallbackPlan;
}
return {
imageGenerate:
allowImageGenerate &&
@@ -283,6 +310,12 @@ export function createOpenClawTools(
} & SpawnedToolContext,
): AnyAgentTool[] {
const resolvedConfig = options?.config ?? openClawToolsDeps.config;
const runtimeSnapshot = getActiveSecretsRuntimeSnapshot();
const availabilityConfig = selectApplicableRuntimeConfig({
inputConfig: resolvedConfig,
runtimeConfig: runtimeSnapshot?.config,
runtimeSourceConfig: runtimeSnapshot?.sourceConfig,
});
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: options?.agentSessionKey,
config: resolvedConfig,
@@ -311,15 +344,21 @@ export function createOpenClawTools(
? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
: undefined;
const optionalMediaTools = resolveOptionalMediaToolFactoryPlan({
config: resolvedConfig,
config: availabilityConfig ?? resolvedConfig,
workspaceDir,
authStore: options?.authProfileStore,
toolAllowlist: options?.pluginToolAllowlist,
});
const imageTool = options?.agentDir?.trim()
const imageToolAgentDir = options?.agentDir;
const imageTool = resolveImageToolFactoryAvailable({
config: availabilityConfig ?? resolvedConfig,
agentDir: imageToolAgentDir,
modelHasVision: options?.modelHasVision,
authStore: options?.authProfileStore,
})
? createImageTool({
config: options?.config,
agentDir: options.agentDir,
config: availabilityConfig ?? options?.config,
agentDir: imageToolAgentDir!,
authProfileStore: options?.authProfileStore,
workspaceDir,
sandbox,

View File

@@ -1,10 +1,17 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { coerceSecretRef, type SecretRef } from "../../config/types.secrets.js";
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import { isManifestPluginAvailableForControlPlane } from "../../plugins/manifest-contract-eligibility.js";
import {
isManifestPluginAvailableForControlPlane,
loadManifestContractSnapshot,
} from "../../plugins/manifest-contract-eligibility.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import {
hasNonEmptyManifestEnvCandidate,
manifestConfigSignalPasses,
manifestPluginSetupProviderEnvVars,
manifestProviderBaseUrlGuardPasses,
} from "../../plugins/manifest-tool-availability.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { resolveDefaultSecretProviderAlias } from "../../secrets/ref-contract.js";
import { listProfilesForProvider } from "../auth-profiles.js";
import type { AuthProfileStore } from "../auth-profiles/types.js";
@@ -19,139 +26,6 @@ type CapabilityProviderMetadataKey =
| "videoGenerationProviderMetadata"
| "musicGenerationProviderMetadata";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readPath(root: unknown, path: string | undefined): unknown {
if (!path?.trim()) {
return root;
}
let current = root;
for (const segment of path.split(".")) {
const key = segment.trim();
if (!key) {
return undefined;
}
if (!isRecord(current) || !(key in current)) {
return undefined;
}
current = current[key];
}
return current;
}
function readStringAtPath(root: unknown, path: string): string | undefined {
const value = readPath(root, path);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readEffectiveConfig(params: {
config?: OpenClawConfig;
rootPath: string;
overlayPath?: string;
}): Record<string, unknown> | undefined {
const root = readPath(params.config, params.rootPath);
if (!isRecord(root)) {
return undefined;
}
const overlay = readPath(root, params.overlayPath);
return isRecord(overlay) ? { ...root, ...overlay } : root;
}
function canResolveEnvSecretRefInConfigPath(params: {
config?: OpenClawConfig;
ref: SecretRef;
}): boolean {
if (params.ref.source !== "env") {
return false;
}
const providerConfig = params.config?.secrets?.providers?.[params.ref.provider];
if (!providerConfig) {
return params.ref.provider === resolveDefaultSecretProviderAlias(params.config ?? {}, "env");
}
if (providerConfig.source !== "env") {
return false;
}
const allowlist = providerConfig.allowlist;
return !allowlist || allowlist.includes(params.ref.id);
}
function hasConfiguredValue(params: { config?: OpenClawConfig; value: unknown }): boolean {
const secretRef = coerceSecretRef(params.value, params.config?.secrets?.defaults);
if (secretRef) {
return (
canResolveEnvSecretRefInConfigPath({
config: params.config,
ref: secretRef,
}) && Boolean(process.env[secretRef.id]?.trim())
);
}
if (typeof params.value === "string") {
return params.value.trim().length > 0;
}
if (Array.isArray(params.value)) {
return params.value.length > 0;
}
if (isRecord(params.value)) {
return Object.keys(params.value).length > 0;
}
return params.value !== undefined && params.value !== null;
}
function configSignalPasses(params: {
config?: OpenClawConfig;
signal: NonNullable<
NonNullable<PluginManifestRecord["imageGenerationProviderMetadata"]>[string]["configSignals"]
>[number];
}): boolean {
const effectiveConfig = readEffectiveConfig({
config: params.config,
rootPath: params.signal.rootPath,
overlayPath: params.signal.overlayPath,
});
if (!effectiveConfig) {
return false;
}
const modeSignal = params.signal.mode;
if (modeSignal) {
const modePath = modeSignal.path?.trim() || "mode";
const mode = readStringAtPath(effectiveConfig, modePath) ?? modeSignal.default;
if (!mode) {
return false;
}
if (modeSignal.allowed?.length && !modeSignal.allowed.includes(mode)) {
return false;
}
if (modeSignal.disallowed?.includes(mode)) {
return false;
}
}
for (const requiredPath of params.signal.required ?? []) {
if (
!hasConfiguredValue({
config: params.config,
value: readPath(effectiveConfig, requiredPath),
})
) {
return false;
}
}
const requiredAny = params.signal.requiredAny ?? [];
if (
requiredAny.length > 0 &&
!requiredAny.some((path) =>
hasConfiguredValue({
config: params.config,
value: readPath(effectiveConfig, path),
}),
)
) {
return false;
}
return true;
}
function metadataKeyForCapabilityContract(
key: CapabilityContractKey,
): CapabilityProviderMetadataKey | undefined {
@@ -168,52 +42,6 @@ function metadataKeyForCapabilityContract(
return undefined;
}
function normalizeBaseUrlForManifestGuard(value: string): string {
return value.trim().replace(/\/+$/, "");
}
function providerBaseUrlGuardPasses(params: {
config?: OpenClawConfig;
guard: NonNullable<
NonNullable<PluginManifestRecord["imageGenerationProviderMetadata"]>[string]["authSignals"]
>[number]["providerBaseUrl"];
}): boolean {
const guard = params.guard;
if (!guard) {
return true;
}
const providerConfig = params.config?.models?.providers?.[guard.provider];
const rawBaseUrl =
typeof providerConfig?.baseUrl === "string" && providerConfig.baseUrl.trim()
? providerConfig.baseUrl
: guard.defaultBaseUrl;
if (!rawBaseUrl) {
return false;
}
const normalizedBaseUrl = normalizeBaseUrlForManifestGuard(rawBaseUrl);
return guard.allowedBaseUrls.some(
(allowedBaseUrl) => normalizeBaseUrlForManifestGuard(allowedBaseUrl) === normalizedBaseUrl,
);
}
function pluginSetupProviderEnvVars(
plugin: PluginManifestRecord,
providerId: string,
): readonly string[] {
const direct = plugin.setup?.providers?.find((provider) => provider.id === providerId)?.envVars;
if (direct && direct.length > 0) {
return direct;
}
return plugin.providerAuthEnvVars?.[providerId] ?? [];
}
function hasNonEmptyEnvCandidate(envVars: readonly string[]): boolean {
return envVars.some((envVar) => {
const key = envVar.trim();
return key.length > 0 && Boolean(process.env[key]?.trim());
});
}
function listCapabilityAuthSignals(params: {
plugin: PluginManifestRecord;
key: CapabilityContractKey;
@@ -244,6 +72,24 @@ export function getCurrentCapabilityMetadataSnapshot(params: {
});
}
export function loadCapabilityMetadataSnapshot(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): Pick<PluginMetadataSnapshot, "index" | "plugins"> {
return (
getCurrentPluginMetadataSnapshot({
config: params.config,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
}) ??
loadManifestContractSnapshot({
config: params.config,
env: params.env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
})
);
}
export function hasSnapshotCapabilityAvailability(params: {
snapshot: Pick<PluginMetadataSnapshot, "index" | "plugins">;
key: CapabilityContractKey;
@@ -268,8 +114,9 @@ export function hasSnapshotCapabilityAvailability(params: {
const metadata = metadataKey ? plugin[metadataKey]?.[providerId] : undefined;
if (
metadata?.configSignals?.some((signal) =>
configSignalPasses({
manifestConfigSignalPasses({
config: params.config,
env: process.env,
signal,
}),
)
@@ -282,7 +129,7 @@ export function hasSnapshotCapabilityAvailability(params: {
providerId,
})) {
if (
!providerBaseUrlGuardPasses({
!manifestProviderBaseUrlGuardPasses({
config: params.config,
guard: signal.providerBaseUrl,
})
@@ -295,7 +142,12 @@ export function hasSnapshotCapabilityAvailability(params: {
) {
return true;
}
if (hasNonEmptyEnvCandidate(pluginSetupProviderEnvVars(plugin, signal.provider))) {
if (
hasNonEmptyManifestEnvCandidate(
process.env,
manifestPluginSetupProviderEnvVars(plugin, signal.provider),
)
) {
return true;
}
}
@@ -322,7 +174,12 @@ export function hasSnapshotProviderEnvAvailability(params: {
) {
continue;
}
if (hasNonEmptyEnvCandidate(pluginSetupProviderEnvVars(plugin, params.providerId))) {
if (
hasNonEmptyManifestEnvCandidate(
process.env,
manifestPluginSetupProviderEnvVars(plugin, params.providerId),
)
) {
return true;
}
}

View File

@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
loadPluginManifestRegistry: vi.fn<(params?: Record<string, unknown>) => MockManifestRegistry>(
() => createEmptyMockManifestRegistry(),
),
resolveInstalledManifestRegistryIndexFingerprint: vi.fn(() => "test-installed-index"),
loadBundledCapabilityRuntimeRegistry: vi.fn(),
loadPluginRegistrySnapshot: vi.fn<() => { plugins: Array<Record<string, unknown>> }>(() => ({
plugins: [],
@@ -60,6 +61,8 @@ vi.mock("./bundled-capability-runtime.js", () => ({
vi.mock("./manifest-registry-installed.js", () => ({
loadPluginManifestRegistryForInstalledIndex: mocks.loadPluginManifestRegistry,
resolveInstalledManifestRegistryIndexFingerprint:
mocks.resolveInstalledManifestRegistryIndexFingerprint,
}));
vi.mock("./plugin-registry.js", async (importOriginal) => {

View File

@@ -1195,6 +1195,7 @@ describe("loadPluginManifestRegistry", () => {
contracts: {
mediaUnderstandingProviders: ["openai"],
imageGenerationProviders: ["openai"],
tools: ["image_generate"],
},
imageGenerationProviderMetadata: {
openai: {
@@ -1241,6 +1242,22 @@ describe("loadPluginManifestRegistry", () => {
nativeDocumentInputs: ["pdf", "docx"],
},
},
toolMetadata: {
image_generate: {
authSignals: [
{
provider: "openai-codex",
},
],
configSignals: [
{
rootPath: "plugins.entries.openai.config",
overlayPath: "image",
required: ["apiKey"],
},
],
},
},
configSchema: { type: "object" },
});
@@ -1293,6 +1310,22 @@ describe("loadPluginManifestRegistry", () => {
nativeDocumentInputs: ["pdf"],
},
});
expect(registry.plugins[0]?.toolMetadata).toEqual({
image_generate: {
authSignals: [
{
provider: "openai-codex",
},
],
configSignals: [
{
rootPath: "plugins.entries.openai.config",
overlayPath: "image",
required: ["apiKey"],
},
],
},
});
});
it("preserves external auth provider contracts from plugin manifests", () => {

View File

@@ -38,6 +38,7 @@ import {
type PluginManifestProviderRequest,
type PluginManifestQaRunner,
type PluginManifestSetup,
type PluginManifestToolMetadata,
type PluginPackageChannel,
type PluginPackageInstall,
} from "./manifest.js";
@@ -153,6 +154,7 @@ export type PluginManifestRecord = {
imageGenerationProviderMetadata?: Record<string, PluginManifestCapabilityProviderMetadata>;
videoGenerationProviderMetadata?: Record<string, PluginManifestCapabilityProviderMetadata>;
musicGenerationProviderMetadata?: Record<string, PluginManifestCapabilityProviderMetadata>;
toolMetadata?: Record<string, PluginManifestToolMetadata>;
configContracts?: PluginManifestConfigContracts;
channelConfigs?: Record<string, PluginManifestChannelConfig>;
channelCatalogMeta?: {
@@ -337,6 +339,7 @@ function buildRecord(params: {
imageGenerationProviderMetadata: params.manifest.imageGenerationProviderMetadata,
videoGenerationProviderMetadata: params.manifest.videoGenerationProviderMetadata,
musicGenerationProviderMetadata: params.manifest.musicGenerationProviderMetadata,
toolMetadata: params.manifest.toolMetadata,
configContracts: params.manifest.configContracts,
channelConfigs,
...(params.candidate.packageManifest?.channel?.id

View File

@@ -0,0 +1,278 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type {
PluginManifestCapabilityProviderAuthSignal,
PluginManifestCapabilityProviderConfigSignal,
} from "./manifest.js";
type ToolMetadata = NonNullable<PluginManifestRecord["toolMetadata"]>[string];
export type ManifestConfigAvailabilitySignal = PluginManifestCapabilityProviderConfigSignal;
export type ManifestAuthAvailabilitySignal = PluginManifestCapabilityProviderAuthSignal;
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function readPath(root: unknown, path: string | undefined): unknown {
if (!path?.trim()) {
return root;
}
let current = root;
for (const segment of path.split(".")) {
const key = segment.trim();
if (!key) {
return undefined;
}
if (!isRecord(current) || !(key in current)) {
return undefined;
}
current = current[key];
}
return current;
}
function readStringAtPath(root: unknown, path: string): string | undefined {
const value = readPath(root, path);
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readEffectiveConfig(params: {
config?: OpenClawConfig;
rootPath: string;
overlayPath?: string;
}): Record<string, unknown> | undefined {
const root = readPath(params.config, params.rootPath);
if (!isRecord(root)) {
return undefined;
}
const overlay = readPath(root, params.overlayPath);
return isRecord(overlay) ? { ...root, ...overlay } : root;
}
function hasConfiguredSecretRefInConfigPath(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
ref: SecretRef;
}): boolean {
const providerConfig = params.config?.secrets?.providers?.[params.ref.provider];
if (params.ref.source !== "env") {
return Boolean(providerConfig && providerConfig.source === params.ref.source);
}
if (!providerConfig) {
return params.ref.provider === resolveDefaultSecretProviderAlias(params.config ?? {}, "env");
}
if (providerConfig.source !== "env") {
return false;
}
const allowlist = providerConfig.allowlist;
return !allowlist || allowlist.includes(params.ref.id);
}
function hasConfiguredValue(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
value: unknown;
}): boolean {
const secretRef = coerceSecretRef(params.value, params.config?.secrets?.defaults);
if (secretRef) {
return (
hasConfiguredSecretRefInConfigPath({
config: params.config,
env: params.env,
ref: secretRef,
}) &&
(secretRef.source !== "env" || Boolean(params.env[secretRef.id]?.trim()))
);
}
if (typeof params.value === "string") {
return params.value.trim().length > 0;
}
if (Array.isArray(params.value)) {
return params.value.length > 0;
}
if (isRecord(params.value)) {
return Object.keys(params.value).length > 0;
}
return params.value !== undefined && params.value !== null;
}
export function manifestConfigSignalPasses(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
signal: ManifestConfigAvailabilitySignal;
}): boolean {
const effectiveConfig = readEffectiveConfig({
config: params.config,
rootPath: params.signal.rootPath,
overlayPath: params.signal.overlayPath,
});
if (!effectiveConfig) {
return false;
}
const modeSignal = params.signal.mode;
if (modeSignal) {
const modePath = modeSignal.path?.trim() || "mode";
const mode = readStringAtPath(effectiveConfig, modePath) ?? modeSignal.default;
if (!mode) {
return false;
}
if (modeSignal.allowed?.length && !modeSignal.allowed.includes(mode)) {
return false;
}
if (modeSignal.disallowed?.includes(mode)) {
return false;
}
}
for (const requiredPath of params.signal.required ?? []) {
if (
!hasConfiguredValue({
config: params.config,
env: params.env,
value: readPath(effectiveConfig, requiredPath),
})
) {
return false;
}
}
const requiredAny = params.signal.requiredAny ?? [];
if (
requiredAny.length > 0 &&
!requiredAny.some((path) =>
hasConfiguredValue({
config: params.config,
env: params.env,
value: readPath(effectiveConfig, path),
}),
)
) {
return false;
}
return true;
}
function normalizeBaseUrlForManifestGuard(value: string): string {
return value.trim().replace(/\/+$/, "");
}
export function manifestProviderBaseUrlGuardPasses(params: {
config?: OpenClawConfig;
guard: ManifestAuthAvailabilitySignal["providerBaseUrl"];
}): boolean {
const guard = params.guard;
if (!guard) {
return true;
}
const providerConfig = params.config?.models?.providers?.[guard.provider];
const rawBaseUrl =
typeof providerConfig?.baseUrl === "string" && providerConfig.baseUrl.trim()
? providerConfig.baseUrl
: guard.defaultBaseUrl;
if (!rawBaseUrl) {
return false;
}
const normalizedBaseUrl = normalizeBaseUrlForManifestGuard(rawBaseUrl);
return guard.allowedBaseUrls.some(
(allowedBaseUrl) => normalizeBaseUrlForManifestGuard(allowedBaseUrl) === normalizedBaseUrl,
);
}
export function manifestPluginSetupProviderEnvVars(
plugin: PluginManifestRecord,
providerId: string,
): readonly string[] {
const direct = plugin.setup?.providers?.find((provider) => provider.id === providerId)?.envVars;
if (direct && direct.length > 0) {
return direct;
}
return plugin.providerAuthEnvVars?.[providerId] ?? [];
}
export function hasNonEmptyManifestEnvCandidate(
env: NodeJS.ProcessEnv,
envVars: readonly string[],
): boolean {
return envVars.some((envVar) => {
const key = envVar.trim();
return key.length > 0 && Boolean(env[key]?.trim());
});
}
function listToolAuthSignals(metadata: ToolMetadata): ManifestAuthAvailabilitySignal[] {
if (metadata.authSignals?.length) {
return metadata.authSignals;
}
return [...(metadata.authProviders ?? []), ...(metadata.aliases ?? [])].map((provider) => ({
provider,
}));
}
function toolMetadataPasses(params: {
plugin: PluginManifestRecord;
metadata: ToolMetadata;
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
hasAuthForProvider?: (providerId: string) => boolean;
}): boolean {
if (
params.metadata.configSignals?.some((signal) =>
manifestConfigSignalPasses({
config: params.config,
env: params.env,
signal,
}),
)
) {
return true;
}
for (const signal of listToolAuthSignals(params.metadata)) {
if (
!manifestProviderBaseUrlGuardPasses({
config: params.config,
guard: signal.providerBaseUrl,
})
) {
continue;
}
if (params.hasAuthForProvider?.(signal.provider)) {
return true;
}
if (
hasNonEmptyManifestEnvCandidate(
params.env,
manifestPluginSetupProviderEnvVars(params.plugin, signal.provider),
)
) {
return true;
}
}
return false;
}
export function hasManifestToolAvailability(params: {
plugin: PluginManifestRecord;
toolNames: readonly string[];
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
hasAuthForProvider?: (providerId: string) => boolean;
}): boolean {
for (const toolName of params.toolNames) {
const metadata = params.plugin.toolMetadata?.[toolName];
if (!metadata) {
return true;
}
if (
toolMetadataPasses({
plugin: params.plugin,
metadata,
config: params.config,
env: params.env,
hasAuthForProvider: params.hasAuthForProvider,
})
) {
return true;
}
}
return false;
}

View File

@@ -381,6 +381,8 @@ export type PluginManifest = {
videoGenerationProviderMetadata?: Record<string, PluginManifestCapabilityProviderMetadata>;
/** Cheap music-generation provider auth metadata without importing plugin runtime. */
musicGenerationProviderMetadata?: Record<string, PluginManifestCapabilityProviderMetadata>;
/** Cheap plugin-tool availability metadata without importing plugin runtime. */
toolMetadata?: Record<string, PluginManifestToolMetadata>;
/** Manifest-owned config behavior consumed by generic core helpers. */
configContracts?: PluginManifestConfigContracts;
channelConfigs?: Record<string, PluginManifestChannelConfig>;
@@ -453,6 +455,8 @@ export type PluginManifestCapabilityProviderMetadata = {
configSignals?: PluginManifestCapabilityProviderConfigSignal[];
};
export type PluginManifestToolMetadata = PluginManifestCapabilityProviderMetadata;
export type PluginManifestProviderAuthChoice = {
/** Provider id owned by this manifest entry. */
provider: string;
@@ -1565,6 +1569,7 @@ export function loadPluginManifest(
const musicGenerationProviderMetadata = normalizeCapabilityProviderMetadata(
raw.musicGenerationProviderMetadata,
);
const toolMetadata = normalizeCapabilityProviderMetadata(raw.toolMetadata);
const configContracts = normalizeManifestConfigContracts(raw.configContracts);
const channelConfigs = normalizeChannelConfigs(raw.channelConfigs);
@@ -1614,6 +1619,7 @@ export function loadPluginManifest(
imageGenerationProviderMetadata,
videoGenerationProviderMetadata,
musicGenerationProviderMetadata,
toolMetadata,
configContracts,
channelConfigs,
},

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
type MockRegistryToolEntry = {
pluginId: string;
@@ -28,6 +29,8 @@ let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetad
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
let clearCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").clearCurrentPluginMetadataSnapshot;
let setCurrentPluginMetadataSnapshot: typeof import("./current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot;
function makeTool(name: string) {
return {
@@ -47,6 +50,7 @@ function createContext() {
enabled: true,
allow: ["optional-demo", "message", "multi"],
load: { paths: ["/tmp/plugin.js"] },
slots: { memory: "none" },
},
},
workspaceDir: "/tmp",
@@ -81,6 +85,21 @@ function setRegistry(entries: MockRegistryToolEntry[]) {
}>,
};
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshots({
config: createContext().config,
plugins: entries
.map((entry) => ({
id: entry.pluginId,
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: entry.declaredNames ?? entry.names,
},
}))
.filter((plugin) => plugin.contracts.tools.length > 0),
});
return registry;
}
@@ -102,7 +121,6 @@ function createOptionalDemoEntry(): MockRegistryToolEntry {
names: ["optional_tool"],
optional: true,
source: "/tmp/optional-demo.js",
names: ["optional_tool"],
factory: () => makeTool("optional_tool"),
};
}
@@ -167,6 +185,19 @@ function expectAutoEnabledOptionalLoad(autoEnabledConfig: unknown) {
function resolveAutoEnabledOptionalDemoTools() {
setOptionalDemoRegistry();
const { rawContext, autoEnabledConfig } = createAutoEnabledOptionalContext();
installToolManifestSnapshot({
config: autoEnabledConfig,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
applyPluginAutoEnableMock.mockReturnValue({ config: autoEnabledConfig, changes: [] });
const tools = resolvePluginTools({
@@ -181,6 +212,19 @@ function resolveAutoEnabledOptionalDemoTools() {
}
function createOptionalDemoActiveRegistry() {
installToolManifestSnapshot({
config: createContext().config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
return {
plugins: [{ id: "optional-demo", status: "loaded" }],
tools: [createOptionalDemoEntry()],
@@ -188,6 +232,93 @@ function createOptionalDemoActiveRegistry() {
};
}
function installToolManifestSnapshot(params: {
config: ReturnType<typeof createContext>["config"];
plugin: Record<string, unknown>;
}) {
installToolManifestSnapshots({
config: params.config,
plugins: [params.plugin],
});
}
function installToolManifestSnapshots(params: {
config: ReturnType<typeof createContext>["config"];
plugins: Record<string, unknown>[];
}) {
const plugins = params.plugins;
setCurrentPluginMetadataSnapshot(
{
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
workspaceDir: "/tmp",
index: {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 0,
installRecords: {},
plugins: [],
diagnostics: [],
},
registryDiagnostics: [],
manifestRegistry: { plugins, diagnostics: [] },
plugins,
diagnostics: [],
byPluginId: new Map(plugins.map((plugin) => [String(plugin.id), plugin])),
normalizePluginId: (id: string) => id,
owners: {
channels: new Map(),
channelConfigs: new Map(),
providers: new Map(),
modelCatalogProviders: new Map(),
cliBackends: new Map(),
setupProviders: new Map(),
commandAliases: new Map(),
contracts: new Map(),
},
metrics: {
registrySnapshotMs: 0,
manifestRegistryMs: 0,
ownerMapsMs: 0,
totalMs: 0,
indexPluginCount: 0,
manifestPluginCount: plugins.length,
},
} as never,
{ config: params.config, env: process.env, workspaceDir: "/tmp" },
);
}
function createXaiToolManifest() {
return {
id: "xai",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: ["xai"],
providerAuthEnvVars: {
xai: ["XAI_API_KEY"],
},
contracts: {
tools: ["x_search"],
},
toolMetadata: {
x_search: {
authSignals: [{ provider: "xai" }],
configSignals: [
{
rootPath: "plugins.entries.xai.config",
overlayPath: "webSearch",
required: ["apiKey"],
},
],
},
},
};
}
function expectResolvedToolNames(
tools: ReturnType<typeof resolvePluginTools>,
expectedToolNames: readonly string[],
@@ -229,6 +360,8 @@ describe("resolvePluginTools optional tools", () => {
({ buildPluginToolMetadataKey, resolvePluginTools } = await import("./tools.js"));
({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } =
await import("./runtime.js"));
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
await import("./current-plugin-metadata-snapshot.js"));
});
beforeEach(() => {
@@ -243,16 +376,204 @@ describe("resolvePluginTools optional tools", () => {
changes: [],
}));
resetPluginRuntimeStateForTest?.();
clearCurrentPluginMetadataSnapshot?.();
});
afterEach(() => {
resetPluginRuntimeStateForTest?.();
clearCurrentPluginMetadataSnapshot?.();
setLoggerOverride(null);
loggingState.rawConsole = null;
resetLogger();
vi.useRealTimers();
});
it("does not load plugin-owned tools whose manifest metadata has no available signal", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
loadOpenClawPluginsMock.mockImplementation((params) =>
Array.isArray((params as { onlyPluginIds?: string[] }).onlyPluginIds) &&
(params as { onlyPluginIds?: string[] }).onlyPluginIds?.length === 0
? { tools: [], diagnostics: [] }
: {
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
},
);
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {},
});
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
}),
);
});
it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
pinActivePluginChannelRegistry({
plugins: [{ id: "xai", status: "loaded" }],
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
} as never);
loadOpenClawPluginsMock.mockReturnValue({ tools: [], diagnostics: [] });
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {},
allowGatewaySubagentBinding: true,
});
expect(tools).toEqual([]);
expect(factory).not.toHaveBeenCalled();
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: [],
}),
);
});
it("loads plugin-owned tools when manifest tool metadata has env auth evidence", () => {
const config = createContext().config;
installToolManifestSnapshot({
config,
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
loadOpenClawPluginsMock.mockReturnValue({
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
});
const tools = resolvePluginTools({
context: {
...createContext(),
config,
} as never,
env: {
XAI_API_KEY: "test-key",
},
});
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["xai"],
}),
);
});
it("loads plugin-owned tools when manifest config signals point at configured non-env SecretRefs", () => {
const base = createContext();
const config = {
...base.config,
plugins: {
...base.config.plugins,
entries: {
xai: {
config: {
webSearch: {
apiKey: {
source: "file",
provider: "vault",
id: "/xai/tool-key",
},
},
},
},
},
},
secrets: {
providers: {
vault: {
source: "file",
path: "/tmp/openclaw-secrets.json",
mode: "json",
},
},
},
} as const;
installToolManifestSnapshot({
config,
plugin: createXaiToolManifest(),
});
const factory = vi.fn(() => makeTool("x_search"));
loadOpenClawPluginsMock.mockReturnValue({
tools: [
{
pluginId: "xai",
optional: false,
source: "/tmp/xai.js",
names: ["x_search"],
factory,
},
],
diagnostics: [],
});
const tools = resolvePluginTools({
context: {
...base,
config,
} as never,
env: {},
});
expectResolvedToolNames(tools, ["x_search"]);
expect(factory).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["xai"],
}),
);
});
it("skips optional tools without explicit allowlist", () => {
setOptionalDemoRegistry();
const tools = resolveOptionalDemoTools();
@@ -285,6 +606,7 @@ describe("resolvePluginTools optional tools", () => {
optional: true,
source: "/tmp/optional-demo.js",
names: [],
declaredNames: ["optional_tool"],
factory,
},
]);
@@ -582,6 +904,19 @@ describe("resolvePluginTools optional tools", () => {
});
it("does not widen active registry reuse to non-matching plugin tool owners", () => {
installToolManifestSnapshot({
config: createContext().config,
plugin: {
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
});
const heavyFactory = vi.fn(() => makeTool("heavy_tool"));
const activeRegistry = {
plugins: [
@@ -640,7 +975,7 @@ describe("resolvePluginTools optional tools", () => {
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["optional-demo", "tavily"],
onlyPluginIds: ["tavily"],
}),
);
});

View File

@@ -7,6 +7,7 @@ import {
isManifestPluginAvailableForControlPlane,
loadManifestContractSnapshot,
} from "./manifest-contract-eligibility.js";
import { hasManifestToolAvailability } from "./manifest-tool-availability.js";
import {
getActivePluginChannelRegistry,
getActivePluginRegistry,
@@ -235,35 +236,34 @@ function manifestToolContractMatchesAllowlist(params: {
return params.toolNames.some((name) => params.allowlist.has(normalizeToolName(name)));
}
function addToolPluginIdsFromRegistry(
registry: ReturnType<typeof getActivePluginRegistry>,
pluginIds: Set<string>,
allowlist: Set<string>,
): void {
for (const entry of registry?.tools ?? []) {
if (
pluginToolNamesMatchAllowlist({
names: entry.names,
pluginId: entry.pluginId,
optional: entry.optional,
allowlist,
})
) {
pluginIds.add(entry.pluginId);
}
function listManifestToolNamesForAvailability(params: {
toolNames: readonly string[];
pluginId: string;
allowlist: Set<string>;
}): string[] {
if (
params.allowlist.size === 0 ||
params.allowlist.has("*") ||
params.allowlist.has("group:plugins")
) {
return [...params.toolNames];
}
if (params.allowlist.has(normalizeToolName(params.pluginId))) {
return [...params.toolNames];
}
return params.toolNames.filter((name) => params.allowlist.has(normalizeToolName(name)));
}
function resolvePluginToolRuntimePluginIds(params: {
config: PluginLoadOptions["config"];
availabilityConfig?: PluginLoadOptions["config"];
workspaceDir?: string;
env: NodeJS.ProcessEnv;
toolAllowlist?: string[];
hasAuthForProvider?: (providerId: string) => boolean;
}): string[] {
const pluginIds = new Set<string>();
const allowlist = normalizeAllowlist(params.toolAllowlist);
addToolPluginIdsFromRegistry(getActivePluginChannelRegistry(), pluginIds, allowlist);
addToolPluginIdsFromRegistry(getActivePluginRegistry(), pluginIds, allowlist);
const snapshot = loadManifestContractSnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
@@ -279,11 +279,23 @@ function resolvePluginToolRuntimePluginIds(params: {
) {
continue;
}
const toolNames = plugin.contracts?.tools ?? [];
if (
manifestToolContractMatchesAllowlist({
toolNames: plugin.contracts?.tools ?? [],
toolNames,
pluginId: plugin.id,
allowlist,
}) &&
hasManifestToolAvailability({
plugin,
toolNames: listManifestToolNamesForAvailability({
toolNames,
pluginId: plugin.id,
allowlist,
}),
config: params.availabilityConfig ?? params.config,
env: params.env,
hasAuthForProvider: params.hasAuthForProvider,
})
) {
pluginIds.add(plugin.id);
@@ -296,7 +308,7 @@ function registryContainsPluginIds(
registry: ReturnType<typeof getActivePluginRegistry>,
pluginIds?: readonly string[],
): boolean {
if (!registry || pluginIds === undefined) {
if (!registry || pluginIds === undefined || pluginIds.length === 0) {
return false;
}
const loadedPluginIds = new Set(
@@ -332,6 +344,7 @@ export function resolvePluginTools(params: {
toolAllowlist?: string[];
suppressNameConflicts?: boolean;
allowGatewaySubagentBinding?: boolean;
hasAuthForProvider?: (providerId: string) => boolean;
env?: NodeJS.ProcessEnv;
}): AnyAgentTool[] {
// Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely.
@@ -353,9 +366,11 @@ export function resolvePluginTools(params: {
: undefined;
const onlyPluginIds = resolvePluginToolRuntimePluginIds({
config: context.config,
availabilityConfig: params.context.runtimeConfig ?? context.config,
workspaceDir: context.workspaceDir,
env,
toolAllowlist: params.toolAllowlist,
hasAuthForProvider: params.hasAuthForProvider,
});
const loadOptions = buildPluginRuntimeLoadOptions(context, {
activate: false,
@@ -375,11 +390,15 @@ export function resolvePluginTools(params: {
const existing = params.existingToolNames ?? new Set<string>();
const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool)));
const allowlist = normalizeAllowlist(params.toolAllowlist);
const scopedPluginIds = new Set(onlyPluginIds);
const blockedPlugins = new Set<string>();
const factoryTimingStartedAt = Date.now();
const factoryTimings: PluginToolFactoryTiming[] = [];
for (const entry of registry.tools) {
if (!scopedPluginIds.has(entry.pluginId)) {
continue;
}
if (blockedPlugins.has(entry.pluginId)) {
continue;
}