From 2ae5669a1477d982ebebdb124ae00bc430e737f3 Mon Sep 17 00:00:00 2001 From: Shubhankar Tripathy <95570942+lonexreb@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:27:57 -0500 Subject: [PATCH] fix(agents): align plugin tool auth with provider runtime (#103852) * fix(agents): count env/config credentials in plugin tool provider-auth gating (#103828) The plugin-tool availability gate (hasAuthForProvider) consulted only the auth profile store, so tools like image analysis rejected providers whose API key arrives via the process environment (including keys injected through OPENCLAW_SERVICE_MANAGED_ENV_KEYS) or config apiKey SecretRefs - paths chat routing resolves fine via resolveApiKeyForProvider. Route the gate through the shared hasProviderAuthForTool helper the media model selection already uses, which layers runtime env, custom-provider SecretRefs, managed secrets, and the profile store. * fix(agents): preserve unprepared auth fallback Keep direct env lookup for callers without a prepared runtime auth lookup while allowing prepared plugin-tool composition to skip redundant metadata discovery. --------- Co-authored-by: Peter Steinberger --- src/agents/openclaw-plugin-tools.ts | 83 ++++++++++--- ...w-tools.browser-plugin.integration.test.ts | 117 ++++++++++++++++++ src/agents/tools/model-config.helpers.test.ts | 31 ++++- src/agents/tools/model-config.helpers.ts | 25 ++-- 4 files changed, 224 insertions(+), 32 deletions(-) diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 8858dbf9d5bc..28effadd2573 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -8,6 +8,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolvePluginTools } from "../plugins/tools.js"; import { resolveApiKeyForProfile, resolveAuthProfileOrder } from "./auth-profiles.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; +import { + createRuntimeProviderAuthLookup, + hasRuntimeAvailableProviderAuth, + resolveApiKeyForProvider as resolveProviderAuth, +} from "./model-auth.js"; import { createNodePluginTools } from "./node-plugin-tools.js"; import { resolveOpenClawPluginToolInputs, @@ -16,6 +21,7 @@ import { import { applyPluginToolDeliveryDefaults } from "./plugin-tool-delivery-defaults.js"; import { resolveAgentRuntimeToolConfig } from "./tool-runtime-config.js"; import type { AnyAgentTool } from "./tools/common.js"; +import { hasProviderAuthForTool } from "./tools/model-config.helpers.js"; type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & { pluginToolAllowlist?: string[]; @@ -50,23 +56,42 @@ export function resolveOpenClawPluginToolsForOptions(params: { // while tests can still inject a fixed resolvedConfig. return resolveAgentRuntimeToolConfig(params.resolvedConfig ?? params.options?.config); }; + const pluginToolInputs = resolveOpenClawPluginToolInputs({ + options: params.options, + resolvedConfig: params.resolvedConfig, + runtimeConfig: resolveCurrentRuntimeConfig(), + getRuntimeConfig: resolveCurrentRuntimeConfig, + }); const authProfileStore = params.options?.authProfileStore; - const resolveAuthProfileIdsForProvider = authProfileStore - ? (providerId: string): string[] => - resolveAuthProfileOrder({ - cfg: resolveCurrentRuntimeConfig(), - store: authProfileStore, - provider: providerId, - }) + const availabilityConfig = resolveCurrentRuntimeConfig(); + const availabilityRuntimeLookup = authProfileStore + ? createRuntimeProviderAuthLookup({ + cfg: availabilityConfig, + workspaceDir: pluginToolInputs.context.workspaceDir, + includePluginSyntheticAuth: false, + }) : undefined; const hasAuthForProvider = authProfileStore - ? (providerId: string) => (resolveAuthProfileIdsForProvider?.(providerId) ?? []).length > 0 + ? (providerId: string) => + hasProviderAuthForTool({ + provider: providerId, + cfg: availabilityConfig, + workspaceDir: pluginToolInputs.context.workspaceDir, + agentDir: params.options?.agentDir, + authStore: authProfileStore, + runtimeLookup: availabilityRuntimeLookup, + }) : undefined; const resolveApiKeyForProvider = authProfileStore ? async (providerId: string): Promise => { - for (const profileId of resolveAuthProfileIdsForProvider?.(providerId) ?? []) { + const cfg = resolveCurrentRuntimeConfig(); + for (const profileId of resolveAuthProfileOrder({ + cfg, + store: authProfileStore, + provider: providerId, + })) { const resolved = await resolveApiKeyForProfile({ - cfg: resolveCurrentRuntimeConfig(), + cfg, store: authProfileStore, profileId, agentDir: params.options?.agentDir, @@ -75,15 +100,39 @@ export function resolveOpenClawPluginToolsForOptions(params: { return resolved.apiKey; } } - return undefined; + const workspaceDir = pluginToolInputs.context.workspaceDir; + const runtimeLookup = createRuntimeProviderAuthLookup({ + cfg, + workspaceDir, + includePluginSyntheticAuth: false, + }); + if ( + !hasRuntimeAvailableProviderAuth({ + provider: providerId, + cfg, + workspaceDir, + allowPluginSyntheticAuth: false, + runtimeLookup, + }) + ) { + return undefined; + } + try { + const resolved = await resolveProviderAuth({ + provider: providerId, + cfg, + store: authProfileStore, + agentDir: params.options?.agentDir, + workspaceDir, + credentialPrecedence: "env-first", + allowAuthProfileFallback: false, + }); + return resolved.apiKey; + } catch { + return undefined; + } } : undefined; - const pluginToolInputs = resolveOpenClawPluginToolInputs({ - options: params.options, - resolvedConfig: params.resolvedConfig, - runtimeConfig: resolveCurrentRuntimeConfig(), - getRuntimeConfig: resolveCurrentRuntimeConfig, - }); const existingToolNames = new Set(params.existingToolNames ?? []); const pluginTools = resolvePluginTools({ ...pluginToolInputs, diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index 4707c382da78..ca64e4b0ccf0 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -1,4 +1,6 @@ // Verifies OpenClaw plugin tools are resolved with browser/runtime context. +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js"; @@ -9,6 +11,7 @@ import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js const hoisted = vi.hoisted(() => ({ resolvePluginTools: vi.fn(), })); +const TEST_AGENT_DIR = path.join(os.tmpdir(), "openclaw-plugin-tool-auth-test"); vi.mock("../plugins/tools.js", () => ({ resolvePluginTools: (...args: unknown[]) => hoisted.resolvePluginTools(...args), @@ -26,6 +29,7 @@ function firstResolvePluginToolsParams(): Record { describe("createOpenClawTools browser plugin integration", () => { afterEach(() => { hoisted.resolvePluginTools.mockReset(); + vi.unstubAllEnvs(); clearSecretsRuntimeSnapshot(); resetConfigRuntimeState(); }); @@ -177,6 +181,7 @@ describe("createOpenClawTools browser plugin integration", () => { resolveOpenClawPluginToolsForOptions({ options: { config, + agentDir: TEST_AGENT_DIR, authProfileStore: { version: 1, profiles: { @@ -203,6 +208,118 @@ describe("createOpenClawTools browser plugin integration", () => { ); }); + it("keeps provider availability and credential resolution aligned for env-only auth", async () => { + const envName = "OPENCLAW_PLUGIN_TOOL_AUTH_TEST_KEY"; + vi.stubEnv(envName, "env-only-key"); + let capturedParams: + | { + hasAuthForProvider?: (providerId: string) => boolean; + context?: { + hasAuthForProvider?: (providerId: string) => boolean; + resolveApiKeyForProvider?: (providerId: string) => Promise; + }; + } + | undefined; + hoisted.resolvePluginTools.mockImplementation((params: unknown) => { + capturedParams = params as typeof capturedParams; + return []; + }); + const config = { + models: { + providers: { + acme: { + baseUrl: "https://example.com/v1", + apiKey: `\${${envName}}`, + models: [], + }, + }, + }, + plugins: { allow: ["xai"] }, + } as OpenClawConfig; + + resolveOpenClawPluginToolsForOptions({ + options: { + config, + agentDir: TEST_AGENT_DIR, + workspaceDir: "/workspace", + authProfileStore: { version: 1, profiles: {} }, + }, + resolvedConfig: config, + }); + + expect(capturedParams?.hasAuthForProvider?.("acme")).toBe(true); + expect(capturedParams?.context?.hasAuthForProvider?.("acme")).toBe(true); + await expect(capturedParams?.context?.resolveApiKeyForProvider?.("acme")).resolves.toBe( + "env-only-key", + ); + }); + + it("keeps ordered profile precedence when runtime auth is also available", async () => { + vi.stubEnv("ACME_API_KEY", "env-key"); + let resolveApiKeyForProvider: ((providerId: string) => Promise) | undefined; + hoisted.resolvePluginTools.mockImplementation((params: unknown) => { + resolveApiKeyForProvider = ( + params as { + context?: { + resolveApiKeyForProvider?: (providerId: string) => Promise; + }; + } + ).context?.resolveApiKeyForProvider; + return []; + }); + const config = { + auth: { order: { acme: ["acme:profile"] } }, + models: { + providers: { + acme: { + baseUrl: "https://example.com/v1", + apiKey: "${ACME_API_KEY}", + models: [], + }, + }, + }, + plugins: { allow: ["xai"] }, + } as OpenClawConfig; + + resolveOpenClawPluginToolsForOptions({ + options: { + config, + agentDir: TEST_AGENT_DIR, + authProfileStore: { + version: 1, + profiles: { + "acme:profile": { + type: "api_key", + provider: "acme", + key: "profile-key", // pragma: allowlist secret + }, + }, + }, + }, + resolvedConfig: config, + }); + + await expect(resolveApiKeyForProvider?.("acme")).resolves.toBe("profile-key"); + }); + + it("preserves ungated plugin resolution when no authoritative auth store is supplied", () => { + hoisted.resolvePluginTools.mockReturnValue([]); + const config = { plugins: { allow: ["browser"] } } as OpenClawConfig; + + resolveOpenClawPluginToolsForOptions({ + options: { config, agentDir: "/unread-auth-store" }, + resolvedConfig: config, + }); + + const params = firstResolvePluginToolsParams() as { + hasAuthForProvider?: unknown; + context?: { hasAuthForProvider?: unknown; resolveApiKeyForProvider?: unknown }; + }; + expect(params.hasAuthForProvider).toBeUndefined(); + expect(params.context?.hasAuthForProvider).toBeUndefined(); + expect(params.context?.resolveApiKeyForProvider).toBeUndefined(); + }); + it("forwards plugin tool deny policy to plugin resolution", () => { hoisted.resolvePluginTools.mockReturnValue([]); const config = { diff --git a/src/agents/tools/model-config.helpers.test.ts b/src/agents/tools/model-config.helpers.test.ts index bc66deccac18..0fa13b14aad7 100644 --- a/src/agents/tools/model-config.helpers.test.ts +++ b/src/agents/tools/model-config.helpers.test.ts @@ -20,8 +20,20 @@ vi.mock("../auth-profiles/external-cli-sync.js", () => ({ const authMocks = vi.hoisted(() => ({ resolveEnvApiKey: vi.fn() })); vi.mock("../model-auth.js", async (importOriginal) => { - const actual = await importOriginal>(); - return { ...actual, resolveEnvApiKey: authMocks.resolveEnvApiKey }; + const actual = await importOriginal(); + return { + ...actual, + resolveEnvApiKey: authMocks.resolveEnvApiKey, + hasRuntimeAvailableProviderAuth: ( + params: Parameters[0], + ) => { + const envAuth = authMocks.resolveEnvApiKey(params.provider, params.env, { + config: params.cfg, + workspaceDir: params.workspaceDir, + }); + return Boolean(envAuth?.apiKey) || actual.hasRuntimeAvailableProviderAuth(params); + }, + }; }); const AGENT_DIR = "/tmp/openclaw-model-config-helper"; @@ -177,7 +189,20 @@ describe("hasProviderAuthForTool", () => { }); it("rejects providers without config, env, or profile auth", () => { - expect(hasProviderAuthForTool({ provider: "unconfigured-provider" })).toBe(false); + expect( + hasProviderAuthForTool({ + provider: "unconfigured-provider", + runtimeLookup: { + envApiKey: { + aliasMap: {}, + candidateMap: {}, + authEvidenceMap: {}, + skipSetupProviderFallback: true, + }, + }, + }), + ).toBe(false); + expect(authMocks.resolveEnvApiKey).toHaveBeenCalledTimes(1); }); }); diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts index 7483e2c9ad72..413c7bdc0fe8 100644 --- a/src/agents/tools/model-config.helpers.ts +++ b/src/agents/tools/model-config.helpers.ts @@ -28,6 +28,7 @@ import { hasRuntimeAvailableProviderAuth, resolveProviderEntryApiKeyProfileReference, resolveEnvApiKey, + type RuntimeProviderAuthLookup, } from "../model-auth.js"; import { resolveConfiguredModelRef } from "../model-selection.js"; @@ -69,12 +70,14 @@ export function hasAuthForProvider(params: { workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore; + runtimeLookup?: RuntimeProviderAuthLookup; }): boolean { // Env-key resolution is config/workspace aware: plugin-provider env candidates // come from the metadata snapshot resolved for this config. Non-bundled or // config-scoped provider plugins are invisible without it, so a config-blind // lookup would wrongly report "no auth" for env-key providers. if ( + !params.runtimeLookup && resolveEnvApiKey(params.provider, undefined, { config: params.cfg, workspaceDir: params.workspaceDir, @@ -131,6 +134,7 @@ export function hasProviderAuthForTool(params: { workspaceDir?: string; agentDir?: string; authStore?: AuthProfileStore; + runtimeLookup?: RuntimeProviderAuthLookup; }): boolean { if ( hasRuntimeAvailableProviderAuth({ @@ -138,22 +142,19 @@ export function hasProviderAuthForTool(params: { cfg: params.cfg, workspaceDir: params.workspaceDir, allowPluginSyntheticAuth: false, + runtimeLookup: params.runtimeLookup, }) ) { return true; } - if ( - hasAuthForProvider({ - provider: params.provider, - cfg: params.cfg, - workspaceDir: params.workspaceDir, - agentDir: params.agentDir, - authStore: params.authStore, - }) - ) { - return true; - } - return false; + return hasAuthForProvider({ + provider: params.provider, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + authStore: params.authStore, + runtimeLookup: params.runtimeLookup, + }); } function formatProviderModelRef(provider: string, model: string): string {