models.authStatus: normalize provider ids + tighten env-backed escape hatch (#67253)

Fix false-positive "missing" alerts on the Model Auth status card:
- Normalize provider ids before expectsOAuth membership check (alias mismatch)
- Apply env-backed escape hatch to auth.profiles loop (not just models.providers)
- Check actual env var resolution for SecretRef apiKeys

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine
2026-04-15 15:29:26 -07:00
committed by GitHub
parent 7694a926c4
commit f2fdb9d125
3 changed files with 173 additions and 18 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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<string>();
const expectsOAuth = new Set<string>();
// 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<string>();
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 };