mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
committed by
GitHub
parent
5578d01777
commit
2ae5669a14
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user