diff --git a/CHANGELOG.md b/CHANGELOG.md index 8421292c7dd..c5cfe7c4718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Memory-core/dreaming: skip dreaming narrative transcripts from session-store metadata before bootstrap records land so dream diary prompt/prose lines do not pollute session ingestion. (#67315) thanks @jalehman. - Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when `agents.defaults.contextTokens` is the real limit. (#66236) Thanks @ImLukeF. - Dreaming/memory-core: change the default `dreaming.storage.mode` from `inline` to `separate` so Dreaming phase blocks (`## Light Sleep`, `## REM Sleep`) land in `memory/dreaming/{phase}/YYYY-MM-DD.md` instead of being injected into `memory/YYYY-MM-DD.md`. Daily memory files no longer get dominated by structured candidate output, and the daily-ingestion scanner that already strips dream marker blocks no longer has to compete with hundreds of phase-block lines on every run. Operators who want the previous behavior can opt in by setting `plugins.entries.memory-core.config.dreaming.storage.mode: "inline"`. (#66412) Thanks @mjamiv. +- Control UI/Overview: fix false-positive "missing" alerts on the Model Auth status card for aliased providers, env-backed OAuth with auth.profiles, and unresolvable env SecretRefs. (#67253) Thanks @omarshahine. ## 2026.4.15-beta.1 diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 4986a27d7aa..6b7b4f75829 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -296,26 +296,139 @@ describe("models.authStatus", () => { expect(serialised).not.toContain("rt-SECRET-REFRESH"); }); - it("skips env-backed OAuth providers (apiKey set in config) from missing synthesis", async () => { - // Provider configured `auth: "oauth"` with `apiKey` present (env-backed) - // must not be forwarded to buildAuthHealthSummary — doing so would flag - // it as missing even though env auth already satisfies it. + it("skips env-backed OAuth providers (resolvable apiKey) from missing synthesis", async () => { + // Provider configured `auth: "oauth"` with a resolvable apiKey — env + // auth already satisfies it, so forwarding to buildAuthHealthSummary + // would flag it as missing and cry wolf. Inline string is the simplest + // "available" SecretInput for testing. mocks.loadConfig.mockReturnValue({ models: { providers: { - "openai-codex": { auth: "oauth", apiKey: { env: "OPENAI_OAUTH_TOKEN" } }, + "openai-codex": { auth: "oauth", apiKey: "sk-xxxxx" }, }, }, }); await handler(createOptions()); - // When the only configured provider is env-backed, we pass `undefined` - // (meaning "no filter"), not a filter containing it. const call = mocks.buildAuthHealthSummary.mock.calls[0] as unknown as | [{ providers?: string[] }] | undefined; expect(call?.[0]?.providers).toBeUndefined(); }); + it("still flags provider as missing when apiKey env SecretRef points at an unset env var", async () => { + // Config declares an env SecretRef but the referenced env var isn't + // set. We read process.env directly for env-source SecretRefs and fall + // through to the normal missing synthesis so the dashboard surfaces + // the broken config instead of masking it. + delete process.env.MODELS_AUTH_STATUS_TEST_MISSING_KEY; + mocks.loadConfig.mockReturnValue({ + models: { + providers: { + "openai-codex": { + auth: "oauth", + apiKey: { + source: "env", + provider: "default", + id: "MODELS_AUTH_STATUS_TEST_MISSING_KEY", + }, + }, + }, + }, + }); + await handler(createOptions()); + const call = mocks.buildAuthHealthSummary.mock.calls[0] as unknown as + | [{ providers?: string[] }] + | undefined; + expect(call?.[0]?.providers).toEqual(["openai-codex"]); + }); + + it("env SecretRef pointing at a set env var is treated as env-backed", async () => { + process.env.MODELS_AUTH_STATUS_TEST_SET_KEY = "sk-real-value"; + mocks.loadConfig.mockReturnValue({ + models: { + providers: { + "openai-codex": { + auth: "oauth", + apiKey: { + source: "env", + provider: "default", + id: "MODELS_AUTH_STATUS_TEST_SET_KEY", + }, + }, + }, + }, + }); + try { + await handler(createOptions()); + const call = mocks.buildAuthHealthSummary.mock.calls[0] as unknown as + | [{ providers?: string[] }] + | undefined; + expect(call?.[0]?.providers).toBeUndefined(); + } finally { + delete process.env.MODELS_AUTH_STATUS_TEST_SET_KEY; + } + }); + + it("env-backed escape hatch also applies to auth.profiles entries", async () => { + // auth.profiles loop must honor the env-backed skip from the + // models.providers loop — otherwise a provider with resolvable apiKey + // plus a matching auth.profiles entry re-adds itself and triggers the + // false-missing alert we just fixed. + mocks.loadConfig.mockReturnValue({ + models: { + providers: { + "openai-codex": { auth: "oauth", apiKey: "sk-xxxxx" }, + }, + }, + auth: { + profiles: { + "openai-codex:default": { provider: "openai-codex", mode: "oauth" }, + }, + }, + }); + await handler(createOptions()); + const call = mocks.buildAuthHealthSummary.mock.calls[0] as unknown as + | [{ providers?: string[] }] + | undefined; + expect(call?.[0]?.providers).toBeUndefined(); + }); + + it("normalizes expectsOAuth provider ids to match buildAuthHealthSummary", async () => { + // Config uses alias `z.ai`; buildAuthHealthSummary normalizes to `zai`. + // Without normalization, expectsOAuth.has(prov.provider) fires on the + // raw `z.ai` key but prov.provider is `zai`, so the "configured oauth + // but no oauth profile" signal silently skipped the alias path. + mocks.loadConfig.mockReturnValue({ + models: { providers: { "z.ai": { auth: "oauth" } } }, + }); + mocks.buildAuthHealthSummary.mockReturnValue({ + now: 0, + warnAfterMs: 0, + profiles: [], + providers: [ + { + provider: "zai", + status: "static", + profiles: [ + { + profileId: "zai:default", + provider: "zai", + type: "api_key", + status: "static", + source: "store", + label: "zai:default", + }, + ], + }, + ], + }); + const opts = createOptions(); + await handler(opts); + const [, payload] = opts.respond.mock.calls[0] ?? []; + const result = payload as ModelAuthStatusResult; + expect(result.providers[0]?.status).toBe("missing"); + }); + it("flags provider configured auth:oauth but with only api_key profile as missing", async () => { // Config says provider should use OAuth; store has only an api_key // credential (e.g. operator switched modes but forgot to login). diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index eb2743f7b21..b2e7fe2378b 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -8,7 +8,9 @@ import { formatRemainingShort, } from "../../agents/auth-health.js"; import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; +import { normalizeProviderId } from "../../agents/provider-id.js"; import { loadConfig, type OpenClawConfig } from "../../config/config.js"; +import { isSecretRef } from "../../config/types.secrets.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js"; import { PROVIDER_LABELS, resolveUsageProviderId } from "../../infra/provider-usage.shared.js"; import type { UsageProviderId, UsageWindow } from "../../infra/provider-usage.types.js"; @@ -201,6 +203,40 @@ function resolveConfiguredProviders(cfg: OpenClawConfig): { } { const out = new Set(); const expectsOAuth = new Set(); + // Providers with a resolvable apiKey (inline or SecretRef pointing at a + // set env var) are treated as env-backed and skipped from the "missing" + // synthesis. Captured once up front so both the models.providers scan + // and the auth.profiles scan apply the escape hatch consistently. + const envBacked = new Set(); + for (const [id, provider] of Object.entries(cfg.models?.providers ?? {})) { + const apiKey = provider?.apiKey; + if (!id || apiKey === undefined || apiKey === null) { + continue; + } + // Treat as env-backed when the credential is currently resolvable: + // - inline string literal → always resolvable (satisfies auth today) + // - env SecretRef → check process.env for the referenced id (the only + // source we can cheaply verify synchronously on a dashboard read) + // - file/exec SecretRef → conservatively treat as env-backed; we can't + // read files or run commands here without making this a heavy async + // path, and the alternative is crying wolf on valid configs + // A SecretRef pointing at an unset env var falls through to the normal + // "missing" synthesis so the dashboard surfaces the broken config. + let resolvable = false; + if (typeof apiKey === "string" && apiKey.length > 0) { + resolvable = true; + } else if (isSecretRef(apiKey)) { + if (apiKey.source === "env") { + const envValue = process.env[apiKey.id]; + resolvable = typeof envValue === "string" && envValue.length > 0; + } else { + resolvable = true; + } + } + if (resolvable) { + envBacked.add(normalizeProviderId(id)); + } + } for (const [id, provider] of Object.entries(cfg.models?.providers ?? {})) { if (!id) { continue; @@ -211,14 +247,15 @@ function resolveConfiguredProviders(cfg: OpenClawConfig): { if (mode !== "oauth" && mode !== "token") { continue; } - // Env-backed credential escape hatch — see JSDoc. - const hasEnvCredential = provider?.apiKey !== undefined && provider?.apiKey !== null; - if (hasEnvCredential) { + if (envBacked.has(normalizeProviderId(id))) { continue; } out.add(id); if (mode === "oauth") { - expectsOAuth.add(id); + // Store normalized id so lookups against `AuthProviderHealth.provider` + // (which is already normalized by buildAuthHealthSummary) match even + // when the config uses an alias like `z.ai` that normalizes to `zai`. + expectsOAuth.add(normalizeProviderId(id)); } } // auth.profiles entries explicitly opt into the refreshable set via @@ -227,14 +264,18 @@ function resolveConfiguredProviders(cfg: OpenClawConfig): { const provider = profile?.provider; const mode = profile?.mode; if ( - typeof provider === "string" && - provider.length > 0 && - (mode === "oauth" || mode === "token") + typeof provider !== "string" || + provider.length === 0 || + (mode !== "oauth" && mode !== "token") ) { - out.add(provider); - if (mode === "oauth") { - expectsOAuth.add(provider); - } + continue; + } + if (envBacked.has(normalizeProviderId(provider))) { + continue; + } + out.add(provider); + if (mode === "oauth") { + expectsOAuth.add(normalizeProviderId(provider)); } } return { providers: Array.from(out), expectsOAuth };