mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:30:42 +00:00
fix(secrets): align SecretRef inspect/strict behavior across preload/runtime paths (#66818)
* Config: add inspect/strict SecretRef string resolver * CLI: pass resolved/source config snapshots to plugin preload * Slack: keep HTTP route registration config-only * Providers: normalize SecretRef handling for auth and web tools * Secrets: add Exa web search target to registry and docs * Telegram: resolve env SecretRef tokens at runtime * Agents: resolve custom provider env SecretRef ids * Providers: fail closed on blocked SecretRef fallback * Telegram: enforce env SecretRef policy for runtime token refs * Status/Providers/Telegram: tighten SecretRef preload and fallback handling * Providers: enforce env SecretRef policy checks in fallback auth paths * fix: add SecretRef lifecycle changelog entry (#66818) (thanks @joshavant)
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInput,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveSecretInputString, normalizeSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
|
||||
export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
|
||||
export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30;
|
||||
export const DEFAULT_FIRECRAWL_SCRAPE_TIMEOUT_SECONDS = 60;
|
||||
export const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
|
||||
const FIRECRAWL_API_KEY_ENV_VAR = "FIRECRAWL_API_KEY";
|
||||
|
||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
? Web extends { search?: infer Search }
|
||||
@@ -104,33 +103,101 @@ export function resolveFirecrawlFetchConfig(cfg?: OpenClawConfig): FirecrawlFetc
|
||||
return firecrawl as FirecrawlFetchConfig;
|
||||
}
|
||||
|
||||
function normalizeConfiguredSecret(value: unknown, path: string): string | undefined {
|
||||
return normalizeSecretInput(
|
||||
normalizeResolvedSecretInputString({
|
||||
value,
|
||||
path,
|
||||
}),
|
||||
);
|
||||
type ConfiguredSecretResolution =
|
||||
| { status: "available"; value: string }
|
||||
| { status: "missing" }
|
||||
| { status: "blocked" };
|
||||
|
||||
function canResolveEnvSecretRefInReadOnlyPath(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
provider: string;
|
||||
id: string;
|
||||
}): boolean {
|
||||
const providerConfig = params.cfg?.secrets?.providers?.[params.provider];
|
||||
if (!providerConfig) {
|
||||
return params.provider === resolveDefaultSecretProviderAlias(params.cfg ?? {}, "env");
|
||||
}
|
||||
if (providerConfig.source !== "env") {
|
||||
return false;
|
||||
}
|
||||
const allowlist = providerConfig.allowlist;
|
||||
return !allowlist || allowlist.includes(params.id);
|
||||
}
|
||||
|
||||
function resolveConfiguredSecret(
|
||||
value: unknown,
|
||||
path: string,
|
||||
cfg?: OpenClawConfig,
|
||||
): ConfiguredSecretResolution {
|
||||
const resolved = resolveSecretInputString({
|
||||
value,
|
||||
path,
|
||||
defaults: cfg?.secrets?.defaults,
|
||||
mode: "inspect",
|
||||
});
|
||||
if (resolved.status === "available") {
|
||||
const normalized = normalizeSecretInput(resolved.value);
|
||||
return normalized ? { status: "available", value: normalized } : { status: "missing" };
|
||||
}
|
||||
if (resolved.status === "missing") {
|
||||
return { status: "missing" };
|
||||
}
|
||||
if (resolved.ref.source !== "env") {
|
||||
return { status: "blocked" };
|
||||
}
|
||||
const envVarName = resolved.ref.id.trim();
|
||||
if (envVarName !== FIRECRAWL_API_KEY_ENV_VAR) {
|
||||
return { status: "blocked" };
|
||||
}
|
||||
if (
|
||||
!canResolveEnvSecretRefInReadOnlyPath({
|
||||
cfg,
|
||||
provider: resolved.ref.provider,
|
||||
id: envVarName,
|
||||
})
|
||||
) {
|
||||
return { status: "blocked" };
|
||||
}
|
||||
const envValue = normalizeSecretInput(process.env[envVarName]);
|
||||
return envValue ? { status: "available", value: envValue } : { status: "missing" };
|
||||
}
|
||||
|
||||
export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
const pluginConfig = cfg?.plugins?.entries?.firecrawl?.config as PluginEntryConfig;
|
||||
const search = resolveFirecrawlSearchConfig(cfg);
|
||||
const fetch = resolveFirecrawlFetchConfig(cfg);
|
||||
return (
|
||||
normalizeConfiguredSecret(
|
||||
pluginConfig?.webFetch?.apiKey,
|
||||
"plugins.entries.firecrawl.config.webFetch.apiKey",
|
||||
) ||
|
||||
normalizeConfiguredSecret(
|
||||
search?.apiKey,
|
||||
"plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
) ||
|
||||
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
|
||||
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
|
||||
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||
|
||||
undefined
|
||||
);
|
||||
const configuredCandidates: Array<{ value: unknown; path: string }> = [
|
||||
{
|
||||
value: pluginConfig?.webFetch?.apiKey,
|
||||
path: "plugins.entries.firecrawl.config.webFetch.apiKey",
|
||||
},
|
||||
{
|
||||
value: search?.apiKey,
|
||||
path: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
},
|
||||
{
|
||||
value: search?.apiKey,
|
||||
path: "tools.web.search.firecrawl.apiKey",
|
||||
},
|
||||
{
|
||||
value: fetch?.apiKey,
|
||||
path: "tools.web.fetch.firecrawl.apiKey",
|
||||
},
|
||||
];
|
||||
let blockedConfiguredSecret = false;
|
||||
for (const candidate of configuredCandidates) {
|
||||
const resolved = resolveConfiguredSecret(candidate.value, candidate.path, cfg);
|
||||
if (resolved.status === "available") {
|
||||
return resolved.value;
|
||||
}
|
||||
if (resolved.status === "blocked") {
|
||||
blockedConfiguredSecret = true;
|
||||
}
|
||||
}
|
||||
if (blockedConfiguredSecret) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeSecretInput(process.env[FIRECRAWL_API_KEY_ENV_VAR]) || undefined;
|
||||
}
|
||||
|
||||
export function resolveFirecrawlBaseUrl(cfg?: OpenClawConfig): string {
|
||||
|
||||
@@ -474,6 +474,137 @@ describe("firecrawl tools", () => {
|
||||
expect(resolveFirecrawlBaseUrl({} as OpenClawConfig)).not.toBe(DEFAULT_FIRECRAWL_BASE_URL);
|
||||
});
|
||||
|
||||
it("resolves env SecretRefs for Firecrawl API key without requiring a runtime snapshot", () => {
|
||||
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "FIRECRAWL_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveFirecrawlApiKey(cfg)).toBe("firecrawl-env-ref-key");
|
||||
});
|
||||
|
||||
it("does not use env fallback when a non-env SecretRef is configured but unavailable", () => {
|
||||
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-fallback");
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "vault",
|
||||
id: "/firecrawl/api-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not read arbitrary env SecretRef ids for Firecrawl API key resolution", () => {
|
||||
vi.stubEnv("UNRELATED_SECRET", "should-not-be-read");
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "UNRELATED_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider allowlist excludes FIRECRAWL_API_KEY", () => {
|
||||
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
|
||||
const cfg = {
|
||||
secrets: {
|
||||
providers: {
|
||||
"firecrawl-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_FIRECRAWL_API_KEY"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "firecrawl-env",
|
||||
id: "FIRECRAWL_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider source is not env", () => {
|
||||
vi.stubEnv("FIRECRAWL_API_KEY", "firecrawl-env-ref-key");
|
||||
const cfg = {
|
||||
secrets: {
|
||||
providers: {
|
||||
"firecrawl-env": {
|
||||
source: "file",
|
||||
path: "/tmp/secrets.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "firecrawl-env",
|
||||
id: "FIRECRAWL_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveFirecrawlApiKey(cfg)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("only allows the official Firecrawl API host for fetch endpoints", () => {
|
||||
expect(firecrawlClientTesting.resolveEndpoint("https://api.firecrawl.dev", "/v2/scrape")).toBe(
|
||||
"https://api.firecrawl.dev/v2/scrape",
|
||||
|
||||
62
extensions/slack/src/http/plugin-routes.test.ts
Normal file
62
extensions/slack/src/http/plugin-routes.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../../../test/helpers/plugins/plugin-api.js";
|
||||
import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js";
|
||||
import { registerSlackPluginHttpRoutes } from "./plugin-routes.js";
|
||||
|
||||
function createApi(config: OpenClawConfig, registerHttpRoute = vi.fn()): OpenClawPluginApi {
|
||||
return createTestPluginApi({
|
||||
id: "slack",
|
||||
config,
|
||||
registerHttpRoute,
|
||||
}) as OpenClawPluginApi;
|
||||
}
|
||||
|
||||
describe("registerSlackPluginHttpRoutes", () => {
|
||||
it("registers account webhook paths without resolving unresolved token refs", () => {
|
||||
const registerHttpRoute = vi.fn();
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
accounts: {
|
||||
default: {
|
||||
webhookPath: "/hooks/default",
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_BOT_TOKEN",
|
||||
} as unknown as string,
|
||||
},
|
||||
ops: {
|
||||
webhookPath: "hooks/ops",
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_OPS_BOT_TOKEN",
|
||||
} as unknown as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const api = createApi(cfg, registerHttpRoute);
|
||||
|
||||
expect(() => registerSlackPluginHttpRoutes(api)).not.toThrow();
|
||||
|
||||
const paths = registerHttpRoute.mock.calls
|
||||
.map((call) => (call[0] as { path: string }).path)
|
||||
.toSorted();
|
||||
expect(paths).toEqual(["/hooks/default", "/hooks/ops"]);
|
||||
});
|
||||
|
||||
it("falls back to the default slack webhook path", () => {
|
||||
const registerHttpRoute = vi.fn();
|
||||
const api = createApi({}, registerHttpRoute);
|
||||
|
||||
registerSlackPluginHttpRoutes(api);
|
||||
|
||||
const paths = registerHttpRoute.mock.calls
|
||||
.map((call) => (call[0] as { path: string }).path)
|
||||
.toSorted();
|
||||
expect(paths).toEqual(["/slack/events"]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
import { listSlackAccountIds, resolveSlackAccount } from "../accounts.js";
|
||||
import { listSlackAccountIds, mergeSlackAccountConfig } from "../accounts.js";
|
||||
import { normalizeSlackWebhookPath } from "./paths.js";
|
||||
|
||||
let slackHttpHandlerRuntimePromise: Promise<typeof import("./handler.runtime.js")> | null = null;
|
||||
@@ -14,8 +14,9 @@ export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
|
||||
const accountIds = new Set<string>([DEFAULT_ACCOUNT_ID, ...listSlackAccountIds(api.config)]);
|
||||
const registeredPaths = new Set<string>();
|
||||
for (const accountId of accountIds) {
|
||||
const account = resolveSlackAccount({ cfg: api.config, accountId });
|
||||
registeredPaths.add(normalizeSlackWebhookPath(account.config.webhookPath));
|
||||
// Route registration must remain config-only and should not resolve tokens.
|
||||
const accountConfig = mergeSlackAccountConfig(api.config, accountId);
|
||||
registeredPaths.add(normalizeSlackWebhookPath(accountConfig.webhookPath));
|
||||
}
|
||||
if (registeredPaths.size === 0) {
|
||||
registeredPaths.add(normalizeSlackWebhookPath());
|
||||
|
||||
@@ -229,7 +229,8 @@ describe("resolveTelegramToken", () => {
|
||||
expectNoTokenForUnknownAccount(createUnknownAccountConfig());
|
||||
});
|
||||
|
||||
it("throws when botToken is an unresolved SecretRef object", () => {
|
||||
it("resolves env-backed SecretRefs from process.env", () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", "secretref-env-token");
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
@@ -238,6 +239,148 @@ describe("resolveTelegramToken", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(resolveTelegramToken(cfg)).toEqual({
|
||||
token: "secretref-env-token",
|
||||
source: "config",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall back to TELEGRAM_BOT_TOKEN when an explicit env SecretRef is configured but unavailable", () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", "fallback-env-token");
|
||||
vi.stubEnv("TELEGRAM_REF_TOKEN", "");
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "default", id: "TELEGRAM_REF_TOKEN" },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(resolveTelegramToken(cfg)).toEqual({
|
||||
token: "",
|
||||
source: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fall through when account-level env SecretRef is configured but unavailable", () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", "fallback-env-token");
|
||||
vi.stubEnv("TELEGRAM_ACCOUNT_REF_TOKEN", "");
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "channel-token",
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TELEGRAM_ACCOUNT_REF_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(resolveTelegramToken(cfg)).toEqual({
|
||||
token: "",
|
||||
source: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bypass env provider allowlists for env-backed SecretRefs", () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", "secretref-env-token");
|
||||
const cfg = {
|
||||
secrets: {
|
||||
providers: {
|
||||
"telegram-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_TELEGRAM_BOT_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "telegram-env", id: "TELEGRAM_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||
/not allowlisted in secrets\.providers\.telegram-env\.allowlist/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when an env SecretRef points at a provider configured with another source", () => {
|
||||
const cfg = {
|
||||
secrets: {
|
||||
providers: {
|
||||
"telegram-env": {
|
||||
source: "file",
|
||||
path: "/tmp/secrets.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "telegram-env", id: "TELEGRAM_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||
/Secret provider "telegram-env" has source "file" but ref requests "env"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when an env SecretRef provider is not configured and not the default env alias", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "ops-env", id: "TELEGRAM_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||
/Secret provider "ops-env" is not configured \(ref: env:ops-env:TELEGRAM_BOT_TOKEN\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts env SecretRefs that use the configured default env provider alias", () => {
|
||||
vi.stubEnv("TELEGRAM_RUNTIME_TOKEN", "secretref-env-token");
|
||||
const cfg = {
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "telegram-runtime",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "telegram-runtime",
|
||||
id: "TELEGRAM_RUNTIME_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(resolveTelegramToken(cfg)).toEqual({
|
||||
token: "secretref-env-token",
|
||||
source: "config",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps strict runtime behavior for unresolved non-env SecretRefs", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "file", provider: "vault", id: "/telegram/bot-token" },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(() => resolveTelegramToken(cfg)).toThrow(
|
||||
/channels\.telegram\.botToken: unresolved SecretRef/i,
|
||||
);
|
||||
|
||||
@@ -3,8 +3,12 @@ import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
normalizeSecretInputString,
|
||||
resolveSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
|
||||
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
|
||||
|
||||
@@ -12,6 +16,83 @@ export type TelegramTokenResolution = BaseTokenResolution & {
|
||||
source: TelegramTokenSource;
|
||||
};
|
||||
|
||||
type RuntimeTokenValueResolution =
|
||||
| { status: "available"; value: string }
|
||||
| { status: "configured_unavailable" }
|
||||
| { status: "missing" };
|
||||
|
||||
function resolveEnvSecretRefValue(params: {
|
||||
cfg?: Pick<OpenClawConfig, "secrets">;
|
||||
provider: string;
|
||||
id: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string | undefined {
|
||||
const providerConfig = params.cfg?.secrets?.providers?.[params.provider];
|
||||
if (providerConfig) {
|
||||
if (providerConfig.source !== "env") {
|
||||
throw new Error(
|
||||
`Secret provider "${params.provider}" has source "${providerConfig.source}" but ref requests "env".`,
|
||||
);
|
||||
}
|
||||
if (providerConfig.allowlist && !providerConfig.allowlist.includes(params.id)) {
|
||||
throw new Error(
|
||||
`Environment variable "${params.id}" is not allowlisted in secrets.providers.${params.provider}.allowlist.`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
params.provider !== resolveDefaultSecretProviderAlias({ secrets: params.cfg?.secrets }, "env")
|
||||
) {
|
||||
throw new Error(
|
||||
`Secret provider "${params.provider}" is not configured (ref: env:${params.provider}:${params.id}).`,
|
||||
);
|
||||
}
|
||||
return normalizeSecretInputString((params.env ?? process.env)[params.id]);
|
||||
}
|
||||
|
||||
function resolveRuntimeTokenValue(params: {
|
||||
cfg?: Pick<OpenClawConfig, "secrets">;
|
||||
value: unknown;
|
||||
path: string;
|
||||
}): RuntimeTokenValueResolution {
|
||||
const resolved = resolveSecretInputString({
|
||||
value: params.value,
|
||||
path: params.path,
|
||||
defaults: params.cfg?.secrets?.defaults,
|
||||
mode: "inspect",
|
||||
});
|
||||
if (resolved.status === "available") {
|
||||
return {
|
||||
status: "available",
|
||||
value: resolved.value,
|
||||
};
|
||||
}
|
||||
if (resolved.status === "missing") {
|
||||
return { status: "missing" };
|
||||
}
|
||||
if (resolved.ref.source === "env") {
|
||||
const envValue = resolveEnvSecretRefValue({
|
||||
cfg: params.cfg,
|
||||
provider: resolved.ref.provider,
|
||||
id: resolved.ref.id,
|
||||
});
|
||||
if (envValue) {
|
||||
return {
|
||||
status: "available",
|
||||
value: envValue,
|
||||
};
|
||||
}
|
||||
return { status: "configured_unavailable" };
|
||||
}
|
||||
// Runtime resolution stays strict for non-env SecretRefs.
|
||||
resolveSecretInputString({
|
||||
value: params.value,
|
||||
path: params.path,
|
||||
defaults: params.cfg?.secrets?.defaults,
|
||||
mode: "strict",
|
||||
});
|
||||
return { status: "configured_unavailable" };
|
||||
}
|
||||
|
||||
type ResolveTelegramTokenOpts = {
|
||||
envToken?: string | null;
|
||||
accountId?: string | null;
|
||||
@@ -79,12 +160,16 @@ export function resolveTelegramToken(
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
|
||||
const accountToken = normalizeResolvedSecretInputString({
|
||||
const accountToken = resolveRuntimeTokenValue({
|
||||
cfg,
|
||||
value: accountCfg?.botToken,
|
||||
path: `channels.telegram.accounts.${accountId}.botToken`,
|
||||
});
|
||||
if (accountToken) {
|
||||
return { token: accountToken, source: "config" };
|
||||
if (accountToken.status === "available") {
|
||||
return { token: accountToken.value, source: "config" };
|
||||
}
|
||||
if (accountToken.status === "configured_unavailable") {
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
@@ -100,12 +185,16 @@ export function resolveTelegramToken(
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
|
||||
const configToken = normalizeResolvedSecretInputString({
|
||||
const configToken = resolveRuntimeTokenValue({
|
||||
cfg,
|
||||
value: telegramCfg?.botToken,
|
||||
path: "channels.telegram.botToken",
|
||||
});
|
||||
if (configToken) {
|
||||
return { token: configToken, source: "config" };
|
||||
if (configToken.status === "available") {
|
||||
return { token: configToken.value, source: "config" };
|
||||
}
|
||||
if (configToken.status === "configured_unavailable") {
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
|
||||
const envToken = allowEnv ? (opts.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim() : "";
|
||||
|
||||
@@ -137,4 +137,150 @@ describe("xai tool auth helpers", () => {
|
||||
expect(isXaiToolEnabled({ enabled: false })).toBe(false);
|
||||
expect(isXaiToolEnabled({ enabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not use env fallback when a non-env SecretRef is configured but unavailable", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "env-key");
|
||||
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "file",
|
||||
provider: "vault",
|
||||
id: "/xai/tool-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves env SecretRefs from source config when runtime snapshot is unavailable", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "XAI_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("xai-secretref-key");
|
||||
});
|
||||
|
||||
it("does not read arbitrary env SecretRef ids for xAI tool auth", () => {
|
||||
vi.stubEnv("UNRELATED_SECRET", "should-not-be-read");
|
||||
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "UNRELATED_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider allowlist excludes XAI_API_KEY", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
secrets: {
|
||||
providers: {
|
||||
"xai-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_XAI_API_KEY"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "xai-env",
|
||||
id: "XAI_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not resolve env SecretRefs when provider source is not env", () => {
|
||||
vi.stubEnv("XAI_API_KEY", "xai-secretref-key");
|
||||
|
||||
expect(
|
||||
resolveXaiToolApiKey({
|
||||
sourceConfig: {
|
||||
secrets: {
|
||||
providers: {
|
||||
"xai-env": {
|
||||
source: "file",
|
||||
path: "/tmp/secrets.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "xai-env",
|
||||
id: "XAI_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
resolveDefaultSecretProviderAlias,
|
||||
resolveNonEnvSecretRefApiKeyMarker,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
readProviderEnvValue,
|
||||
readConfiguredSecretString,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
normalizeSecretInputString,
|
||||
resolveSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
|
||||
export type XaiFallbackAuth = {
|
||||
apiKey: string;
|
||||
source: string;
|
||||
};
|
||||
const XAI_API_KEY_ENV_VAR = "XAI_API_KEY";
|
||||
|
||||
type ConfiguredRuntimeApiKeyResolution =
|
||||
| { status: "available"; value: string }
|
||||
| { status: "missing" }
|
||||
| { status: "blocked" };
|
||||
|
||||
function canResolveEnvSecretRefInReadOnlyPath(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
provider: string;
|
||||
id: string;
|
||||
}): boolean {
|
||||
const providerConfig = params.cfg?.secrets?.providers?.[params.provider];
|
||||
if (!providerConfig) {
|
||||
return params.provider === resolveDefaultSecretProviderAlias(params.cfg ?? {}, "env");
|
||||
}
|
||||
if (providerConfig.source !== "env") {
|
||||
return false;
|
||||
}
|
||||
const allowlist = providerConfig.allowlist;
|
||||
return !allowlist || allowlist.includes(params.id);
|
||||
}
|
||||
|
||||
function readConfiguredOrManagedApiKey(value: unknown): string | undefined {
|
||||
const literal = normalizeSecretInputString(value);
|
||||
@@ -36,23 +61,74 @@ function readLegacyGrokFallbackAuth(cfg?: OpenClawConfig): XaiFallbackAuth | und
|
||||
return apiKey ? { apiKey, source: "tools.web.search.grok.apiKey" } : undefined;
|
||||
}
|
||||
|
||||
export function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
function readConfiguredRuntimeApiKey(
|
||||
value: unknown,
|
||||
path: string,
|
||||
cfg?: OpenClawConfig,
|
||||
): ConfiguredRuntimeApiKeyResolution {
|
||||
const resolved = resolveSecretInputString({
|
||||
value,
|
||||
path,
|
||||
defaults: cfg?.secrets?.defaults,
|
||||
mode: "inspect",
|
||||
});
|
||||
if (resolved.status === "available") {
|
||||
return { status: "available", value: resolved.value };
|
||||
}
|
||||
if (resolved.status === "missing") {
|
||||
return { status: "missing" };
|
||||
}
|
||||
if (resolved.ref.source !== "env") {
|
||||
return { status: "blocked" };
|
||||
}
|
||||
const envVarName = resolved.ref.id.trim();
|
||||
if (envVarName !== XAI_API_KEY_ENV_VAR) {
|
||||
return { status: "blocked" };
|
||||
}
|
||||
if (
|
||||
!canResolveEnvSecretRefInReadOnlyPath({
|
||||
cfg,
|
||||
provider: resolved.ref.provider,
|
||||
id: envVarName,
|
||||
})
|
||||
) {
|
||||
return { status: "blocked" };
|
||||
}
|
||||
const envValue = normalizeSecretInputString(process.env[envVarName]);
|
||||
return envValue ? { status: "available", value: envValue } : { status: "missing" };
|
||||
}
|
||||
|
||||
function readLegacyGrokApiKeyResult(cfg?: OpenClawConfig): ConfiguredRuntimeApiKeyResolution {
|
||||
const search = cfg?.tools?.web?.search;
|
||||
if (!search || typeof search !== "object") {
|
||||
return undefined;
|
||||
return { status: "missing" };
|
||||
}
|
||||
const grok = (search as Record<string, unknown>).grok;
|
||||
return readConfiguredSecretString(
|
||||
return readConfiguredRuntimeApiKey(
|
||||
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
|
||||
"tools.web.search.grok.apiKey",
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
export function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
const resolved = readLegacyGrokApiKeyResult(cfg);
|
||||
return resolved.status === "available" ? resolved.value : undefined;
|
||||
}
|
||||
|
||||
function readPluginXaiWebSearchApiKeyResult(
|
||||
cfg?: OpenClawConfig,
|
||||
): ConfiguredRuntimeApiKeyResolution {
|
||||
return readConfiguredRuntimeApiKey(
|
||||
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
|
||||
"plugins.entries.xai.config.webSearch.apiKey",
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
export function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
return readConfiguredSecretString(
|
||||
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
|
||||
"plugins.entries.xai.config.webSearch.apiKey",
|
||||
);
|
||||
const resolved = readPluginXaiWebSearchApiKeyResult(cfg);
|
||||
return resolved.status === "available" ? resolved.value : undefined;
|
||||
}
|
||||
|
||||
export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth | undefined {
|
||||
@@ -69,18 +145,50 @@ export function resolveFallbackXaiAuth(cfg?: OpenClawConfig): XaiFallbackAuth |
|
||||
}
|
||||
|
||||
export function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
return readPluginXaiWebSearchApiKey(cfg) ?? readLegacyGrokApiKey(cfg);
|
||||
const plugin = readPluginXaiWebSearchApiKeyResult(cfg);
|
||||
if (plugin.status === "available") {
|
||||
return plugin.value;
|
||||
}
|
||||
if (plugin.status === "blocked") {
|
||||
return undefined;
|
||||
}
|
||||
const legacy = readLegacyGrokApiKeyResult(cfg);
|
||||
return legacy.status === "available" ? legacy.value : undefined;
|
||||
}
|
||||
|
||||
export function resolveXaiToolApiKey(params: {
|
||||
runtimeConfig?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
}): string | undefined {
|
||||
return (
|
||||
resolveFallbackXaiApiKey(params.runtimeConfig) ??
|
||||
resolveFallbackXaiApiKey(params.sourceConfig) ??
|
||||
readProviderEnvValue(["XAI_API_KEY"])
|
||||
);
|
||||
const runtimePlugin = readPluginXaiWebSearchApiKeyResult(params.runtimeConfig);
|
||||
if (runtimePlugin.status === "available") {
|
||||
return runtimePlugin.value;
|
||||
}
|
||||
if (runtimePlugin.status === "blocked") {
|
||||
return undefined;
|
||||
}
|
||||
const runtimeLegacy = readLegacyGrokApiKeyResult(params.runtimeConfig);
|
||||
if (runtimeLegacy.status === "available") {
|
||||
return runtimeLegacy.value;
|
||||
}
|
||||
if (runtimeLegacy.status === "blocked") {
|
||||
return undefined;
|
||||
}
|
||||
const sourcePlugin = readPluginXaiWebSearchApiKeyResult(params.sourceConfig);
|
||||
if (sourcePlugin.status === "available") {
|
||||
return sourcePlugin.value;
|
||||
}
|
||||
if (sourcePlugin.status === "blocked") {
|
||||
return undefined;
|
||||
}
|
||||
const sourceLegacy = readLegacyGrokApiKeyResult(params.sourceConfig);
|
||||
if (sourceLegacy.status === "available") {
|
||||
return sourceLegacy.value;
|
||||
}
|
||||
if (sourceLegacy.status === "blocked") {
|
||||
return undefined;
|
||||
}
|
||||
return readProviderEnvValue([XAI_API_KEY_ENV_VAR]);
|
||||
}
|
||||
|
||||
export function isXaiToolEnabled(params: {
|
||||
|
||||
Reference in New Issue
Block a user