diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 094ad898224..161f4ee0025 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -6,8 +6,9 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-en import { createToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; import { resolveProviderWebSearchPluginConfig } from "openclaw/plugin-sdk/provider-web-search"; import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { applyXaiModelCompat, buildXaiProvider, normalizeXaiModelId } from "./api.js"; +import { applyXaiModelCompat, normalizeXaiModelId } from "./api.js"; import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildXaiProvider } from "./provider-catalog.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { createXaiFastModeWrapper, diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 0ffe69693dc..fd5fece9623 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NON_ENV_SECRETREF_MARKER } from "../../src/agents/model-auth-markers.js"; import { capturePluginRegistration } from "../../src/plugins/captured-registration.js"; import { withEnv } from "../../test/helpers/extensions/env.js"; import xaiPlugin from "./index.js"; @@ -158,6 +159,34 @@ describe("xai web search config resolution", () => { }), ).toEqual({ apiKey: "xai-legacy-fallback", + source: "tools.web.search.grok.apiKey", + mode: "api-key", + }); + }); + + it("returns a managed marker for SecretRef-backed plugin auth fallback", () => { + const captured = capturePluginRegistration(xaiPlugin); + const provider = captured.providers[0]; + expect( + provider?.resolveSyntheticAuth?.({ + config: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: { source: "file", provider: "vault", id: "/xai/api-key" }, + }, + }, + }, + }, + }, + }, + provider: "xai", + providerConfig: undefined, + }), + ).toEqual({ + apiKey: NON_ENV_SECRETREF_MARKER, source: "plugins.entries.xai.config.webSearch.apiKey", mode: "api-key", }); diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index f1d5548ee15..1b276ef4527 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -1,5 +1,6 @@ import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; import type { ModelProviderConfig } from "../config/config.js"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import type { AuthProfileStore } from "./auth-profiles.js"; @@ -22,8 +23,71 @@ vi.mock("../plugins/provider-runtime.js", () => ({ buildProviderMissingAuthMessageWithPlugin: () => undefined, resolveProviderSyntheticAuthWithPlugin: (params: { provider: string; + config?: { + plugins?: { + enabled?: boolean; + entries?: { + xai?: { + enabled?: boolean; + config?: { + webSearch?: { + apiKey?: unknown; + }; + }; + }; + }; + }; + tools?: { + web?: { + search?: { + grok?: { + apiKey?: unknown; + }; + }; + }; + }; + }; context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } }; }) => { + if (params.provider === "xai") { + if ( + params.config?.plugins?.enabled === false || + params.config?.plugins?.entries?.xai?.enabled === false + ) { + return undefined; + } + const pluginApiKey = params.config?.plugins?.entries?.xai?.config?.webSearch?.apiKey; + if (typeof pluginApiKey === "string" && pluginApiKey.trim()) { + return { + apiKey: pluginApiKey.trim(), + source: "plugins.entries.xai.config.webSearch.apiKey", + mode: "api-key" as const, + }; + } + if (pluginApiKey && typeof pluginApiKey === "object") { + return { + apiKey: NON_ENV_SECRETREF_MARKER, + source: "plugins.entries.xai.config.webSearch.apiKey", + mode: "api-key" as const, + }; + } + const legacyApiKey = params.config?.tools?.web?.search?.grok?.apiKey; + if (typeof legacyApiKey === "string" && legacyApiKey.trim()) { + return { + apiKey: legacyApiKey.trim(), + source: "tools.web.search.grok.apiKey", + mode: "api-key" as const, + }; + } + if (legacyApiKey && typeof legacyApiKey === "object") { + return { + apiKey: NON_ENV_SECRETREF_MARKER, + source: "tools.web.search.grok.apiKey", + mode: "api-key" as const, + }; + } + return undefined; + } if (params.provider !== "ollama") { return undefined; } @@ -43,6 +107,10 @@ vi.mock("../plugins/provider-runtime.js", () => ({ }, })); +afterEach(() => { + clearRuntimeConfigSnapshot(); +}); + function createCustomProviderConfig( baseUrl: string, modelId = "llama3", @@ -316,6 +384,76 @@ describe("resolveUsableCustomProviderApiKey", () => { }); }); +describe("resolveApiKeyForProvider", () => { + it("reuses the xai plugin web search key without models.providers.xai", async () => { + const resolved = await resolveApiKeyForProvider({ + provider: "xai", + cfg: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-plugin-fallback-key", // pragma: allowlist secret + }, + }, + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }); + + expect(resolved).toMatchObject({ + apiKey: "xai-plugin-fallback-key", + source: "plugins.entries.xai.config.webSearch.apiKey", + mode: "api-key", + }); + }); + + it("prefers the active runtime snapshot for SecretRef-backed xai fallback auth", async () => { + const sourceConfig = { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: { source: "file", provider: "vault", id: "/xai/api-key" }, + }, + }, + }, + }, + }, + }; + const runtimeConfig = { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: "xai-runtime-key", // pragma: allowlist secret + }, + }, + }, + }, + }, + }; + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + + const resolved = await resolveApiKeyForProvider({ + provider: "xai", + cfg: sourceConfig, + store: { version: 1, profiles: {} }, + }); + + expect(resolved).toMatchObject({ + apiKey: "xai-runtime-key", + source: "plugins.entries.xai.config.webSearch.apiKey", + mode: "api-key", + }); + }); +}); + describe("resolveApiKeyForProvider – synthetic local auth for custom providers", () => { it("synthesizes a local auth marker for custom providers with a local baseUrl and no apiKey", async () => { const auth = await resolveCustomProviderAuth( diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 6a5dd3c4301..3e9e8d9c075 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { type Api, type Model } from "@mariozechner/pi-ai"; import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js"; import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js"; import { coerceSecretRef } from "../config/types.secrets.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; @@ -26,6 +26,7 @@ import { CUSTOM_LOCAL_AUTH_MARKER, isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, + NON_ENV_SECRETREF_MARKER, } from "./model-auth-markers.js"; import { requireApiKey, @@ -189,10 +190,69 @@ function isCustomLocalProviderConfig(providerConfig: ModelProviderConfig): boole ); } +function isManagedSecretRefApiKeyMarker(apiKey: string | undefined): boolean { + return apiKey?.trim() === NON_ENV_SECRETREF_MARKER; +} + +type SyntheticProviderAuthResolution = { + auth?: ResolvedProviderAuth; + blockedOnManagedSecretRef?: boolean; +}; + +function resolveProviderSyntheticRuntimeAuth(params: { + cfg: OpenClawConfig | undefined; + provider: string; +}): SyntheticProviderAuthResolution { + const resolveFromConfig = ( + config: OpenClawConfig | undefined, + ): ResolvedProviderAuth | undefined => { + const providerConfig = resolveProviderConfig(config, params.provider); + return resolveProviderSyntheticAuthWithPlugin({ + provider: params.provider, + config, + context: { + config, + provider: params.provider, + providerConfig, + }, + }); + }; + + const directAuth = resolveFromConfig(params.cfg); + if (!directAuth) { + return {}; + } + if (!isManagedSecretRefApiKeyMarker(directAuth.apiKey)) { + return { auth: directAuth }; + } + + const runtimeConfig = getRuntimeConfigSnapshot(); + if (!runtimeConfig || runtimeConfig === params.cfg) { + return { blockedOnManagedSecretRef: true }; + } + + const runtimeAuth = resolveFromConfig(runtimeConfig); + const runtimeApiKey = runtimeAuth?.apiKey; + if (!runtimeAuth || !runtimeApiKey || isNonSecretApiKeyMarker(runtimeApiKey)) { + return { blockedOnManagedSecretRef: true }; + } + return { + auth: runtimeAuth, + }; +} + function resolveSyntheticLocalProviderAuth(params: { cfg: OpenClawConfig | undefined; provider: string; }): ResolvedProviderAuth | null { + const syntheticProviderAuth = resolveProviderSyntheticRuntimeAuth(params); + if (syntheticProviderAuth.auth) { + return syntheticProviderAuth.auth; + } + if (syntheticProviderAuth.blockedOnManagedSecretRef) { + return null; + } + const providerConfig = resolveProviderConfig(params.cfg, params.provider); if (!providerConfig) { return null; @@ -206,19 +266,6 @@ function resolveSyntheticLocalProviderAuth(params: { return null; } - const pluginSyntheticAuth = resolveProviderSyntheticAuthWithPlugin({ - provider: params.provider, - config: params.cfg, - context: { - config: params.cfg, - provider: params.provider, - providerConfig, - }, - }); - if (pluginSyntheticAuth) { - return pluginSyntheticAuth; - } - const authOverride = resolveProviderAuthOverride(params.cfg, params.provider); if (authOverride && authOverride !== "api-key") { return null; diff --git a/src/agents/models-config.providers.discovery-auth.test.ts b/src/agents/models-config.providers.discovery-auth.test.ts index 4f8db7a7cb5..ea98c7429eb 100644 --- a/src/agents/models-config.providers.discovery-auth.test.ts +++ b/src/agents/models-config.providers.discovery-auth.test.ts @@ -138,6 +138,30 @@ describe("provider discovery auth marker guardrails", () => { expect(providers?.xai?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); }); + it("surfaces xai provider auth from SecretRef-backed plugin web search config", async () => { + const agentDir = await createAgentDirWithAuthProfiles({}); + + const providers = await resolveImplicitProvidersForTest({ + agentDir, + env: {}, + config: { + plugins: { + entries: { + xai: { + config: { + webSearch: { + apiKey: { source: "file", provider: "vault", id: "/xai/apiKey" }, + }, + }, + }, + }, + }, + }, + }); + + expect(providers?.xai?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); + it("surfaces xai provider auth from legacy grok web search config without persisting plaintext", async () => { const agentDir = await createAgentDirWithAuthProfiles({}); @@ -159,4 +183,26 @@ describe("provider discovery auth marker guardrails", () => { expect(providers?.xai?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); }); + + it("surfaces xai provider auth from SecretRef-backed legacy grok web search config", async () => { + const agentDir = await createAgentDirWithAuthProfiles({}); + + const providers = await resolveImplicitProvidersForTest({ + agentDir, + env: {}, + config: { + tools: { + web: { + search: { + grok: { + apiKey: { source: "exec", provider: "vault", id: "providers/xai/token" }, + }, + }, + }, + }, + }, + }); + + expect(providers?.xai?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + }); }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 1e0b6a05de7..984d62755f0 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1132,6 +1132,15 @@ export type ProviderPlugin = { /** * Provider-owned synthetic auth marker. * + * This hook is the canonical seam for provider-specific fallback auth + * derived from plugin/private config. It may return: + * - a runnable literal credential for runtime callers + * - a non-secret marker for managed-secret source config, which is still useful + * for discovery/bootstrap callers + * + * Runtime callers must not treat non-secret markers as runnable credentials; + * they should retry against the active runtime snapshot when available. + * * Use this when the provider can operate without a real secret for certain * configured local/self-hosted cases and wants auth resolution to treat that * config as available.