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 <steipete@gmail.com>
This commit is contained in:
Shubhankar Tripathy
2026-07-28 19:27:57 -05:00
committed by GitHub
parent 5578d01777
commit 2ae5669a14
4 changed files with 224 additions and 32 deletions
+66 -17
View File
@@ -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<string | undefined> => {
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,
@@ -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<string, unknown> {
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<string | undefined>;
};
}
| 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<string | undefined>) | undefined;
hoisted.resolvePluginTools.mockImplementation((params: unknown) => {
resolveApiKeyForProvider = (
params as {
context?: {
resolveApiKeyForProvider?: (providerId: string) => Promise<string | undefined>;
};
}
).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 = {
+28 -3
View File
@@ -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<Record<string, unknown>>();
return { ...actual, resolveEnvApiKey: authMocks.resolveEnvApiKey };
const actual = await importOriginal<typeof import("../model-auth.js")>();
return {
...actual,
resolveEnvApiKey: authMocks.resolveEnvApiKey,
hasRuntimeAvailableProviderAuth: (
params: Parameters<typeof actual.hasRuntimeAvailableProviderAuth>[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);
});
});
+13 -12
View File
@@ -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 {