mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) } : {}),
|
||||
|
||||
73
src/plugins/provider-auth-env-trust.test.ts
Normal file
73
src/plugins/provider-auth-env-trust.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user