mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:30:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user