From 1769fb2aa1d652ace3f975df3beecfb106ca8fc8 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:59:28 -0500 Subject: [PATCH] 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) --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../reference/secretref-credential-surface.md | 1 + ...tref-user-supplied-credentials-matrix.json | 7 + extensions/firecrawl/src/config.ts | 117 +++++++--- .../firecrawl/src/firecrawl-tools.test.ts | 131 ++++++++++++ .../slack/src/http/plugin-routes.test.ts | 62 ++++++ extensions/slack/src/http/plugin-routes.ts | 7 +- extensions/telegram/src/token.test.ts | 145 ++++++++++++- extensions/telegram/src/token.ts | 103 ++++++++- extensions/xai/src/tool-auth-shared.test.ts | 146 +++++++++++++ extensions/xai/src/tool-auth-shared.ts | 138 ++++++++++-- src/agents/model-auth.test.ts | 201 ++++++++++++++++++ src/agents/model-auth.ts | 65 +++++- src/cli/command-secret-targets.test.ts | 2 + src/cli/command-secret-targets.ts | 1 + src/cli/plugin-registry-loader.test.ts | 17 ++ src/cli/plugin-registry-loader.ts | 11 +- src/commands/agents.providers.test.ts | 124 +++++++++++ src/commands/agents.providers.ts | 38 +++- src/commands/status.scan.fast-json.test.ts | 45 ++++ src/commands/status.scan.fast-json.ts | 2 + src/commands/status.scan.test.ts | 24 ++- src/config/types.secrets.resolution.test.ts | 80 +++++++ src/config/types.secrets.ts | 62 +++++- src/plugin-sdk/secret-input.ts | 8 +- src/secrets/target-registry-data.ts | 11 + src/secrets/target-registry.test.ts | 14 ++ 28 files changed, 1497 insertions(+), 70 deletions(-) create mode 100644 extensions/slack/src/http/plugin-routes.test.ts create mode 100644 src/commands/agents.providers.test.ts create mode 100644 src/config/types.secrets.resolution.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4486f2e833..99a93ee0b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Agents/Anthropic: ignore non-positive Anthropic Messages token overrides and fail locally when no positive token budget remains, so invalid `max_tokens` values no longer reach the provider API. (#66664) thanks @jalehman - Agents/context engines: preserve prompt-only token counts, not full request totals, when deferred maintenance reuses after-turn runtime context so background compaction bookkeeping matches the active prompt window. (#66820) thanks @jalehman. - BlueBubbles/inbound: add a persistent file-backed GUID dedupe so MessagePoller webhook replays after BB Server restart or reconnect no longer cause the agent to re-reply to already-handled messages. (#19176, #12053, #66816) Thanks @omarshahine. +- Secrets/plugins/status: align SecretRef inspect-vs-strict handling across plugin preload, read-only status/agents surfaces, and runtime auth paths so unresolved refs no longer crash read-only CLI flows while runtime-required non-env refs stay strict. (#66818) Thanks @joshavant. ## 2026.4.14 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 113eff3aedb..9e3e9eadf08 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -cd06d41c9302b068d2d998e478a4cca5e0bdd0b165e381cc68740698a5921d21 plugin-sdk-api-baseline.json -8131372bd1fb433d24de85c94e3fe58368579abed10ec80f39370c6f6fee6373 plugin-sdk-api-baseline.jsonl +effb6ee16d16bc1b1e76ec293868910f887a168d9b756449928c703fe4c9e81a plugin-sdk-api-baseline.json +16eb8ac91b10b3ee62d856bf16c25c1ba3ba9fa7303500af2947a6e532b0c222 plugin-sdk-api-baseline.jsonl diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 6696623a748..26bc1d1aa28 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -42,6 +42,7 @@ Scope intent: - `messages.tts.providers.*.apiKey` - `tools.web.fetch.firecrawl.apiKey` - `plugins.entries.brave.config.webSearch.apiKey` +- `plugins.entries.exa.config.webSearch.apiKey` - `plugins.entries.google.config.webSearch.apiKey` - `plugins.entries.xai.config.webSearch.apiKey` - `plugins.entries.moonshot.config.webSearch.apiKey` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 40449f76ae8..9f427138e28 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -526,6 +526,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "plugins.entries.exa.config.webSearch.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.exa.config.webSearch.apiKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "plugins.entries.firecrawl.config.webSearch.apiKey", "configFile": "openclaw.json", diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts index f6928274d3c..8d4d563b95f 100644 --- a/extensions/firecrawl/src/config.ts +++ b/extensions/firecrawl/src/config.ts @@ -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["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 { diff --git a/extensions/firecrawl/src/firecrawl-tools.test.ts b/extensions/firecrawl/src/firecrawl-tools.test.ts index 009e327c64e..ead73e95b5e 100644 --- a/extensions/firecrawl/src/firecrawl-tools.test.ts +++ b/extensions/firecrawl/src/firecrawl-tools.test.ts @@ -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", diff --git a/extensions/slack/src/http/plugin-routes.test.ts b/extensions/slack/src/http/plugin-routes.test.ts new file mode 100644 index 00000000000..c296f8f8c92 --- /dev/null +++ b/extensions/slack/src/http/plugin-routes.test.ts @@ -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"]); + }); +}); diff --git a/extensions/slack/src/http/plugin-routes.ts b/extensions/slack/src/http/plugin-routes.ts index 38fcf24d7f5..481901f042d 100644 --- a/extensions/slack/src/http/plugin-routes.ts +++ b/extensions/slack/src/http/plugin-routes.ts @@ -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 | null = null; @@ -14,8 +14,9 @@ export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void { const accountIds = new Set([DEFAULT_ACCOUNT_ID, ...listSlackAccountIds(api.config)]); const registeredPaths = new Set(); 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()); diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts index 05a5ec00889..932098421d8 100644 --- a/extensions/telegram/src/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -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, ); diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index 98d5087fe77..3dd593cf2ac 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -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; + 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; + 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() : ""; diff --git a/extensions/xai/src/tool-auth-shared.test.ts b/extensions/xai/src/tool-auth-shared.test.ts index 2c281b6a06e..674bdab3abd 100644 --- a/extensions/xai/src/tool-auth-shared.test.ts +++ b/extensions/xai/src/tool-auth-shared.test.ts @@ -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(); + }); }); diff --git a/extensions/xai/src/tool-auth-shared.ts b/extensions/xai/src/tool-auth-shared.ts index c82d2da532a..1d6bc8ddb35 100644 --- a/extensions/xai/src/tool-auth-shared.ts +++ b/extensions/xai/src/tool-auth-shared.ts @@ -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).grok; - return readConfiguredSecretString( + return readConfiguredRuntimeApiKey( grok && typeof grok === "object" ? (grok as Record).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 | undefined, "xai")?.apiKey, + "plugins.entries.xai.config.webSearch.apiKey", + cfg, ); } export function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined { - return readConfiguredSecretString( - resolveProviderWebSearchPluginConfig(cfg as Record | 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: { diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 44c993a5ae5..00e82b5c38c 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -394,6 +394,207 @@ describe("resolveUsableCustomProviderApiKey", () => { } }); + it("resolves env SecretRefs from process env for custom providers", () => { + const previous = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "sk-secretref-env"; // pragma: allowlist secret + try { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: { + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }, + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved?.apiKey).toBe("sk-secretref-env"); + expect(resolved?.source).toContain("OPENAI_API_KEY"); + } finally { + if (previous === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previous; + } + } + }); + + it("resolves env SecretRefs with unknown env IDs from process env for custom providers", () => { + const previous = process.env.MY_CUSTOM_KEY; + process.env.MY_CUSTOM_KEY = "sk-custom-secretref-env"; // pragma: allowlist secret + try { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: { + source: "env", + provider: "default", + id: "MY_CUSTOM_KEY", + }, + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved?.apiKey).toBe("sk-custom-secretref-env"); + expect(resolved?.source).toContain("MY_CUSTOM_KEY"); + } finally { + if (previous === undefined) { + delete process.env.MY_CUSTOM_KEY; + } else { + process.env.MY_CUSTOM_KEY = previous; + } + } + }); + + it("does not resolve env SecretRefs when provider allowlist excludes the env id", () => { + const previous = process.env.MY_CUSTOM_KEY; + process.env.MY_CUSTOM_KEY = "sk-custom-secretref-env"; // pragma: allowlist secret + try { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + secrets: { + providers: { + "custom-env": { + source: "env", + allowlist: ["OPENAI_API_KEY"], + }, + }, + }, + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: { + source: "env", + provider: "custom-env", + id: "MY_CUSTOM_KEY", + }, + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved).toBeNull(); + } finally { + if (previous === undefined) { + delete process.env.MY_CUSTOM_KEY; + } else { + process.env.MY_CUSTOM_KEY = previous; + } + } + }); + + it("does not resolve env SecretRefs when provider source is not env", () => { + const previous = process.env.MY_CUSTOM_KEY; + process.env.MY_CUSTOM_KEY = "sk-custom-secretref-env"; // pragma: allowlist secret + try { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + secrets: { + providers: { + "custom-env": { + source: "file", + path: "/tmp/secrets.json", + }, + }, + }, + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: { + source: "env", + provider: "custom-env", + id: "MY_CUSTOM_KEY", + }, + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved).toBeNull(); + } finally { + if (previous === undefined) { + delete process.env.MY_CUSTOM_KEY; + } else { + process.env.MY_CUSTOM_KEY = previous; + } + } + }); + + it("does not treat env SecretRefs with missing unknown env IDs as usable", () => { + const previous = process.env.MY_CUSTOM_KEY; + delete process.env.MY_CUSTOM_KEY; + try { + expect( + hasUsableCustomProviderApiKey( + { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: { + source: "env", + provider: "default", + id: "MY_CUSTOM_KEY", + }, + models: [], + }, + }, + }, + }, + "custom", + ), + ).toBe(false); + } finally { + if (previous === undefined) { + delete process.env.MY_CUSTOM_KEY; + } else { + process.env.MY_CUSTOM_KEY = previous; + } + } + }); + + it("does not treat non-env SecretRefs as usable models.json credentials", () => { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + custom: { + baseUrl: "https://example.com/v1", + apiKey: { + source: "file", + provider: "vault", + id: "custom-provider-key", + }, + models: [], + }, + }, + }, + }, + provider: "custom", + }); + expect(resolved).toBeNull(); + }); + it("does not treat known env marker names as usable when env value is missing", () => { const previous = process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY; diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index d293affaaa0..b1c06084c68 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -13,6 +13,7 @@ import { shouldDeferProviderSyntheticProfileAuthWithPlugin, } from "../plugins/provider-runtime.js"; import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; +import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -73,7 +74,19 @@ export function getCustomProviderApiKey( provider: string, ): string | undefined { const entry = resolveProviderConfig(cfg, provider); - return normalizeOptionalSecretInput(entry?.apiKey); + const literal = normalizeOptionalSecretInput(entry?.apiKey); + if (literal) { + return literal; + } + const ref = coerceSecretRef(entry?.apiKey); + if (!ref) { + return undefined; + } + if (ref.source === "env") { + const envId = ref.id.trim(); + return envId || NON_ENV_SECRETREF_MARKER; + } + return NON_ENV_SECRETREF_MARKER; } type ResolvedCustomProviderApiKey = { @@ -81,11 +94,61 @@ type ResolvedCustomProviderApiKey = { source: string; }; +function canResolveEnvSecretRefInReadOnlyPath(params: { + cfg: OpenClawConfig | undefined; + 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); +} + export function resolveUsableCustomProviderApiKey(params: { cfg: OpenClawConfig | undefined; provider: string; env?: NodeJS.ProcessEnv; }): ResolvedCustomProviderApiKey | null { + const customProviderConfig = resolveProviderConfig(params.cfg, params.provider); + const apiKeyRef = coerceSecretRef(customProviderConfig?.apiKey); + if (apiKeyRef) { + if (apiKeyRef.source !== "env") { + return null; + } + const envVarName = apiKeyRef.id.trim(); + if (!envVarName) { + return null; + } + if ( + !canResolveEnvSecretRefInReadOnlyPath({ + cfg: params.cfg, + provider: apiKeyRef.provider, + id: envVarName, + }) + ) { + return null; + } + const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[envVarName]); + if (!envValue) { + return null; + } + const applied = new Set(getShellEnvAppliedKeys()); + return { + apiKey: envValue, + source: resolveEnvSourceLabel({ + applied, + envVars: [envVarName], + label: `${envVarName} (models.json secretref)`, + }), + }; + } + const customKey = getCustomProviderApiKey(params.cfg, params.provider); if (!customKey) { return null; diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 6b7fbe3f82f..cabf3028da7 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -14,6 +14,7 @@ const REGISTRY_IDS = [ "models.providers.openai.apiKey", "messages.tts.providers.openai.apiKey", "plugins.entries.firecrawl.config.webFetch.apiKey", + "plugins.entries.exa.config.webSearch.apiKey", "skills.entries.demo.apiKey", "tools.web.search.apiKey", ] as const; @@ -77,6 +78,7 @@ describe("command secret target ids", () => { expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true); expect(ids.has("plugins.entries.firecrawl.config.webFetch.apiKey")).toBe(true); + expect(ids.has("plugins.entries.exa.config.webSearch.apiKey")).toBe(true); expect(ids.has("channels.discord.token")).toBe(false); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index 6241dbff632..5e419d81a58 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -31,6 +31,7 @@ const STATIC_AGENT_RUNTIME_BASE_TARGET_IDS = [ "tools.web.search.apiKey", "plugins.entries.brave.config.webSearch.apiKey", "plugins.entries.google.config.webSearch.apiKey", + "plugins.entries.exa.config.webSearch.apiKey", "plugins.entries.xai.config.webSearch.apiKey", "plugins.entries.moonshot.config.webSearch.apiKey", "plugins.entries.perplexity.config.webSearch.apiKey", diff --git a/src/cli/plugin-registry-loader.test.ts b/src/cli/plugin-registry-loader.test.ts index 47b4dafb8d7..c11d1f611b4 100644 --- a/src/cli/plugin-registry-loader.test.ts +++ b/src/cli/plugin-registry-loader.test.ts @@ -60,6 +60,23 @@ describe("plugin-registry-loader", () => { expect(loggingState.forceConsoleToStderr).toBe(false); }); + it("forwards explicit config snapshots to plugin loading", async () => { + const config = { channels: { telegram: { enabled: true } } } as never; + const activationSourceConfig = { channels: { telegram: { enabled: true } } } as never; + + await ensureCliPluginRegistryLoaded({ + scope: "configured-channels", + config, + activationSourceConfig, + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "configured-channels", + config, + activationSourceConfig, + }); + }); + it("maps command paths to plugin registry scopes", () => { expect(resolvePluginRegistryScopeForCommandPath(["status"])).toBe("channels"); expect(resolvePluginRegistryScopeForCommandPath(["health"])).toBe("channels"); diff --git a/src/cli/plugin-registry-loader.ts b/src/cli/plugin-registry-loader.ts index ff5461f60a0..53f374417af 100644 --- a/src/cli/plugin-registry-loader.ts +++ b/src/cli/plugin-registry-loader.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loggingState } from "../logging/state.js"; import type { PluginRegistryScope } from "./plugin-registry.js"; @@ -17,6 +18,8 @@ export function resolvePluginRegistryScopeForCommandPath( export async function ensureCliPluginRegistryLoaded(params: { scope: PluginRegistryScope; routeLogsToStderr?: boolean; + config?: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; }) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); const previousForceStderr = loggingState.forceConsoleToStderr; @@ -24,7 +27,13 @@ export async function ensureCliPluginRegistryLoaded(params: { loggingState.forceConsoleToStderr = true; } try { - ensurePluginRegistryLoaded({ scope: params.scope }); + ensurePluginRegistryLoaded({ + scope: params.scope, + ...(params.config ? { config: params.config } : {}), + ...(params.activationSourceConfig + ? { activationSourceConfig: params.activationSourceConfig } + : {}), + }); } finally { loggingState.forceConsoleToStderr = previousForceStderr; } diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts new file mode 100644 index 00000000000..dfebd607539 --- /dev/null +++ b/src/commands/agents.providers.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { buildProviderStatusIndex } from "./agents.providers.js"; + +const mocks = vi.hoisted(() => ({ + listChannelPlugins: vi.fn(), + getChannelPlugin: vi.fn(), + normalizeChannelId: vi.fn((value: unknown) => + typeof value === "string" && value.trim().length > 0 ? value : null, + ), + resolveChannelDefaultAccountId: vi.fn(() => "default"), + isChannelVisibleInConfiguredLists: vi.fn(() => true), +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: (...args: Parameters) => + mocks.listChannelPlugins(...args), + getChannelPlugin: (...args: Parameters) => + mocks.getChannelPlugin(...args), + normalizeChannelId: (...args: Parameters) => + mocks.normalizeChannelId(...args), +})); + +vi.mock("../channels/plugins/helpers.js", () => ({ + resolveChannelDefaultAccountId: ( + ...args: Parameters + ) => mocks.resolveChannelDefaultAccountId(...args), +})); + +vi.mock("../channels/plugins/exposure.js", () => ({ + isChannelVisibleInConfiguredLists: ( + ...args: Parameters + ) => mocks.isChannelVisibleInConfiguredLists(...args), +})); + +describe("buildProviderStatusIndex", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("prefers inspectAccount for read-only status surfaces", async () => { + const inspectAccount = vi.fn(() => ({ enabled: true, configured: true, name: "Work" })); + const resolveAccount = vi.fn(() => { + throw new Error("should not be used when inspectAccount exists"); + }); + const plugin = { + id: "slack", + meta: { label: "Slack" }, + config: { + listAccountIds: () => ["work"], + inspectAccount, + resolveAccount, + describeAccount: () => ({ configured: true, enabled: true, linked: true, name: "Work" }), + }, + status: {}, + } as never; + + mocks.listChannelPlugins.mockReturnValue([plugin]); + mocks.getChannelPlugin.mockReturnValue(plugin); + + const map = await buildProviderStatusIndex({} as OpenClawConfig); + + expect(resolveAccount).not.toHaveBeenCalled(); + expect(inspectAccount).toHaveBeenCalledWith({}, "work"); + expect(map.get("slack:work")).toMatchObject({ + provider: "slack", + accountId: "work", + state: "linked", + configured: true, + enabled: true, + name: "Work", + }); + }); + + it("records accounts that throw during read-only resolution as not configured", async () => { + const plugin = { + id: "telegram", + meta: { label: "Telegram" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("unresolved SecretRef"); + }, + }, + status: {}, + } as never; + + mocks.listChannelPlugins.mockReturnValue([plugin]); + mocks.getChannelPlugin.mockReturnValue(plugin); + + await expect(buildProviderStatusIndex({} as OpenClawConfig)).resolves.toEqual( + new Map([ + [ + "telegram:default", + { + provider: "telegram", + accountId: "default", + state: "not configured", + configured: false, + }, + ], + ]), + ); + }); + + it("rethrows unexpected read-only account resolution errors", async () => { + const plugin = { + id: "telegram", + meta: { label: "Telegram" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("plugin crash"); + }, + }, + status: {}, + } as never; + + mocks.listChannelPlugins.mockReturnValue([plugin]); + mocks.getChannelPlugin.mockReturnValue(plugin); + + await expect(buildProviderStatusIndex({} as OpenClawConfig)).rejects.toThrow("plugin crash"); + }); +}); diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index aca40662e14..f2aa438ea48 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -23,6 +23,14 @@ function providerAccountKey(provider: ChannelId, accountId?: string) { return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`; } +function isUnresolvedSecretRefResolutionError(error: unknown): boolean { + return ( + error instanceof Error && + typeof error.message === "string" && + /unresolved SecretRef/i.test(error.message) + ); +} + function formatChannelAccountLabel(params: { provider: ChannelId; accountId: string; @@ -43,6 +51,17 @@ function formatProviderState(entry: ProviderAccountStatus): string { return parts.join(", "); } +async function resolveReadOnlyAccount(params: { + plugin: ReturnType[number]; + cfg: OpenClawConfig; + accountId: string; +}): Promise { + if (params.plugin.config.inspectAccount) { + return await Promise.resolve(params.plugin.config.inspectAccount(params.cfg, params.accountId)); + } + return params.plugin.config.resolveAccount(params.cfg, params.accountId); +} + export async function buildProviderStatusIndex( cfg: OpenClawConfig, ): Promise> { @@ -51,7 +70,24 @@ export async function buildProviderStatusIndex( for (const plugin of listChannelPlugins()) { const accountIds = plugin.config.listAccountIds(cfg); for (const accountId of accountIds) { - const account = plugin.config.resolveAccount(cfg, accountId); + let account: unknown; + try { + account = await resolveReadOnlyAccount({ plugin, cfg, accountId }); + } catch (error) { + if (!isUnresolvedSecretRefResolutionError(error)) { + throw error; + } + map.set(providerAccountKey(plugin.id, accountId), { + provider: plugin.id, + accountId, + state: "not configured", + configured: false, + }); + continue; + } + if (!account) { + continue; + } const snapshot = plugin.config.describeAccount?.(account, cfg); const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 09e3b47cdaf..3d977c15940 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -57,6 +57,51 @@ describe("scanStatusJsonFast", () => { expect(loggingStateRef.forceConsoleToStderr).toBe(false); }); + it("preloads configured channel plugins from the resolved snapshot while preserving source activation config", async () => { + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); + applyStatusScanDefaults(mocks, { + hasConfiguredChannels: true, + sourceConfig: { + channels: { + telegram: { + botToken: { + source: "file", + provider: "vault", + id: "/telegram/bot-token", + }, + }, + }, + } as never, + resolvedConfig: { + marker: "resolved-snapshot", + channels: { + telegram: { + botToken: "resolved-token", + }, + }, + } as never, + }); + + await scanStatusJsonFast({}, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "configured-channels", + config: expect.objectContaining({ marker: "resolved-snapshot" }), + activationSourceConfig: expect.objectContaining({ + channels: expect.objectContaining({ + telegram: expect.objectContaining({ + botToken: expect.objectContaining({ + source: "file", + id: "/telegram/bot-token", + }), + }), + }), + }), + }), + ); + }); + it("skips plugin compatibility loading even when configured channels are present", async () => { mocks.hasPotentialConfiguredChannels.mockReturnValue(true); diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index ee98bddab68..4c4bc7bb686 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -39,6 +39,8 @@ export async function scanStatusJsonWithPolicy( await ensureCliPluginRegistryLoaded({ scope: "configured-channels", routeLogsToStderr: true, + config: overview.cfg, + activationSourceConfig: overview.sourceConfig, }); } diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 4affcd2b6a1..ed03f41a775 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -186,10 +186,12 @@ describe("scanStatus", () => { configureScanStatus({ hasConfiguredChannels: true, sourceConfig: createStatusScanConfig({ + marker: "source-preload", plugins: { enabled: false }, channels: { telegram: { enabled: false } }, }), resolvedConfig: createStatusScanConfig({ + marker: "resolved-preload", plugins: { enabled: false }, channels: { telegram: { enabled: false } }, }), @@ -198,9 +200,13 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "configured-channels", + config: expect.objectContaining({ marker: "resolved-preload" }), + activationSourceConfig: expect.objectContaining({ marker: "source-preload" }), + }), + ); // Verify plugin logs were routed to stderr during loading and restored after expect(loggingStateRef.forceConsoleToStderr).toBe(false); expect(mocks.probeGateway).toHaveBeenCalledWith( @@ -215,9 +221,11 @@ describe("scanStatus", () => { configureScanStatus({ hasConfiguredChannels: true, sourceConfig: createStatusScanConfig({ + marker: "source-env-only", plugins: { enabled: false }, }), resolvedConfig: createStatusScanConfig({ + marker: "resolved-env-only", plugins: { enabled: false }, }), summary: createStatusSummary({ linkChannel: { linked: false } }), @@ -227,8 +235,12 @@ describe("scanStatus", () => { await scanStatus({ json: true }, {} as never); }); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - }); + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "configured-channels", + config: expect.objectContaining({ marker: "resolved-env-only" }), + activationSourceConfig: expect.objectContaining({ marker: "source-env-only" }), + }), + ); }); }); diff --git a/src/config/types.secrets.resolution.test.ts b/src/config/types.secrets.resolution.test.ts new file mode 100644 index 00000000000..3023f2d4e27 --- /dev/null +++ b/src/config/types.secrets.resolution.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { normalizeResolvedSecretInputString, resolveSecretInputString } from "./types.secrets.js"; + +describe("resolveSecretInputString", () => { + it("returns available for non-empty string values", () => { + expect( + resolveSecretInputString({ + value: " abc123 ", + path: "models.providers.openai.apiKey", + }), + ).toEqual({ + status: "available", + value: "abc123", + ref: null, + }); + }); + + it("returns configured_unavailable for unresolved refs in inspect mode", () => { + expect( + resolveSecretInputString({ + value: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + path: "models.providers.openai.apiKey", + mode: "inspect", + }), + ).toEqual({ + status: "configured_unavailable", + value: undefined, + ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }); + }); + + it("uses explicit refValue in inspect mode", () => { + expect( + resolveSecretInputString({ + value: "", + refValue: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + path: "profiles.default.key", + mode: "inspect", + }), + ).toEqual({ + status: "configured_unavailable", + value: undefined, + ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }); + }); + + it("returns missing when no value or ref is configured", () => { + expect( + resolveSecretInputString({ + value: "", + path: "models.providers.openai.apiKey", + mode: "inspect", + }), + ).toEqual({ + status: "missing", + value: undefined, + ref: null, + }); + }); + + it("throws for unresolved refs in strict mode", () => { + expect(() => + resolveSecretInputString({ + value: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + path: "models.providers.openai.apiKey", + }), + ).toThrow(/unresolved SecretRef/); + }); +}); + +describe("normalizeResolvedSecretInputString", () => { + it("keeps strict unresolved-ref behavior", () => { + expect(() => + normalizeResolvedSecretInputString({ + value: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + path: "models.providers.openai.apiKey", + }), + ).toThrow(/unresolved SecretRef/); + }); +}); diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index 37418436040..bbf46a97061 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -19,6 +19,11 @@ export type SecretInput = string | SecretRef; export const DEFAULT_SECRET_PROVIDER_ALIAS = "default"; // pragma: allowlist secret export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/; +export type SecretInputStringResolutionMode = "strict" | "inspect"; +export type SecretInputStringResolution = + | { status: "available"; value: string; ref: null } + | { status: "configured_unavailable"; value: undefined; ref: SecretRef } + | { status: "missing"; value: undefined; ref: null }; type SecretDefaults = { env?: string; file?: string; @@ -120,6 +125,12 @@ function formatSecretRefLabel(ref: SecretRef): string { return `${ref.source}:${ref.provider}:${ref.id}`; } +function createUnresolvedSecretInputError(params: { path: string; ref: SecretRef }): Error { + return new Error( + `${params.path}: unresolved SecretRef "${formatSecretRefLabel(params.ref)}". Resolve this command against an active gateway runtime snapshot before reading it.`, + ); +} + export function assertSecretInputResolved(params: { value: unknown; refValue?: unknown; @@ -134,9 +145,44 @@ export function assertSecretInputResolved(params: { if (!ref) { return; } - throw new Error( - `${params.path}: unresolved SecretRef "${formatSecretRefLabel(ref)}". Resolve this command against an active gateway runtime snapshot before reading it.`, - ); + throw createUnresolvedSecretInputError({ path: params.path, ref }); +} + +export function resolveSecretInputString(params: { + value: unknown; + refValue?: unknown; + defaults?: SecretDefaults; + path: string; + mode?: SecretInputStringResolutionMode; +}): SecretInputStringResolution { + const normalized = normalizeSecretInputString(params.value); + if (normalized) { + return { + status: "available", + value: normalized, + ref: null, + }; + } + const { ref } = resolveSecretInputRef({ + value: params.value, + refValue: params.refValue, + defaults: params.defaults, + }); + if (!ref) { + return { + status: "missing", + value: undefined, + ref: null, + }; + } + if ((params.mode ?? "strict") === "strict") { + throw createUnresolvedSecretInputError({ path: params.path, ref }); + } + return { + status: "configured_unavailable", + value: undefined, + ref, + }; } export function normalizeResolvedSecretInputString(params: { @@ -145,11 +191,13 @@ export function normalizeResolvedSecretInputString(params: { defaults?: SecretDefaults; path: string; }): string | undefined { - const normalized = normalizeSecretInputString(params.value); - if (normalized) { - return normalized; + const resolved = resolveSecretInputString({ + ...params, + mode: "strict", + }); + if (resolved.status === "available") { + return resolved.value; } - assertSecretInputResolved(params); return undefined; } diff --git a/src/plugin-sdk/secret-input.ts b/src/plugin-sdk/secret-input.ts index 867a9d168e5..47e48520c43 100644 --- a/src/plugin-sdk/secret-input.ts +++ b/src/plugin-sdk/secret-input.ts @@ -2,17 +2,23 @@ import { z } from "zod"; import { hasConfiguredSecretInput, isSecretRef, + resolveSecretInputString, normalizeResolvedSecretInputString, normalizeSecretInputString, } from "../config/types.secrets.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { buildSecretInputSchema } from "./secret-input-schema.js"; -export type { SecretInput } from "../config/types.secrets.js"; +export type { + SecretInput, + SecretInputStringResolution, + SecretInputStringResolutionMode, +} from "../config/types.secrets.js"; export { buildSecretInputSchema, hasConfiguredSecretInput, isSecretRef, + resolveSecretInputString, normalizeResolvedSecretInputString, normalizeSecretInput, normalizeSecretInputString, diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index fa1e693a02f..8347139510f 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -369,6 +369,17 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInConfigure: true, includeInAudit: true, }, + { + id: "plugins.entries.exa.config.webSearch.apiKey", + targetType: "plugins.entries.exa.config.webSearch.apiKey", + configFile: "openclaw.json", + pathPattern: "plugins.entries.exa.config.webSearch.apiKey", + secretShape: SECRET_INPUT_SHAPE, + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true, + }, { id: "plugins.entries.xai.config.webSearch.apiKey", targetType: "plugins.entries.xai.config.webSearch.apiKey", diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index 740c1299d8f..0bec69118af 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -42,4 +42,18 @@ describe("secret target registry", () => { expect(target).toBeNull(); }); + + it("includes exa webSearch api key target path", () => { + const target = resolveConfigSecretTargetByPath([ + "plugins", + "entries", + "exa", + "config", + "webSearch", + "apiKey", + ]); + + expect(target).not.toBeNull(); + expect(target?.entry?.id).toBe("plugins.entries.exa.config.webSearch.apiKey"); + }); });