diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 8e786825d65..190d28e2e2d 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -105,6 +105,7 @@ const mocks = vi.hoisted(() => ({ { id: "openai", defaultModel: "text-embedding-3-small", transport: "remote" }, ]), registerBuiltInMemoryEmbeddingProviders: vi.fn(), + buildMediaUnderstandingRegistry: vi.fn(() => new Map()), isWebSearchProviderConfigured: vi.fn(() => false), isWebFetchProviderConfigured: vi.fn(() => false), modelsStatusCommand: vi.fn( @@ -183,6 +184,11 @@ vi.mock("../media-understanding/runtime.js", () => ({ mocks.transcribeAudioFile as typeof import("../media-understanding/runtime.js").transcribeAudioFile, })); +vi.mock("../media-understanding/provider-registry.js", () => ({ + buildMediaUnderstandingRegistry: + mocks.buildMediaUnderstandingRegistry as typeof import("../media-understanding/provider-registry.js").buildMediaUnderstandingRegistry, +})); + vi.mock("../plugins/memory-embedding-providers.js", () => ({ listMemoryEmbeddingProviders: mocks.listMemoryEmbeddingProviders as unknown as typeof import("../plugins/memory-embedding-providers.js").listMemoryEmbeddingProviders, @@ -241,6 +247,7 @@ vi.mock("../web-fetch/runtime.js", () => ({ describe("capability cli", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); beforeEach(() => { @@ -288,6 +295,7 @@ describe("capability cli", () => { mocks.textToSpeech.mockClear(); mocks.setTtsProvider.mockClear(); mocks.resolveExplicitTtsOverrides.mockClear(); + mocks.buildMediaUnderstandingRegistry.mockReset().mockReturnValue(new Map()); mocks.createEmbeddingProvider.mockClear(); mocks.registerMemoryEmbeddingProvider.mockClear(); mocks.registerBuiltInMemoryEmbeddingProviders.mockClear(); @@ -920,6 +928,55 @@ describe("capability cli", () => { ); }); + it("marks env-backed audio providers as configured", async () => { + vi.stubEnv("DEEPGRAM_API_KEY", "deepgram-test-key"); + vi.stubEnv("GROQ_API_KEY", "groq-test-key"); + mocks.buildMediaUnderstandingRegistry.mockReturnValueOnce( + new Map([ + [ + "deepgram", + { + id: "deepgram", + capabilities: ["audio"], + defaultModels: { audio: "nova-3" }, + }, + ], + [ + "groq", + { + id: "groq", + capabilities: ["audio"], + defaultModels: { audio: "whisper-large-v3-turbo" }, + }, + ], + ]), + ); + + await runRegisteredCli({ + register: registerCapabilityCli as (program: Command) => void, + argv: ["capability", "audio", "providers", "--json"], + }); + + expect(mocks.runtime.writeJson).toHaveBeenCalledWith([ + { + available: true, + configured: true, + selected: false, + id: "deepgram", + capabilities: ["audio"], + defaultModels: { audio: "nova-3" }, + }, + { + available: true, + configured: true, + selected: false, + id: "groq", + capabilities: ["audio"], + defaultModels: { audio: "whisper-large-v3-turbo" }, + }, + ]); + }); + it("surfaces available, configured, and selected for web providers", async () => { mocks.loadConfig.mockReturnValue({ tools: { diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index df239a07485..d4775bfc253 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -37,6 +37,7 @@ import { registerMemoryEmbeddingProvider, } from "../plugins/memory-embedding-providers.js"; import { writeRuntimeJson, defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -1487,7 +1488,14 @@ export function registerCapabilityCli(program: Command) { .filter((provider) => provider.capabilities?.includes("audio")) .map((provider) => ({ available: true, - configured: providerHasGenericConfig({ cfg, providerId: provider.id }), + configured: providerHasGenericConfig({ + cfg, + providerId: provider.id, + envVars: getProviderEnvVars(provider.id, { + config: cfg, + includeUntrustedWorkspacePlugins: false, + }), + }), selected: false, id: provider.id, capabilities: provider.capabilities, diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index d418adff747..bb541ccf5a9 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -141,7 +141,12 @@ export function createProviderApiKeyAuthMethod( normalizeOptionalString(profileId.split(":", 1)[0]) || params.providerId, credentialInput, params.metadata, - capturedMode ? { secretInputMode: capturedMode } : undefined, + capturedMode + ? { + secretInputMode: capturedMode, + config: ctx.config, + } + : undefined, ), })), ...(params.applyConfig ? { configPatch: params.applyConfig(ctx.config) } : {}), diff --git a/src/plugins/provider-auth-env-trust.test.ts b/src/plugins/provider-auth-env-trust.test.ts new file mode 100644 index 00000000000..2f566bc6ac0 --- /dev/null +++ b/src/plugins/provider-auth-env-trust.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; + +const getProviderEnvVars = vi.hoisted(() => vi.fn(() => ["WHISPERX_API_KEY"])); + +vi.mock("../secrets/provider-env-vars.js", () => ({ + getProviderEnvVars, +})); + +describe("provider auth env trust", () => { + it("buildApiKeyCredential excludes untrusted workspace plugin env vars for ref mode", async () => { + const { buildApiKeyCredential } = await import("./provider-auth-helpers.js"); + const config = { plugins: {} }; + + const credential = buildApiKeyCredential("whisperx", "secret-value", undefined, { + secretInputMode: "ref", + config, + }); + + expect(getProviderEnvVars).toHaveBeenCalledWith("whisperx", { + config, + includeUntrustedWorkspacePlugins: false, + }); + expect(credential).toMatchObject({ + keyRef: { source: "env", provider: "default", id: "WHISPERX_API_KEY" }, + }); + }); + + it("resolveRefFallbackInput excludes untrusted workspace plugin env vars", async () => { + const { resolveRefFallbackInput } = await import("./provider-auth-ref.js"); + const config = { plugins: {} }; + + const result = resolveRefFallbackInput({ + config, + provider: "whisperx", + env: { WHISPERX_API_KEY: "test-secret" }, + }); + + expect(getProviderEnvVars).toHaveBeenCalledWith("whisperx", { + config, + includeUntrustedWorkspacePlugins: false, + }); + expect(result).toMatchObject({ + ref: { source: "env", provider: "default", id: "WHISPERX_API_KEY" }, + resolvedValue: "test-secret", + }); + }); + + it("promptSecretRefForSetup keeps config-aware trusted env var suggestions", async () => { + const { promptSecretRefForSetup } = await import("./provider-auth-ref.js"); + const config = { plugins: { allow: ["workspace-audio"] } }; + const prompter = { + select: vi.fn(async () => "env"), + text: vi.fn(async () => "WHISPERX_API_KEY"), + note: vi.fn(async () => {}), + }; + + const result = await promptSecretRefForSetup({ + config, + provider: "whisperx", + prompter: prompter as never, + env: { WHISPERX_API_KEY: "test-secret" }, + }); + + expect(getProviderEnvVars).toHaveBeenCalledWith("whisperx", { + config, + includeUntrustedWorkspacePlugins: false, + }); + expect(result).toMatchObject({ + ref: { source: "env", provider: "default", id: "WHISPERX_API_KEY" }, + resolvedValue: "test-secret", + }); + }); +}); diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index 180d4665633..245c32ac99f 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -23,6 +23,7 @@ const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAg export type ApiKeyStorageOptions = { secretInputMode?: SecretInputMode; + config?: OpenClawConfig; }; export type WriteOAuthCredentialsOptions = { @@ -43,8 +44,11 @@ function parseEnvSecretRef(value: string): SecretRef | null { return buildEnvSecretRef(match[1]); } -function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { - const envVars = getProviderEnvVars(provider); +function resolveProviderDefaultEnvSecretRef(provider: string, config?: OpenClawConfig): SecretRef { + const envVars = getProviderEnvVars(provider, { + ...(config ? { config } : {}), + includeUntrustedWorkspacePlugins: false, + }); const envVar = envVars?.find((candidate) => candidate.trim().length > 0); if (!envVar) { throw new Error( @@ -69,7 +73,7 @@ function resolveApiKeySecretInput( return inlineEnvRef; } if (options?.secretInputMode === "ref") { - return resolveProviderDefaultEnvSecretRef(provider); + return resolveProviderDefaultEnvSecretRef(provider, options.config); } return normalized; } diff --git a/src/plugins/provider-auth-ref.ts b/src/plugins/provider-auth-ref.ts index f91a2a1e7bc..5f98a3b3aed 100644 --- a/src/plugins/provider-auth-ref.ts +++ b/src/plugins/provider-auth-ref.ts @@ -42,8 +42,14 @@ export function extractEnvVarFromSourceLabel(source: string): string | undefined return match?.[1]; } -function resolveDefaultProviderEnvVar(provider: string): string | undefined { - const envVars = getProviderEnvVars(provider); +function resolveDefaultProviderEnvVar( + provider: string, + config?: OpenClawConfig, +): string | undefined { + const envVars = getProviderEnvVars(provider, { + ...(config ? { config } : {}), + includeUntrustedWorkspacePlugins: false, + }); return envVars?.find((candidate) => normalizeOptionalString(candidate) !== undefined); } @@ -57,7 +63,12 @@ export function resolveRefFallbackInput(params: { preferredEnvVar?: string; env?: NodeJS.ProcessEnv; }): { ref: SecretRef; resolvedValue: string } { - const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider); + const fallbackEnvVar = + params.preferredEnvVar ?? + getProviderEnvVars(params.provider, { + config: params.config, + includeUntrustedWorkspacePlugins: false, + }).find((candidate) => normalizeOptionalString(candidate) !== undefined); if (!fallbackEnvVar) { throw new Error( `No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`, @@ -262,7 +273,7 @@ export async function promptSecretRefForSetup(params: { env?: NodeJS.ProcessEnv; }): Promise<{ ref: SecretRef; resolvedValue: string }> { const defaultEnvVar = - params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? ""; + params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider, params.config) ?? ""; const defaultFilePointer = resolveDefaultFilePointerId(params.provider); let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret diff --git a/src/secrets/provider-env-vars.dynamic.test.ts b/src/secrets/provider-env-vars.dynamic.test.ts index 2f3299493c6..79247ad0324 100644 --- a/src/secrets/provider-env-vars.dynamic.test.ts +++ b/src/secrets/provider-env-vars.dynamic.test.ts @@ -4,6 +4,7 @@ type MockManifestRegistry = { plugins: Array<{ id: string; origin: string; + kind?: "memory" | "context-engine" | Array<"memory" | "context-engine">; providerAuthEnvVars?: Record; providerAuthAliases?: Record; }>; @@ -49,4 +50,143 @@ describe("provider env vars dynamic manifest metadata", () => { expect(mod.listKnownProviderAuthEnvVarNames()).toContain("FIREWORKS_ALT_API_KEY"); expect(mod.listKnownSecretEnvVarNames()).toContain("FIREWORKS_ALT_API_KEY"); }); + + it("keeps workspace plugin env vars in default lookups", async () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "workspace-audio", + origin: "workspace", + providerAuthEnvVars: { + whisperx: ["WHISPERX_API_KEY"], + }, + }, + ], + diagnostics: [], + }); + + const mod = await import("./provider-env-vars.js"); + + expect(mod.getProviderEnvVars("whisperx")).toEqual(["WHISPERX_API_KEY"]); + expect(mod.listKnownProviderAuthEnvVarNames()).toContain("WHISPERX_API_KEY"); + }); + + it("excludes untrusted workspace plugin env vars when requested", async () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "workspace-audio", + origin: "workspace", + providerAuthEnvVars: { + whisperx: ["AWS_SECRET_ACCESS_KEY"], + }, + }, + ], + diagnostics: [], + }); + + const mod = await import("./provider-env-vars.js"); + + expect( + mod.getProviderEnvVars("whisperx", { + config: { plugins: {} }, + includeUntrustedWorkspacePlugins: false, + }), + ).toEqual([]); + expect( + mod.listKnownProviderAuthEnvVarNames({ + config: { plugins: {} }, + includeUntrustedWorkspacePlugins: false, + }), + ).not.toContain("AWS_SECRET_ACCESS_KEY"); + }); + + it("keeps explicitly trusted workspace plugin env vars when requested", async () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "workspace-audio", + origin: "workspace", + providerAuthEnvVars: { + whisperx: ["WHISPERX_API_KEY"], + }, + }, + ], + diagnostics: [], + }); + + const mod = await import("./provider-env-vars.js"); + + expect( + mod.getProviderEnvVars("whisperx", { + config: { + plugins: { + allow: ["workspace-audio"], + }, + }, + includeUntrustedWorkspacePlugins: false, + }), + ).toEqual(["WHISPERX_API_KEY"]); + }); + + it("does not trust arbitrary workspace plugin ids from the context engine slot", async () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "workspace-audio", + origin: "workspace", + providerAuthEnvVars: { + whisperx: ["AWS_SECRET_ACCESS_KEY"], + }, + }, + ], + diagnostics: [], + }); + + const mod = await import("./provider-env-vars.js"); + + expect( + mod.getProviderEnvVars("whisperx", { + config: { + plugins: { + slots: { + contextEngine: "workspace-audio", + }, + }, + }, + includeUntrustedWorkspacePlugins: false, + }), + ).toEqual([]); + }); + + it("keeps selected workspace context engine env vars when requested", async () => { + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "workspace-engine", + origin: "workspace", + kind: "context-engine", + providerAuthEnvVars: { + whisperx: ["WHISPERX_API_KEY"], + }, + }, + ], + diagnostics: [], + }); + + const mod = await import("./provider-env-vars.js"); + + expect( + mod.getProviderEnvVars("whisperx", { + config: { + plugins: { + slots: { + contextEngine: "workspace-engine", + }, + }, + }, + includeUntrustedWorkspacePlugins: false, + }), + ).toEqual(["WHISPERX_API_KEY"]); + }); }); diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index ee77b880218..900677b9e31 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -1,6 +1,9 @@ import { resolveProviderAuthAliasMap } from "../agents/provider-auth-aliases.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import { hasKind } from "../plugins/slots.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], @@ -22,6 +25,71 @@ export type ProviderEnvVarLookupParams = { includeUntrustedWorkspacePlugins?: boolean; }; +type PluginEntriesConfig = NonNullable["entries"]>; + +function normalizePluginConfigId(id: unknown): string { + return normalizeOptionalLowercaseString(id) ?? ""; +} + +function hasPluginId(list: unknown, pluginId: string): boolean { + return Array.isArray(list) && list.some((entry) => normalizePluginConfigId(entry) === pluginId); +} + +function findPluginEntry( + entries: PluginEntriesConfig | undefined, + pluginId: string, +): { enabled?: boolean } | undefined { + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + return undefined; + } + for (const [key, value] of Object.entries(entries)) { + if (normalizePluginConfigId(key) !== pluginId) { + continue; + } + return value && typeof value === "object" && !Array.isArray(value) + ? (value as { enabled?: boolean }) + : {}; + } + return undefined; +} + +function isWorkspacePluginTrustedForProviderEnvVars( + plugin: PluginManifestRecord, + config: OpenClawConfig | undefined, +): boolean { + const pluginsConfig = config?.plugins; + if (pluginsConfig?.enabled === false) { + return false; + } + + const pluginId = normalizePluginConfigId(plugin.id); + if (!pluginId || hasPluginId(pluginsConfig?.deny, pluginId)) { + return false; + } + + const entry = findPluginEntry(pluginsConfig?.entries, pluginId); + if (entry?.enabled === false) { + return false; + } + if (entry?.enabled === true || hasPluginId(pluginsConfig?.allow, pluginId)) { + return true; + } + return ( + hasKind(plugin.kind, "context-engine") && + normalizePluginConfigId(pluginsConfig?.slots?.contextEngine) === pluginId + ); +} + +function shouldUsePluginProviderEnvVars( + plugin: PluginManifestRecord, + params: ProviderEnvVarLookupParams | undefined, +): boolean { + if (plugin.origin !== "workspace" || params?.includeUntrustedWorkspacePlugins !== false) { + return true; + } + return isWorkspacePluginTrustedForProviderEnvVars(plugin, params?.config); +} + function appendUniqueEnvVarCandidates( target: Record, providerId: string, @@ -53,6 +121,9 @@ function resolveManifestProviderAuthEnvVarCandidates( }); const candidates: Record = {}; for (const plugin of registry.plugins) { + if (!shouldUsePluginProviderEnvVars(plugin, params)) { + continue; + } if (!plugin.providerAuthEnvVars) { continue; }