CLI: detect env-backed audio providers (#65491)

* CLI: detect env-backed audio providers

* fix(cli): trust audio provider env detection

* Secrets: keep default provider env lookups stable

* Plugins: harden env-backed auth defaults

* Plugins: tighten trusted env var lookups

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
scoootscooob
2026-04-12 14:04:44 -07:00
committed by GitHub
parent 0bca55acea
commit 94ef2f1b0d
8 changed files with 378 additions and 9 deletions

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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) } : {}),

View File

@@ -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",
});
});
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -4,6 +4,7 @@ type MockManifestRegistry = {
plugins: Array<{
id: string;
origin: string;
kind?: "memory" | "context-engine" | Array<"memory" | "context-engine">;
providerAuthEnvVars?: Record<string, string[]>;
providerAuthAliases?: Record<string, string>;
}>;
@@ -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"]);
});
});

View File

@@ -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<NonNullable<OpenClawConfig["plugins"]>["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<string, string[]>,
providerId: string,
@@ -53,6 +121,9 @@ function resolveManifestProviderAuthEnvVarCandidates(
});
const candidates: Record<string, string[]> = {};
for (const plugin of registry.plugins) {
if (!shouldUsePluginProviderEnvVars(plugin, params)) {
continue;
}
if (!plugin.providerAuthEnvVars) {
continue;
}