fix(models): defer live catalog discovery

This commit is contained in:
joshavant
2026-08-13 03:58:47 -05:00
committed by Josh Avant
parent 80d1478b24
commit d83f7b815d
72 changed files with 1739 additions and 527 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ Other selection rules:
- Changing `agents.defaults.model.primary` does not rewrite existing session pins. If status reports `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, run `/model default` to clear the pin.
- CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing only `models.providers.*.models` instead of the full built-in catalog.
- The Control UI model picker asks the Gateway for its configured model view. An explicit `modelPolicy.allow` filters it, including trailing prefix wildcard entries; otherwise it shows configured models plus providers with usable auth. Default and configured picker views hide catalog rows marked `deprecated` or `disabled` unless that exact model is configured as a primary, fallback, utility/tool model, alias/settings key, or exact policy entry. Hidden rows remain selectable by exact `provider/model` ref. The full built-in catalog, including hidden rows, is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`).
- The Control UI starts from the Gateway's prepared configured model view, so opening chat does not start provider discovery. Opening or refreshing a model picker may discover models required by a trailing `provider/*` policy entry. Default and configured picker views hide catalog rows marked `deprecated` or `disabled` unless that exact model is configured as a primary, fallback, utility/tool model, alias/settings key, or exact policy entry. Hidden rows remain selectable by exact `provider/model` ref. The full built-in catalog, including hidden rows, is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`).
- Provider inventory UIs use `models.list` with `view: "provider-config"` to show source-authored `models.providers.*.models` rows without applying picker allowlists.
Full mechanics: [Model failover](/concepts/model-failover).
+9
View File
@@ -1029,6 +1029,15 @@ context.
- `"all"`: full gateway catalog, bypassing `agents.defaults.modelPolicy.allow`. Use for
diagnostics/discovery UIs, not normal model pickers.
Two optional controls separate automatic reads from operator-requested discovery:
- `preparedOnly: true` reuses the current prepared catalog or a completed catalog for that
runtime generation without starting provider discovery. Control UI startup and polling use
this mode.
- `refresh: true` replaces a completed full catalog when the selected view requires discovery.
Concurrent refreshes share one build; a failed refresh leaves the previous completed catalog
available and returns the failure to the caller.
## Exec approvals
- When an exec request needs approval, the gateway broadcasts
@@ -1005,6 +1005,8 @@ describe("validateModelsListParams", () => {
{ view: "default" },
{ view: "configured" },
{ view: "all" },
{ view: "configured", preparedOnly: true },
{ view: "all", refresh: true },
]);
});
@@ -206,6 +206,13 @@ describe("ModelsListParamsSchema", () => {
agentId: "research",
includeProviderCapabilities: true,
},
{
preparedOnly: true,
},
{
refresh: true,
view: "all",
},
);
expectRejected(ModelsListParamsSchema, { view: "provider-route" }, { agentId: "" });
});
@@ -230,6 +230,10 @@ export const AgentsFilesSetResultSchema = closedObject({
export const ModelsListParamsSchema = closedObject({
agentId: Type.Optional(NonEmptyString),
includeProviderCapabilities: Type.Optional(Type.Boolean()),
/** Reuse prepared/cached facts without starting provider discovery. */
preparedOnly: Type.Optional(Type.Boolean()),
/** Force replacement of a completed full-catalog generation. */
refresh: Type.Optional(Type.Boolean()),
view: Type.Optional(
Type.Union([
Type.Literal("default"),
+4 -2
View File
@@ -22,6 +22,7 @@ export type DiscoverAuthStorageOptions = {
ambientCredentials?: Readonly<AgentCredentialMap>;
externalCli?: ExternalCliAuthDiscovery;
inheritedAuthDir?: string;
preparedStore?: AuthProfileStore;
readOnly?: boolean;
skipExternalAuthProfiles?: boolean;
skipCredentials?: boolean;
@@ -95,8 +96,9 @@ export function resolveAgentDiscoveryAuthFacts(
...(options?.externalCli ? { externalCli: options.externalCli } : {}),
...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}),
};
const store =
options?.skipExternalAuthProfiles === true
const store = options?.preparedStore
? options.preparedStore
: options?.skipExternalAuthProfiles === true
? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}),
+54 -2
View File
@@ -6,11 +6,15 @@
import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OAuthCredential } from "./auth-profiles/types.js";
import type { ProviderAuthAliasLookupParams } from "./provider-auth-aliases.js";
const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({
const { readCodexCliCredentialsCachedMock, resolveProviderIdForAuthMock } = vi.hoisted(() => ({
readCodexCliCredentialsCachedMock: vi.fn<
(options?: { allowKeychainPrompt?: boolean }) => OAuthCredential | null
>(() => null),
resolveProviderIdForAuthMock: vi.fn<(provider: string, params?: unknown) => string>(
(provider: string) => (provider === "codex-cli" ? "openai" : provider),
),
}));
vi.mock("./cli-credentials.js", () => ({
@@ -20,7 +24,7 @@ vi.mock("./cli-credentials.js", () => ({
resetCliCredentialCachesForTest: () => undefined,
}));
vi.mock("./provider-auth-aliases.js", () => ({
resolveProviderIdForAuth: (provider: string) => (provider === "codex-cli" ? "openai" : provider),
resolveProviderIdForAuth: resolveProviderIdForAuthMock,
}));
import {
@@ -72,6 +76,10 @@ describe("buildAuthHealthSummary", () => {
beforeEach(() => {
readCodexCliCredentialsCachedMock.mockReset();
readCodexCliCredentialsCachedMock.mockReturnValue(null);
resolveProviderIdForAuthMock.mockReset();
resolveProviderIdForAuthMock.mockImplementation((provider: string) =>
provider === "codex-cli" ? "openai" : provider,
);
});
it("classifies OAuth and API key profiles", () => {
@@ -580,6 +588,50 @@ describe("buildAuthHealthSummary", () => {
},
]);
});
it("uses caller-owned plugin metadata when resolving explicit auth order", () => {
vi.spyOn(Date, "now").mockReturnValue(now);
resolveProviderIdForAuthMock.mockImplementation((provider: string, params?: unknown) => {
const metadata = (params as { metadataSnapshot?: { plugins?: unknown[] } } | undefined)
?.metadataSnapshot;
return provider === "fixture-alias" && metadata?.plugins?.length
? "fixture-provider"
: provider;
});
const metadataSnapshot = {
plugins: [
{
id: "fixture-auth-alias",
origin: "bundled" as const,
providerAuthAliases: { "fixture-alias": "fixture-provider" },
},
],
} as unknown as NonNullable<ProviderAuthAliasLookupParams["metadataSnapshot"]>;
const summary = buildAuthHealthSummary({
cfg: { auth: { order: { "fixture-provider": [] } } },
store: {
version: 1,
profiles: {
"fixture-alias:token": {
type: "token",
provider: "fixture-alias",
token: "fake-token",
},
},
},
authAliasLookupParams: {
metadataSnapshot,
},
});
expect(summary.providers).toMatchObject([
{ provider: "fixture-alias", status: "missing", effectiveProfiles: [] },
]);
expect(resolveProviderIdForAuthMock).toHaveBeenCalledWith(
"fixture-alias",
expect.objectContaining({ metadataSnapshot }),
);
});
});
describe("formatRemainingShort", () => {
+11 -2
View File
@@ -20,7 +20,10 @@ import { resolveAuthProfileDisplayLabel } from "./auth-profiles/display.js";
import { resolveEffectiveOAuthCredential } from "./auth-profiles/effective-oauth.js";
import { resolveAuthProfileOrder } from "./auth-profiles/order.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
import {
type ProviderAuthAliasLookupParams,
resolveProviderIdForAuth,
} from "./provider-auth-aliases.js";
type AuthProfileSource = "store";
@@ -279,6 +282,8 @@ export function buildAuthHealthSummary(params: {
providers?: string[];
runtimeCredentialsByProvider?: ReadonlyMap<string, AuthProfileCredential>;
allowKeychainPrompt?: boolean;
/** Exact prepared metadata for request paths that must not rediscover plugin aliases. */
authAliasLookupParams?: ProviderAuthAliasLookupParams;
}): AuthHealthSummary {
const now = Date.now();
const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS;
@@ -338,7 +343,10 @@ export function buildAuthHealthSummary(params: {
}
const resolveExplicitAuthOrder = (provider: string): string[] | undefined => {
const authProvider = resolveProviderIdForAuth(provider, { config: params.cfg });
const authProvider = resolveProviderIdForAuth(provider, {
config: params.cfg,
...params.authAliasLookupParams,
});
return (
findNormalizedProviderValue(params.store.order, authProvider) ??
findNormalizedProviderValue(params.store.order, provider) ??
@@ -357,6 +365,7 @@ export function buildAuthHealthSummary(params: {
cfg: params.cfg,
store: params.store,
provider: provider.provider,
authAliasLookupParams: params.authAliasLookupParams,
});
const orderedProfiles = ordered
.map((profileId) => provider.profiles.find((profile) => profile.profileId === profileId))
+132 -59
View File
@@ -15,6 +15,11 @@ import {
overlayRuntimeExternalOAuthProfiles,
type RuntimeExternalOAuthProfile,
} from "./oauth-shared.js";
import {
getRuntimeExternalCliProfileIds,
removeRuntimeExternalProfileReferences,
setRuntimeExternalCliProfileIds,
} from "./runtime-external-profile-references.js";
import type { AuthProfileStore } from "./types.js";
type ExternalAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
@@ -81,55 +86,38 @@ function isExternalAuthProfileAllowed(
});
}
function resolveExternalAuthProfileMap(params: {
function resolveExternalAuthProfiles(params: {
store: AuthProfileStore;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
externalCli?: ExternalCliOverlayOptions;
}): ExternalAuthProfileMap {
}): {
profiles: ExternalAuthProfileMap;
pluginProfileIds: ReadonlySet<string>;
runtimeExternalCliProfileIds: ReadonlySet<string>;
} {
const env = params.env ?? process.env;
const resolveProfiles =
resolveExternalAuthProfilesForRuntime ?? resolveExternalAuthProfilesWithPlugins;
const profiles = resolveProfiles({
env,
config: params.externalCli?.config,
workspaceDir: params.workspaceDir,
context: {
config: params.externalCli?.config,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
workspaceDir: undefined,
env,
store: params.store,
},
});
const resolved: ExternalAuthProfileMap = new Map();
const resolved = resolveExternalCliAuthProfileMap(params);
const runtimeExternalCliProfileIds = new Set(
[...resolved.values()]
.filter((profile) => profile.persistence !== "persisted")
.map((profile) => profile.profileId),
);
const pluginProfileIds = new Set<string>();
const explicitProfileIds = resolveExplicitProfileIds(params.externalCli?.externalCliProfileIds);
const cliProfiles =
externalCliSync.resolveExternalCliAuthProfiles?.(params.store, {
allowKeychainPrompt: params.externalCli?.allowKeychainPrompt,
providerIds: params.externalCli?.externalCliProviderIds,
profileIds: explicitProfileIds,
}) ?? [];
for (const profile of cliProfiles) {
if (
!isExternalAuthProfileAllowed(
profile,
params.store,
params.externalCli?.config,
explicitProfileIds,
env,
)
) {
continue;
}
resolved.set(profile.profileId, {
profileId: profile.profileId,
credential: profile.credential,
persistence: profile.persistence ?? "runtime-only",
});
}
for (const rawProfile of profiles) {
const profile = normalizeExternalAuthProfile(rawProfile);
if (!profile) {
@@ -147,26 +135,68 @@ function resolveExternalAuthProfileMap(params: {
continue;
}
resolved.set(profile.profileId, profile);
pluginProfileIds.add(profile.profileId);
runtimeExternalCliProfileIds.delete(profile.profileId);
}
return resolved;
return { profiles: resolved, pluginProfileIds, runtimeExternalCliProfileIds };
}
function resolveAllowedExternalCliAuthProfiles(params: {
store: AuthProfileStore;
env?: NodeJS.ProcessEnv;
externalCli?: ExternalCliOverlayOptions;
}): ProviderExternalAuthProfile[] {
const env = params.env ?? process.env;
const explicitProfileIds = resolveExplicitProfileIds(params.externalCli?.externalCliProfileIds);
const cliProfiles =
externalCliSync.resolveExternalCliAuthProfiles?.(params.store, {
allowKeychainPrompt: params.externalCli?.allowKeychainPrompt,
providerIds: params.externalCli?.externalCliProviderIds,
profileIds: explicitProfileIds,
}) ?? [];
return cliProfiles.flatMap((profile) =>
isExternalAuthProfileAllowed(
profile,
params.store,
params.externalCli?.config,
explicitProfileIds,
env,
)
? [
{
profileId: profile.profileId,
credential: profile.credential,
persistence: profile.persistence ?? "runtime-only",
},
]
: [],
);
}
function resolveExternalCliAuthProfileMap(params: {
store: AuthProfileStore;
env?: NodeJS.ProcessEnv;
externalCli?: ExternalCliOverlayOptions;
}): ExternalAuthProfileMap {
return new Map(
resolveAllowedExternalCliAuthProfiles(params).map((profile) => [profile.profileId, profile]),
);
}
/** List runtime-only and persisted external auth profiles for this store. */
export function listRuntimeExternalAuthProfiles(params: {
store: AuthProfileStore;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
externalCli?: ExternalCliOverlayOptions;
}): RuntimeExternalOAuthProfile[] {
return Array.from(
resolveExternalAuthProfileMap({
resolveExternalAuthProfiles({
store: params.store,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
env: params.env,
externalCli: params.externalCli,
}).values(),
}).profiles.values(),
);
}
@@ -193,22 +223,73 @@ function hasScopedExternalCliOverlay(params?: ExternalCliOverlayOptions): boolea
/** Overlay external auth profiles onto a cloned auth store for runtime use. */
export function overlayExternalAuthProfiles(
store: AuthProfileStore,
params?: {
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
} & ExternalCliOverlayOptions,
params?: { agentDir?: string; env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
): AuthProfileStore {
const profiles = listRuntimeExternalAuthProfiles({
store,
const scoped = hasScopedExternalCliOverlay(params);
const refreshedProfileIds = new Set(
getRuntimeExternalCliProfileIds(store).filter(
(profileId) =>
scoped &&
externalCliSync.isExternalCliAuthProfileInScope({
store,
profileId,
providerIds: params?.externalCliProviderIds,
profileIds: params?.externalCliProfileIds,
}),
),
);
const base = removeRuntimeExternalProfileReferences({ store, profileIds: refreshedProfileIds });
const resolved = resolveExternalAuthProfiles({
store: base,
agentDir: params?.agentDir,
workspaceDir: params?.workspaceDir,
env: params?.env,
externalCli: params,
});
return overlayRuntimeExternalOAuthProfiles(store, profiles, {
runtimeExternalProfileIdsAuthoritative: !hasScopedExternalCliOverlay(params),
const next = overlayRuntimeExternalOAuthProfiles(base, resolved.profiles.values(), {
runtimeExternalProfileIdsAuthoritative: !scoped,
});
const retainedCliProfileIds = getRuntimeExternalCliProfileIds(base).filter(
(profileId) => !resolved.pluginProfileIds.has(profileId),
);
setRuntimeExternalCliProfileIds(next, [
...retainedCliProfileIds,
...resolved.runtimeExternalCliProfileIds,
]);
return next;
}
/** Refresh external CLI credentials without reevaluating lifecycle-owned provider hooks. */
export function overlayExternalCliAuthProfiles(
store: AuthProfileStore,
params?: { env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
): AuthProfileStore {
const scoped = hasScopedExternalCliOverlay(params);
const refreshedProfileIds = new Set(
getRuntimeExternalCliProfileIds(store).filter(
(profileId) =>
scoped &&
externalCliSync.isExternalCliAuthProfileInScope({
store,
profileId,
providerIds: params?.externalCliProviderIds,
profileIds: params?.externalCliProfileIds,
}),
),
);
const base = removeRuntimeExternalProfileReferences({ store, profileIds: refreshedProfileIds });
const profiles = resolveAllowedExternalCliAuthProfiles({
store: base,
env: params?.env,
externalCli: params,
});
const next = overlayRuntimeExternalOAuthProfiles(base, profiles);
setRuntimeExternalCliProfileIds(next, [
...getRuntimeExternalCliProfileIds(base),
...profiles
.filter((profile) => profile.persistence !== "persisted")
.map((profile) => profile.profileId),
]);
return next;
}
/** Persist safe external CLI OAuth profiles that own their local profile slot. */
@@ -219,19 +300,11 @@ export function syncPersistedExternalCliAuthProfiles(
if (!hasPersistableExternalCliSyncCandidate(store, params)) {
return store;
}
const env = params?.env ?? process.env;
const explicitProfileIds = resolveExplicitProfileIds(params?.externalCliProfileIds);
const cliProfiles =
externalCliSync.resolveExternalCliAuthProfiles?.(store, {
allowKeychainPrompt: params?.allowKeychainPrompt,
providerIds: params?.externalCliProviderIds,
profileIds: explicitProfileIds,
}) ?? [];
const persistedProfiles = cliProfiles.filter(
(profile) =>
profile.persistence === "persisted" &&
isExternalAuthProfileAllowed(profile, store, params?.config, explicitProfileIds, env),
);
const persistedProfiles = resolveAllowedExternalCliAuthProfiles({
store,
env: params?.env,
externalCli: params,
}).filter((profile) => profile.persistence === "persisted");
if (persistedProfiles.length === 0) {
return store;
}
@@ -266,6 +266,30 @@ function isExternalCliProviderInScope(params: {
});
}
/** True when a previously resolved built-in CLI profile belongs to this refresh scope. */
export function isExternalCliAuthProfileInScope(params: {
store: AuthProfileStore;
profileId: string;
providerIds?: Iterable<string>;
profileIds?: Iterable<string>;
}): boolean {
const credential = params.store.profiles[params.profileId];
const providerConfig = resolveExternalCliSyncProvider({
profileId: params.profileId,
...(credential?.type === "oauth" ? { credential } : {}),
});
return providerConfig
? isExternalCliProviderInScope({
providerConfig,
store: params.store,
options: {
...(params.providerIds ? { providerIds: params.providerIds } : {}),
...(params.profileIds ? { profileIds: params.profileIds } : {}),
},
})
: false;
}
function listScopedExternalCliProfileIds(params: {
providerConfig: ExternalCliSyncProvider;
store: AuthProfileStore;
+106 -2
View File
@@ -8,16 +8,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderExternalAuthProfile } from "../../plugins/types.js";
import { resolveAgentCredentialMapFromStore } from "../agent-auth-credentials.js";
import { addEnvBackedAgentCredentials } from "../agent-auth-discovery-core.js";
import { overlayExternalAuthProfiles } from "./external-auth.js";
import { overlayExternalAuthProfiles, overlayExternalCliAuthProfiles } from "./external-auth.js";
import { testing } from "./external-auth.test-support.js";
import { readExternalCliBootstrapCredential } from "./external-cli-sync.js";
import { getRuntimeExternalCliProfileIds } from "./runtime-external-profile-references.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
registerRuntimeAuthProfileStoreMutationListener,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./runtime-snapshots.js";
import { ensureAuthProfileStore, getRuntimeAuthProfileStoreSnapshot } from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
import type { AuthProfileStore, OAuthCredential, RuntimeAuthProfileStore } from "./types.js";
const resolveExternalAuthProfilesWithPluginsMock = vi.fn<
(params: unknown) => ProviderExternalAuthProfile[]
@@ -113,6 +114,109 @@ describe("auth external oauth helpers", () => {
expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledTimes(1);
});
it("refreshes and removes a prepared built-in CLI profile authoritatively", () => {
readCodexCliCredentialsCachedMock.mockReturnValueOnce(
createCredential({ access: "startup-access", refresh: "startup-refresh" }),
);
const startup = overlayExternalAuthProfiles(
{
...createStore(),
order: { openai: ["openai:default"] },
lastGood: { openai: "openai:default" },
usageStats: { "openai:default": { lastUsed: 1 } },
},
{
externalCliProviderIds: ["openai"],
},
);
expect(getRuntimeExternalCliProfileIds(startup)).toEqual(["openai:default"]);
const retained = overlayExternalAuthProfiles(startup);
expect(retained.profiles["openai:default"]).toMatchObject({
access: "startup-access",
refresh: "startup-refresh",
});
expect(getRuntimeExternalCliProfileIds(retained)).toEqual(["openai:default"]);
readCodexCliCredentialsCachedMock.mockReturnValueOnce(
createCredential({ access: "rotated-access", refresh: "rotated-refresh" }),
);
const rotated = overlayExternalCliAuthProfiles(retained, {
externalCliProfileIds: ["openai:default"],
});
expect(rotated.profiles["openai:default"]).toMatchObject({
access: "rotated-access",
refresh: "rotated-refresh",
});
expect(getRuntimeExternalCliProfileIds(rotated)).toEqual(["openai:default"]);
readCodexCliCredentialsCachedMock.mockReturnValueOnce(null);
const loggedOut = overlayExternalCliAuthProfiles(rotated, {
externalCliProviderIds: ["openai"],
});
expect(loggedOut.profiles["openai:default"]).toBeUndefined();
expect(loggedOut.order).toBeUndefined();
expect(loggedOut.lastGood).toBeUndefined();
expect(loggedOut.usageStats).toBeUndefined();
expect(getRuntimeExternalCliProfileIds(loggedOut)).toEqual([]);
});
it("preserves a plugin winner that collides with a built-in CLI profile id", () => {
readCodexCliCredentialsCachedMock.mockReturnValue(
createCredential({ access: "cli-access", refresh: "cli-refresh" }),
);
resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([
{
profileId: "openai:default",
credential: createCredential({ access: "plugin-access", refresh: "plugin-refresh" }),
},
]);
const prepared = overlayExternalAuthProfiles(createStore(), {
externalCliProviderIds: ["openai"],
});
expect(prepared.profiles["openai:default"]).toMatchObject({
access: "plugin-access",
refresh: "plugin-refresh",
});
expect(prepared.runtimeExternalProfileIds).toEqual(["openai:default"]);
expect(getRuntimeExternalCliProfileIds(prepared)).toEqual([]);
resolveExternalAuthProfilesWithPluginsMock.mockClear();
const refreshed = overlayExternalCliAuthProfiles(prepared, {
externalCliProviderIds: ["openai"],
});
expect(refreshed.profiles["openai:default"]).toMatchObject({
access: "plugin-access",
refresh: "plugin-refresh",
});
expect(resolveExternalAuthProfilesWithPluginsMock).not.toHaveBeenCalled();
});
it("replaces CLI provenance only inside the requested refresh scope", () => {
const store: RuntimeAuthProfileStore = {
...createStore({
"openai:default": createCredential(),
"claude-cli:default": createCredential({
provider: "claude-cli",
access: "claude-access",
refresh: "claude-refresh",
}),
}),
runtimeExternalProfileIds: ["claude-cli:default", "openai:default"],
runtimeExternalCliProfileIds: ["claude-cli:default", "openai:default"],
};
const refreshed = overlayExternalCliAuthProfiles(store, {
externalCliProfileIds: ["openai:default"],
});
expect(refreshed.profiles["openai:default"]).toBeUndefined();
expect(refreshed.profiles["claude-cli:default"]).toMatchObject({
access: "claude-access",
});
expect(getRuntimeExternalCliProfileIds(refreshed)).toEqual(["claude-cli:default"]);
});
it("publishes a usable scoped CLI bootstrap into the runtime auth owner", () => {
const agentDir = "/tmp/openclaw-external-oauth-publication";
readCodexCliCredentialsCachedMock.mockReturnValue(
+17 -3
View File
@@ -129,6 +129,7 @@ function isConfiguredProfileCompatibleWithAuthProvider(params: {
function listProfilesCompatibleWithAuthProvider(params: {
cfg?: OpenClawConfig;
authAliasLookupParams?: ProviderAuthAliasLookupParams;
store: AuthProfileStore;
provider: string;
providerAuthKey: string;
@@ -140,6 +141,7 @@ function listProfilesCompatibleWithAuthProvider(params: {
.filter(([, credential]) =>
isCredentialProviderCompatibleWithAuthProvider({
cfg: params.cfg,
authAliasLookupParams: params.authAliasLookupParams,
providerAuthKey: params.providerAuthKey,
credential,
}),
@@ -263,6 +265,8 @@ type ResolveAuthProfileOrderParams = {
cfg?: OpenClawConfig;
store: AuthProfileStore;
provider: string;
/** Exact prepared metadata for request paths that must not rediscover plugin aliases. */
authAliasLookupParams?: ProviderAuthAliasLookupParams;
preferredProfile?: string;
/** Model that will consume the profile, for model-scoped cooldowns. */
forModel?: string;
@@ -282,7 +286,10 @@ export function resolveAuthProfileOrderWithMetadata(
): AuthProfileOrderResolution {
const { cfg, store, provider, preferredProfile, forModel } = params;
const providerKey = normalizeProviderId(provider);
const providerAuthKey = resolveProviderIdForAuth(provider, { config: cfg });
const providerAuthKey = resolveProviderIdForAuth(provider, {
config: cfg,
...params.authAliasLookupParams,
});
const now = Date.now();
// Clear any cooldowns that have expired since the last check so profiles
@@ -316,6 +323,7 @@ export function resolveAuthProfileOrderWithMetadata(
.filter(([profileId, profile]) =>
isConfiguredProfileCompatibleWithAuthProvider({
cfg,
authAliasLookupParams: params.authAliasLookupParams,
providerAuthKey,
provider: profile.provider,
mode: profile.mode,
@@ -326,6 +334,7 @@ export function resolveAuthProfileOrderWithMetadata(
: [];
const storeProfiles = listProfilesCompatibleWithAuthProvider({
cfg,
authAliasLookupParams: params.authAliasLookupParams,
store,
provider,
providerAuthKey,
@@ -335,6 +344,7 @@ export function resolveAuthProfileOrderWithMetadata(
? storeProfiles.filter((profileId) =>
isNativeCredentialProviderCompatibleWithAuthProvider({
cfg,
authAliasLookupParams: params.authAliasLookupParams,
providerAuthKey,
credential: store.profiles[profileId],
}),
@@ -357,6 +367,7 @@ export function resolveAuthProfileOrderWithMetadata(
const isValidProfile = (profileId: string): boolean => {
const eligibility = resolveAuthProfileEligibility({
cfg,
authAliasLookupParams: params.authAliasLookupParams,
store,
provider,
profileId,
@@ -445,6 +456,7 @@ function resolveAuthOrder(
function isNativeCredentialProviderCompatibleWithAuthProvider(params: {
cfg?: OpenClawConfig;
authAliasLookupParams?: ProviderAuthAliasLookupParams;
providerAuthKey: string;
credential: AuthProfileCredential | undefined;
}): boolean {
@@ -452,8 +464,10 @@ function isNativeCredentialProviderCompatibleWithAuthProvider(params: {
return false;
}
return (
resolveProviderIdForAuth(params.credential.provider, { config: params.cfg }) ===
params.providerAuthKey
resolveProviderIdForAuth(params.credential.provider, {
config: params.cfg,
...params.authAliasLookupParams,
}) === params.providerAuthKey
);
}
@@ -15,7 +15,8 @@ import {
coercePersistedAuthProfileStore,
mergeAuthProfileStores,
} from "./persisted.js";
import type { AuthProfileStore } from "./types.js";
import { getRuntimeExternalCliProfileIds } from "./runtime-external-profile-references.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
describe("persisted auth profile boundary", () => {
it("normalizes malformed persisted credentials and state before runtime use", () => {
@@ -428,6 +429,47 @@ describe("persisted auth profile boundary", () => {
expect(merged.order?.anthropic).toEqual([profileId]);
expect(merged.lastGood?.anthropic).toBe(profileId);
});
it("carries built-in CLI provenance only with the winning external profile", () => {
const profileId = "openai:default";
const base: RuntimeAuthProfileStore = {
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: [profileId],
runtimeExternalCliProfileIds: [profileId],
profiles: {
[profileId]: {
type: "oauth",
provider: "openai",
access: "cli-access",
refresh: "cli-refresh",
expires: 1,
},
},
};
const inherited = mergeAuthProfileStores(
base,
{ version: AUTH_STORE_VERSION, profiles: {} },
{ preserveBaseRuntimeExternalProfiles: true },
);
expect(getRuntimeExternalCliProfileIds(inherited)).toEqual([profileId]);
const pluginOverride: RuntimeAuthProfileStore = {
version: AUTH_STORE_VERSION,
runtimeExternalProfileIds: [profileId],
profiles: {
[profileId]: {
type: "oauth",
provider: "openai",
access: "plugin-access",
refresh: "plugin-refresh",
expires: 2,
},
},
};
const collided = mergeAuthProfileStores(base, pluginOverride);
expect(collided.profiles[profileId]).toMatchObject({ access: "plugin-access" });
expect(getRuntimeExternalCliProfileIds(collided)).toEqual([]);
});
});
describe("applyLegacyAuthStore", () => {
+24 -3
View File
@@ -19,6 +19,10 @@ import {
normalizeAuthEmailToken,
normalizeAuthIdentityToken,
} from "./oauth-shared.js";
import {
getRuntimeExternalCliProfileIds,
setRuntimeExternalCliProfileIds,
} from "./runtime-external-profile-references.js";
import { readPersistedAuthProfileStoreRaw } from "./sqlite.js";
import {
coerceAuthProfileState,
@@ -555,7 +559,7 @@ function replaceMergedProfileReferences(params: {
}
}
return {
const next = {
...store,
profiles,
...(order && Object.keys(order).length > 0 ? { order } : { order: undefined }),
@@ -564,6 +568,13 @@ function replaceMergedProfileReferences(params: {
? { usageStats }
: { usageStats: undefined }),
};
setRuntimeExternalCliProfileIds(
next,
getRuntimeExternalCliProfileIds(store).map(
(profileId) => replacements.get(profileId) ?? profileId,
),
);
return next;
}
function reconcileMainStoreOAuthProfileDrift(params: {
@@ -609,7 +620,8 @@ export function mergeAuthProfileStores(
override.runtimeLocalProfileIds === undefined &&
override.runtimeInheritsMainState === undefined &&
override.runtimeExternalProfileIds === undefined &&
override.runtimeExternalProfileIdsAuthoritative !== true
override.runtimeExternalProfileIdsAuthoritative !== true &&
getRuntimeExternalCliProfileIds(override).length === 0
) {
return base;
}
@@ -705,7 +717,14 @@ export function mergeAuthProfileStores(
: {}),
}
: {};
return reconcileMainStoreOAuthProfileDrift({
const runtimeExternalCliProfileIds = [
...getRuntimeExternalCliProfileIds(base).filter(
(profileId) =>
!overrideProfileIds.has(profileId) && !removedRuntimeExternalProfileIds.has(profileId),
),
...getRuntimeExternalCliProfileIds(override),
];
const result = reconcileMainStoreOAuthProfileDrift({
base,
override,
merged: {
@@ -720,6 +739,8 @@ export function mergeAuthProfileStores(
...runtimeExternalProfileMetadata,
},
}) as RuntimeAuthProfileStore;
setRuntimeExternalCliProfileIds(result, runtimeExternalCliProfileIds);
return result;
}
/** Builds the persisted secrets store, stripping resolved literals when refs exist. */
+27
View File
@@ -599,6 +599,33 @@ describe("promoteAuthProfileInOrder", () => {
});
});
it("does not persist built-in CLI ownership metadata", async () => {
await withAuthProfileTestState("openclaw-auth-cli-provenance-", async ({ agentDir }) => {
const profileId = "openai:default";
const runtimeStore: RuntimeAuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "oauth",
provider: "openai",
access: "external-access",
refresh: "external-refresh",
expires: Date.now() + 60_000,
},
},
runtimeExternalProfileIds: [profileId],
runtimeExternalCliProfileIds: [profileId],
};
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: runtimeStore }]);
saveAuthProfileStore(runtimeStore, agentDir);
const persisted = loadPersistedAuthProfileStore(agentDir);
expect(persisted).not.toHaveProperty("runtimeExternalCliProfileIds");
expect(persisted?.profiles[profileId]).toBeUndefined();
});
});
it.each(["before save", "before publication"] as const)(
"preserves a runtime-only OAuth mutation %s",
async (mutationTiming) => {
@@ -0,0 +1,160 @@
import { isDeepStrictEqual } from "node:util";
import { cloneAuthProfileStore } from "./clone.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
type RuntimeExternalCliStore = AuthProfileStore &
Pick<RuntimeAuthProfileStore, "runtimeExternalCliProfileIds">;
export function getRuntimeExternalCliProfileIds(store: AuthProfileStore): readonly string[] {
return (store as RuntimeExternalCliStore).runtimeExternalCliProfileIds ?? [];
}
export function setRuntimeExternalCliProfileIds(
store: AuthProfileStore,
profileIds: Iterable<string>,
): void {
const ids = [...new Set(profileIds)].filter((profileId) => store.profiles[profileId]).toSorted();
(store as RuntimeExternalCliStore).runtimeExternalCliProfileIds =
ids.length > 0 ? ids : undefined;
}
export function removeRuntimeExternalProfileReferences(params: {
store: AuthProfileStore;
profileIds: ReadonlySet<string>;
}): AuthProfileStore {
if (params.profileIds.size === 0) {
return params.store;
}
const next = cloneAuthProfileStore(params.store);
for (const profileId of params.profileIds) {
delete next.profiles[profileId];
if (next.usageStats) {
delete next.usageStats[profileId];
}
}
if (next.order) {
const order = Object.fromEntries(
Object.entries(next.order)
.map(
([provider, profileIds]) =>
[
provider,
profileIds.filter((profileId) => !params.profileIds.has(profileId)),
] as const,
)
.filter(([, profileIds]) => profileIds.length > 0),
);
next.order = Object.keys(order).length > 0 ? order : undefined;
}
if (next.lastGood) {
const lastGood = Object.fromEntries(
Object.entries(next.lastGood).filter(([, profileId]) => !params.profileIds.has(profileId)),
);
next.lastGood = Object.keys(lastGood).length > 0 ? lastGood : undefined;
}
if (next.usageStats && Object.keys(next.usageStats).length === 0) {
next.usageStats = undefined;
}
next.runtimePersistedProfileIds = next.runtimePersistedProfileIds?.filter(
(profileId) => !params.profileIds.has(profileId),
);
if (next.runtimePersistedProfileIds?.length === 0) {
next.runtimePersistedProfileIds = undefined;
}
next.runtimeExternalProfileIds = next.runtimeExternalProfileIds?.filter(
(profileId) => !params.profileIds.has(profileId),
);
if (
next.runtimeExternalProfileIds?.length === 0 &&
next.runtimeExternalProfileIdsAuthoritative !== true
) {
next.runtimeExternalProfileIds = undefined;
}
setRuntimeExternalCliProfileIds(
next,
getRuntimeExternalCliProfileIds(next).filter((profileId) => !params.profileIds.has(profileId)),
);
return next;
}
/** Carries lifecycle-owned external profiles across a durable-store refresh. */
export function mergeRuntimeExternalProfileReferences(params: {
next: AuthProfileStore;
existing: AuthProfileStore;
}): AuthProfileStore {
const runtimeExternalProfileIds = new Set(params.existing.runtimeExternalProfileIds ?? []);
if (params.next.runtimeExternalProfileIdsAuthoritative === true) {
return params.next;
}
if (runtimeExternalProfileIds.size === 0) {
return params.next;
}
const merged = cloneAuthProfileStore(params.next);
const mergedRuntimeExternalProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
const mergedRuntimeExternalCliProfileIds = new Set(getRuntimeExternalCliProfileIds(merged));
const existingRuntimeExternalCliProfileIds = new Set(
getRuntimeExternalCliProfileIds(params.existing),
);
const backfilledRuntimeExternalProfileIds = new Set<string>();
for (const profileId of runtimeExternalProfileIds) {
const existingCredential = params.existing.profiles[profileId];
const nextCredential = merged.profiles[profileId];
if (nextCredential) {
if (
mergedRuntimeExternalProfileIds.has(profileId) ||
(existingCredential && isDeepStrictEqual(nextCredential, existingCredential))
) {
mergedRuntimeExternalProfileIds.add(profileId);
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
mergedRuntimeExternalCliProfileIds.add(profileId);
}
}
continue;
}
if (!existingCredential) {
continue;
}
merged.profiles[profileId] = existingCredential;
mergedRuntimeExternalProfileIds.add(profileId);
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
mergedRuntimeExternalCliProfileIds.add(profileId);
}
backfilledRuntimeExternalProfileIds.add(profileId);
if (params.existing.usageStats?.[profileId]) {
merged.usageStats = {
...merged.usageStats,
[profileId]: params.existing.usageStats[profileId],
};
}
}
for (const [provider, profileIds] of Object.entries(params.existing.order ?? {})) {
const externalProfileIds = profileIds.filter((profileId) =>
backfilledRuntimeExternalProfileIds.has(profileId),
);
if (externalProfileIds.length === 0 || merged.order?.[provider]) {
continue;
}
merged.order = {
...merged.order,
[provider]: externalProfileIds,
};
}
for (const [provider, profileId] of Object.entries(params.existing.lastGood ?? {})) {
if (!backfilledRuntimeExternalProfileIds.has(profileId) || merged.lastGood?.[provider]) {
continue;
}
merged.lastGood = {
...merged.lastGood,
[provider]: profileId,
};
}
const profileIds = [...mergedRuntimeExternalProfileIds].toSorted();
merged.runtimeExternalProfileIds =
profileIds.length > 0 || params.existing.runtimeExternalProfileIdsAuthoritative === true
? profileIds
: undefined;
merged.runtimeExternalProfileIdsAuthoritative =
params.existing.runtimeExternalProfileIdsAuthoritative === true ? true : undefined;
setRuntimeExternalCliProfileIds(merged, mergedRuntimeExternalCliProfileIds);
return merged;
}
@@ -25,7 +25,7 @@ import {
setRuntimeAuthProfileStoreSnapshot,
} from "./runtime-snapshots.js";
import { testing } from "./runtime-snapshots.test-support.js";
import type { AuthProfileStore } from "./types.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
function createStore(access: string): AuthProfileStore {
return {
@@ -240,6 +240,36 @@ describe("runtime auth profile snapshots", () => {
}
});
it("notifies when identical external credentials change from CLI to plugin ownership", () => {
const agentDir = "/tmp/openclaw-auth-runtime-external-owner";
const store: RuntimeAuthProfileStore = {
...createStore("same-credential"),
runtimeExternalProfileIds: ["openai:default"],
runtimeExternalCliProfileIds: ["openai:default"],
};
setRuntimeAuthProfileStoreSnapshot(store, agentDir);
const listener = vi.fn();
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
try {
const pluginOwned: RuntimeAuthProfileStore = {
...store,
runtimeExternalCliProfileIds: undefined,
};
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: pluginOwned,
},
]);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith({ affectsInheritedStores: true });
} finally {
unregister();
clearRuntimeAuthProfileStoreSnapshots();
}
});
it("notifies when an empty runtime snapshot starts or stops shadowing persisted auth", () => {
const agentDir = "/tmp/openclaw-auth-runtime-empty-owner";
const listener = vi.fn();
@@ -144,6 +144,7 @@ function ownerState(
| "runtimePersistedProfileIds"
| "runtimeExternalProfileIds"
| "runtimeExternalProfileIdsAuthoritative"
| "runtimeExternalCliProfileIds"
| "runtimeLocalProfileIds"
| "runtimeInheritsMainState"
>
@@ -157,6 +158,7 @@ function ownerState(
runtimePersistedProfileIds: store.runtimePersistedProfileIds,
runtimeExternalProfileIds: store.runtimeExternalProfileIds,
runtimeExternalProfileIdsAuthoritative: store.runtimeExternalProfileIdsAuthoritative,
runtimeExternalCliProfileIds: store.runtimeExternalCliProfileIds,
runtimeLocalProfileIds: store.runtimeLocalProfileIds,
runtimeInheritsMainState: store.runtimeInheritsMainState,
};
+28 -75
View File
@@ -44,6 +44,11 @@ import {
loadPersistedAuthProfileStore,
mergeAuthProfileStores,
} from "./persisted.js";
import {
getRuntimeExternalCliProfileIds,
mergeRuntimeExternalProfileReferences,
setRuntimeExternalCliProfileIds,
} from "./runtime-external-profile-references.js";
import {
clearRuntimeAuthProfileStoreSnapshotCore,
clearRuntimeAuthProfileStoreSnapshots,
@@ -545,6 +550,10 @@ function pruneAuthProfileStoreReferences(
store.runtimeExternalProfileIds = store.runtimeExternalProfileIds
?.filter((profileId) => keptProfileIds.has(profileId))
.toSorted();
setRuntimeExternalCliProfileIds(
store,
getRuntimeExternalCliProfileIds(store).filter((profileId) => keptProfileIds.has(profileId)),
);
if (
store.runtimeExternalProfileIds?.length === 0 &&
store.runtimeExternalProfileIdsAuthoritative !== true
@@ -616,6 +625,7 @@ function buildLocalAuthProfileStoreForSave(params: {
if (params.options?.filterExternalAuthProfiles !== false) {
localStore.runtimeExternalProfileIds = undefined;
localStore.runtimeExternalProfileIdsAuthoritative = undefined;
setRuntimeExternalCliProfileIds(localStore, []);
}
return localStore;
}
@@ -646,6 +656,7 @@ function stripRuntimeExternalProfileMetadata(store: AuthProfileStore): AuthProfi
const stripped = { ...store };
delete stripped.runtimeExternalProfileIds;
delete stripped.runtimeExternalProfileIdsAuthoritative;
setRuntimeExternalCliProfileIds(stripped, []);
return stripped;
}
@@ -727,81 +738,12 @@ function setRuntimeExternalProfileMetadata(params: {
params.store.runtimeExternalProfileIds =
profileIds.length > 0 || params.authoritative ? profileIds : undefined;
params.store.runtimeExternalProfileIdsAuthoritative = params.authoritative ? true : undefined;
}
function mergeRuntimeExternalProfileReferences(params: {
next: AuthProfileStore;
existing: AuthProfileStore;
}): AuthProfileStore {
const runtimeExternalProfileIds = new Set(params.existing.runtimeExternalProfileIds ?? []);
if (params.next.runtimeExternalProfileIdsAuthoritative === true) {
return params.next;
}
if (runtimeExternalProfileIds.size === 0) {
return params.next;
}
const merged = cloneAuthProfileStore(params.next);
const mergedRuntimeExternalProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
const backfilledRuntimeExternalProfileIds = new Set<string>();
for (const profileId of runtimeExternalProfileIds) {
const existingCredential = params.existing.profiles[profileId];
const nextCredential = merged.profiles[profileId];
if (nextCredential) {
if (
mergedRuntimeExternalProfileIds.has(profileId) ||
(existingCredential && isDeepStrictEqual(nextCredential, existingCredential))
) {
mergedRuntimeExternalProfileIds.add(profileId);
}
continue;
}
if (!existingCredential) {
continue;
}
merged.profiles[profileId] = existingCredential;
mergedRuntimeExternalProfileIds.add(profileId);
backfilledRuntimeExternalProfileIds.add(profileId);
if (params.existing.usageStats?.[profileId]) {
merged.usageStats = {
...merged.usageStats,
[profileId]: params.existing.usageStats[profileId],
};
}
}
for (const [provider, profileIds] of Object.entries(params.existing.order ?? {})) {
const externalProfileIds = profileIds.filter((profileId) =>
backfilledRuntimeExternalProfileIds.has(profileId),
);
if (externalProfileIds.length === 0) {
continue;
}
if (merged.order?.[provider]) {
continue;
}
const existingOrder = merged.order?.[provider] ?? [];
merged.order = {
...merged.order,
[provider]: [
...externalProfileIds,
...existingOrder.filter((profileId) => !externalProfileIds.includes(profileId)),
],
};
}
for (const [provider, profileId] of Object.entries(params.existing.lastGood ?? {})) {
if (!backfilledRuntimeExternalProfileIds.has(profileId) || merged.lastGood?.[provider]) {
continue;
}
merged.lastGood = {
...merged.lastGood,
[provider]: profileId,
};
}
setRuntimeExternalProfileMetadata({
store: merged,
profileIds: mergedRuntimeExternalProfileIds,
authoritative: params.existing.runtimeExternalProfileIdsAuthoritative === true,
});
return merged;
setRuntimeExternalCliProfileIds(
params.store,
getRuntimeExternalCliProfileIds(params.store).filter((profileId) =>
params.profileIds.has(profileId),
),
);
}
export function preserveResolvedSecretBackedCredentials(params: {
@@ -844,6 +786,10 @@ function mergeRuntimeExternalProfileState(params: {
}
const merged = cloneAuthProfileStore(params.next);
const mergedRuntimeProfileIds = new Set(merged.runtimeExternalProfileIds ?? []);
const existingRuntimeExternalCliProfileIds = new Set(
getRuntimeExternalCliProfileIds(params.existing),
);
const mergedRuntimeExternalCliProfileIds = new Set(getRuntimeExternalCliProfileIds(merged));
const activeRuntimeProfileIds = new Set<string>();
const nextRuntimeProfileIdsAuthoritative =
params.next.runtimeExternalProfileIdsAuthoritative === true;
@@ -863,12 +809,18 @@ function mergeRuntimeExternalProfileState(params: {
) {
mergedRuntimeProfileIds.add(profileId);
activeRuntimeProfileIds.add(profileId);
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
mergedRuntimeExternalCliProfileIds.add(profileId);
}
}
continue;
}
merged.profiles[profileId] = existingCredential;
mergedRuntimeProfileIds.add(profileId);
activeRuntimeProfileIds.add(profileId);
if (existingRuntimeExternalCliProfileIds.has(profileId)) {
mergedRuntimeExternalCliProfileIds.add(profileId);
}
}
if (activeRuntimeProfileIds.size === 0) {
return params.next;
@@ -907,6 +859,7 @@ function mergeRuntimeExternalProfileState(params: {
profileIds: mergedRuntimeProfileIds,
authoritative: params.existing.runtimeExternalProfileIdsAuthoritative === true,
});
setRuntimeExternalCliProfileIds(merged, mergedRuntimeExternalCliProfileIds);
return merged;
}
+2
View File
@@ -155,6 +155,8 @@ export type AuthProfileStore = AuthProfileSecretsStore &
/** Internal effective-store ownership metadata; never exposed through the plugin SDK. */
export type RuntimeAuthProfileStore = AuthProfileStore & {
/** Runtime-only built-in CLI winners; internal provenance, never exposed or persisted. */
runtimeExternalCliProfileIds?: string[];
runtimeLocalProfileIds?: string[];
runtimeInheritsMainState?: boolean;
};
+34
View File
@@ -101,6 +101,40 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
});
it("keeps automatic wildcard reads on prepared facts", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadPreparedModelCatalogSnapshotForBrowse({
cfg: config({ providerWildcard: true }),
view: "configured",
preparedOnly: true,
loadCatalog,
}),
).resolves.toBe(readOnlyCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: true });
});
it("forwards explicit refresh to full discovery", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
);
await expect(
loadPreparedModelCatalogSnapshotForBrowse({
cfg: config(),
view: "all",
refresh: true,
loadCatalog,
}),
).resolves.toBe(fullCatalog);
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false, refresh: true });
});
it("uses the read-only catalog for provider-config views without picker allowlists", async () => {
const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) =>
readOnly ? readOnlyCatalog : fullCatalog,
+23 -9
View File
@@ -75,18 +75,22 @@ async function loadCatalogForBrowse<T>(params: {
cfg: OpenClawConfig;
agentId?: string;
view?: ModelCatalogBrowseView;
loadCatalog: (params: { readOnly: boolean }) => Promise<T>;
preparedOnly?: boolean;
refresh?: boolean;
loadCatalog: (params: { readOnly: boolean; refresh?: boolean }) => Promise<T>;
empty: T;
timeoutFullDiscovery?: boolean;
timeoutMs?: number;
onTimeout?: (timeoutMs: number) => void;
}): Promise<T> {
const view = params.view ?? "default";
const requiresFullDiscovery = modelCatalogBrowseRequiresFullDiscovery({
cfg: params.cfg,
agentId: params.agentId,
view,
});
const requiresFullDiscovery =
params.preparedOnly !== true &&
modelCatalogBrowseRequiresFullDiscovery({
cfg: params.cfg,
agentId: params.agentId,
view,
});
// Provider-policy wildcards newly escalate ordinary inventory views to live discovery.
// Keep those implicit loads within the browse deadline; explicit all/configured loads retain
// their existing completion semantics unless the caller requests a timeout.
@@ -94,12 +98,18 @@ async function loadCatalogForBrowse<T>(params: {
params.timeoutFullDiscovery ||
(requiresFullDiscovery && (view === "default" || view === "provider-config"));
if (requiresFullDiscovery && !shouldTimeoutFullDiscovery) {
return await params.loadCatalog({ readOnly: false });
return await params.loadCatalog({
readOnly: false,
...(params.refresh ? { refresh: true } : {}),
});
}
let timeout: NodeJS.Timeout | undefined;
const timeoutMs = resolveModelCatalogBrowseTimeoutMs(params.timeoutMs);
const catalogPromise = params.loadCatalog({ readOnly: !requiresFullDiscovery });
const catalogPromise = params.loadCatalog({
readOnly: !requiresFullDiscovery,
...(requiresFullDiscovery && params.refresh ? { refresh: true } : {}),
});
const catalogResult = catalogPromise.then((value) => ({ kind: "catalog" as const, value }));
const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => {
timeout = globalThis.setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
@@ -127,7 +137,11 @@ export function loadPreparedModelCatalogSnapshotForBrowse(params: {
cfg: OpenClawConfig;
agentId?: string;
view?: ModelCatalogBrowseView;
loadCatalog: (params: { readOnly: boolean }) => Promise<ModelCatalogSnapshot>;
/** Never starts provider discovery; a completed generation cache may still be reused. */
preparedOnly?: boolean;
/** Replaces the completed generation cache when discovery is otherwise required. */
refresh?: boolean;
loadCatalog: (params: { readOnly: boolean; refresh?: boolean }) => Promise<ModelCatalogSnapshot>;
timeoutFullDiscovery?: boolean;
timeoutMs?: number;
onTimeout?: (timeoutMs: number) => void;
@@ -11,11 +11,13 @@ import {
loadPreparedGatewayModelCatalogSnapshot,
} from "../gateway/server-model-catalog.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { OPENAI_CODEX_DEFAULT_PROFILE_ID } from "./auth-profiles/constants.js";
import { getRuntimeExternalCliProfileIds } from "./auth-profiles/runtime-external-profile-references.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "./auth-profiles/runtime-snapshots.js";
import { saveAuthProfileStore } from "./auth-profiles/store.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./auth-profiles/store.js";
import {
encodePluginModelCatalogRelativePath,
PLUGIN_MODEL_CATALOG_GENERATED_BY,
@@ -47,10 +49,6 @@ const REF_ONLY_TOKEN_PROVIDER_ID = `${PROVIDER_ID}-ref-token`;
const REF_ONLY_TOKEN_ENV = "OPENCLAW_WORKER_REF_ONLY_TOKEN";
const DURABLE_AUTH_PROVIDER_ID = `${PROVIDER_ID}-durable-auth`;
const DURABLE_AUTH_KEY = "post-startup-durable-key-not-real";
const WORKSPACE_AUTH_PLUGIN_ID = `${PLUGIN_ID}-workspace-auth`;
const WORKSPACE_AUTH_PROVIDER_ID = `${PROVIDER_ID}-workspace-auth`;
const WORKSPACE_AUTH_PROFILE_ID = `${WORKSPACE_AUTH_PROVIDER_ID}:workspace`;
const WORKSPACE_AUTH_KEY = "workspace-external-auth-key-not-real";
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
@@ -59,11 +57,30 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
});
});
function createJwtWithExp(exp: number): string {
const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
function createJwtWithExp(exp: number, marker?: string): string {
const payload = Buffer.from(JSON.stringify({ exp, ...(marker ? { marker } : {}) })).toString(
"base64url",
);
return `header.${payload}.signature`;
}
function writeCodexAuth(codexHome: string, marker: string): void {
const authPath = path.join(codexHome, "auth.json");
fs.writeFileSync(
authPath,
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: createJwtWithExp(Math.floor(Date.now() / 1000) + 3600, marker),
refresh_token: `refresh-${marker}-not-real`,
},
}),
"utf8",
);
const future = new Date(Date.now() + 2_000);
fs.utimesSync(authPath, future, future);
}
function writeFixturePlugin(params: { root: string; spinMs: number }): string {
const pluginDir = path.join(params.root, "plugin");
fs.mkdirSync(pluginDir, { recursive: true });
@@ -139,57 +156,11 @@ module.exports = {
return pluginFile;
}
function writeWorkspaceExternalAuthPlugin(workspaceDir: string): void {
const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", WORKSPACE_AUTH_PLUGIN_ID);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: `@openclaw/${WORKSPACE_AUTH_PLUGIN_ID}`,
version: "1.0.0",
openclaw: { extensions: ["./index.cjs"] },
}),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: WORKSPACE_AUTH_PLUGIN_ID,
providers: [WORKSPACE_AUTH_PROVIDER_ID],
contracts: { externalAuthProviders: [WORKSPACE_AUTH_PROVIDER_ID] },
configSchema: { type: "object", additionalProperties: false, properties: {} },
}),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`module.exports = {
id: ${JSON.stringify(WORKSPACE_AUTH_PLUGIN_ID)},
register(api) {
api.registerProvider({
id: ${JSON.stringify(WORKSPACE_AUTH_PROVIDER_ID)},
label: "Workspace external auth fixture",
auth: [],
resolveExternalAuthProfiles() {
return [{
profileId: ${JSON.stringify(WORKSPACE_AUTH_PROFILE_ID)},
credential: {
type: "api_key",
provider: ${JSON.stringify(WORKSPACE_AUTH_PROVIDER_ID)},
key: ${JSON.stringify(WORKSPACE_AUTH_KEY)},
},
persistence: "runtime-only",
}];
},
});
},
};
`,
"utf8",
);
}
async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessEnv = {}) {
async function createStaticSnapshot(
spinMs: number,
envOverride: NodeJS.ProcessEnv = {},
options?: { hydrateExternalCliProviderIds?: readonly string[] },
) {
const root = tempDirs.make("openclaw-model-catalog-worker-");
const stateDir = path.join(root, "state");
const agentDir = path.join(stateDir, "agents", "main", "agent");
@@ -198,7 +169,6 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
fs.mkdirSync(agentDir, { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
const pluginFile = writeFixturePlugin({ root, spinMs });
writeWorkspaceExternalAuthPlugin(workspaceDir);
const env = {
...process.env,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
@@ -211,7 +181,7 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
const config = {
agents: { defaults: { model: `${PROVIDER_ID}/sqlite-model` } },
plugins: {
allow: [PLUGIN_ID, WORKSPACE_AUTH_PLUGIN_ID],
allow: [PLUGIN_ID],
load: { paths: [pluginFile] },
entries: { [PLUGIN_ID]: { enabled: true } },
},
@@ -239,6 +209,15 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
},
},
]);
const hydratedAuthStore = options?.hydrateExternalCliProviderIds
? ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
config,
externalCliProviderIds: options.hydrateExternalCliProviderIds,
readOnly: true,
syncExternalCli: false,
})
: undefined;
replacePersistedPluginModelCatalogs({
agentDir,
pluginCatalogWrites: {
@@ -268,6 +247,7 @@ async function createStaticSnapshot(spinMs: number, envOverride: NodeJS.ProcessE
config,
env,
marker,
hydratedAuthStore,
pluginMetadataSnapshot: build.pluginGeneration.pluginMetadataSnapshot,
snapshot: build.snapshot,
supersede: () => (current = false),
@@ -352,32 +332,6 @@ describe("prepared model catalog worker boundary", () => {
});
});
it("refreshes workspace plugin external auth in both worker operations", async () => {
const fixture = await createStaticSnapshot(0);
const refreshed = await loadPreparedModelRuntimeAuth(fixture.snapshot, [
WORKSPACE_AUTH_PROVIDER_ID,
]);
expect(refreshed).toMatchObject({
authModes: { [WORKSPACE_AUTH_PROVIDER_ID]: "api_key" },
authStore: {
profiles: {
[WORKSPACE_AUTH_PROFILE_ID]: expect.objectContaining({ key: WORKSPACE_AUTH_KEY }),
},
},
});
const catalog = await fixture.snapshot.loadFullModelCatalog?.();
expect(getPreparedModelFullCatalogAuth(catalog!)).toMatchObject({
authModes: { [WORKSPACE_AUTH_PROVIDER_ID]: "api_key" },
authStore: {
profiles: {
[WORKSPACE_AUTH_PROFILE_ID]: expect.objectContaining({ key: WORKSPACE_AUTH_KEY }),
},
},
});
});
it("refreshes durable auth profiles added, updated, and removed after startup", async () => {
const fixture = await createStaticSnapshot(0);
const route = {
@@ -407,7 +361,7 @@ describe("prepared model catalog worker boundary", () => {
modelCatalog: { entries: [route], routeVariants: [route] },
});
const project = async () => {
const fullCatalog = await fixture.snapshot.loadFullModelCatalog?.();
const fullCatalog = await fixture.snapshot.loadFullModelCatalog?.({ refresh: true });
const fullAuth = fullCatalog && getPreparedModelFullCatalogAuth(fullCatalog);
if (!fullAuth) {
throw new Error("full catalog omitted prepared auth");
@@ -558,7 +512,7 @@ describe("prepared model catalog worker boundary", () => {
loadGatewayModelCatalogSnapshot: loadSnapshot,
logGateway: { debug: () => undefined },
} as unknown as GatewayRequestContext;
return await buildModelsListResult({ context, params: { view: "all" } });
return await buildModelsListResult({ context, params: { view: "all", refresh: true } });
};
await expect(listModels()).resolves.toMatchObject({
@@ -585,7 +539,52 @@ describe("prepared model catalog worker boundary", () => {
});
});
it("shares in-flight discovery but reruns completed refreshes with prepared auth and SQLite facts", async () => {
it("refreshes and removes a Codex login that existed in the prepared generation", async () => {
const codexHome = tempDirs.make("openclaw-prepared-codex-");
writeCodexAuth(codexHome, "startup");
const previousCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = codexHome;
let fixture: Awaited<ReturnType<typeof createStaticSnapshot>>;
try {
fixture = await createStaticSnapshot(0, {}, { hydrateExternalCliProviderIds: ["openai"] });
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodexHome;
}
}
const preparedStore = getPreparedModelRuntimeAuthStore(fixture.snapshot);
expect(fixture.hydratedAuthStore?.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
type: "oauth",
refresh: "refresh-startup-not-real",
});
expect(preparedStore?.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
type: "oauth",
refresh: "refresh-startup-not-real",
});
expect(preparedStore && getRuntimeExternalCliProfileIds(preparedStore)).toEqual([
OPENAI_CODEX_DEFAULT_PROFILE_ID,
]);
writeCodexAuth(codexHome, "rotated");
const rotated = await loadPreparedModelRuntimeAuth(fixture.snapshot, {
providerIds: [],
profileIds: [OPENAI_CODEX_DEFAULT_PROFILE_ID],
});
expect(rotated?.authStore.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
type: "oauth",
refresh: "refresh-rotated-not-real",
});
fs.rmSync(path.join(codexHome, "auth.json"));
const loggedOut = await loadPreparedModelRuntimeAuth(fixture.snapshot, {
providerIds: ["openai"],
});
expect(loggedOut?.authStore.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toBeUndefined();
});
it("shares in-flight discovery, caches completion, and explicitly refreshes prepared facts", async () => {
const fixture = await createStaticSnapshot(750);
let settled = false;
const first = fixture.snapshot.loadFullModelCatalog?.().finally(() => {
@@ -603,7 +602,8 @@ describe("prepared model catalog worker boundary", () => {
id: "proof-refresh-1-sqlite-true-shared-true-unrelated-true",
}),
);
await expect(fixture.snapshot.loadFullModelCatalog?.()).resolves.toEqual(
await expect(fixture.snapshot.loadFullModelCatalog?.()).resolves.toBe(catalog);
await expect(fixture.snapshot.loadFullModelCatalog?.({ refresh: true })).resolves.toEqual(
expect.objectContaining({
entries: expect.arrayContaining([
expect.objectContaining({
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { createPreparedModelCatalogWorkerInput } from "./prepared-model-catalog-worker.js";
import {
createPreparedModelAuthRefreshWorkerInput,
createPreparedModelCatalogWorkerInput,
} from "./prepared-model-catalog-worker.js";
import type { PreparedModelRuntimeAgentFacts } from "./prepared-model-runtime.facts.js";
vi.mock("../plugins/manifest-registry-installed.js", () => ({
@@ -8,6 +11,27 @@ vi.mock("../plugins/manifest-registry-installed.js", () => ({
}));
describe("prepared model catalog worker input", () => {
it("serializes and fingerprints auth refresh profile scope", () => {
const params = {
agentDir: "/tmp/agent",
authStore: { version: 1, profiles: {} },
config: {},
env: {},
providerIds: ["openai"],
};
const scoped = createPreparedModelAuthRefreshWorkerInput({
...params,
profileIds: ["openai:work", "openai:default", "openai:work"],
});
const other = createPreparedModelAuthRefreshWorkerInput({
...params,
profileIds: ["openai:default"],
});
expect(structuredClone(scoped).profileIds).toEqual(["openai:default", "openai:work"]);
expect(scoped.generationFingerprint).not.toBe(other.generationFingerprint);
});
it("preserves SecretRef identity beside materialized literals", () => {
const authStore = {
version: 1,
+12 -5
View File
@@ -30,10 +30,10 @@ export type PreparedModelAuthRefreshWorkerInput = Readonly<{
generationFingerprint: string;
agentDir: string;
inheritedAuthDir?: string;
workspaceDir?: string;
authStore: AuthProfileStore;
config: PreparedModelRuntimeInput["config"];
env: NodeJS.ProcessEnv;
profileIds?: readonly string[];
providerIds: readonly string[];
}>;
@@ -140,10 +140,10 @@ export function createPreparedModelCatalogWorkerInput(params: {
export function createPreparedModelAuthRefreshWorkerInput(params: {
agentDir: string;
inheritedAuthDir?: string;
workspaceDir?: string;
authStore: AuthProfileStore;
config: PreparedModelRuntimeInput["config"];
env: NodeJS.ProcessEnv;
profileIds?: readonly string[];
providerIds: readonly string[];
}): PreparedModelAuthRefreshWorkerInput {
const providerIds = [...new Set(params.providerIds)].toSorted((left, right) =>
@@ -151,23 +151,26 @@ export function createPreparedModelAuthRefreshWorkerInput(params: {
);
const authStore = cloneAuthProfileStore(params.authStore);
const env = { ...params.env };
const profileIds = params.profileIds
? [...new Set(params.profileIds)].toSorted((left, right) => left.localeCompare(right))
: undefined;
return {
kind: "auth-refresh",
agentDir: params.agentDir,
...(params.inheritedAuthDir ? { inheritedAuthDir: params.inheritedAuthDir } : {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
generationFingerprint: fingerprintPreparedRuntimeFacts({
agentDir: params.agentDir,
inheritedAuthDir: params.inheritedAuthDir,
workspaceDir: params.workspaceDir,
authStore,
config: params.config,
env,
profileIds,
providerIds,
}),
authStore,
config: params.config,
env,
...(profileIds ? { profileIds } : {}),
providerIds,
};
}
@@ -230,7 +233,11 @@ function runPreparedModelWorker<T>(params: {
worker.removeAllListeners();
const finish = () => {
if (outcome.status === "resolved") {
resolve(outcome.value);
if (params.isCurrent()) {
resolve(outcome.value);
} else {
reject(superseded());
}
} else {
reject(outcome.error);
}
+20 -4
View File
@@ -16,7 +16,9 @@ import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalo
import type { ResolvedPublishedModelCatalogOwner } from "./prepared-model-catalog.types.js";
import {
getPreparedModelRuntimeAuthMaterializations,
loadPreparedModelRuntimeAuth,
setPreparedModelRuntimeAuthMaterializations,
setPreparedModelRuntimeAuthLoader,
setPreparedModelRuntimeAuthStore,
} from "./prepared-model-runtime-auth.js";
import { isPreparedModelCatalogFull } from "./prepared-model-runtime.facts.js";
@@ -48,6 +50,8 @@ export type LoadPreparedModelCatalogParams = {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
providerDiscoveryProviderIds?: readonly string[];
/** Rebuilds a completed full catalog instead of reusing this generation's cache. */
refreshFullCatalog?: boolean;
/** Scoped read-only loads may run live discovery for the scoped providers only. */
scopedLiveProviderDiscovery?: boolean;
allowGatewaySubagentBinding?: boolean;
@@ -63,11 +67,18 @@ type PreparedModelCatalogConfigPolicy = "exact" | "published";
async function materializeRequestedModelCatalog(
snapshot: PreparedModelRuntimeSnapshot,
readOnly: boolean | undefined,
refreshFullCatalog: boolean | undefined,
): Promise<PreparedModelRuntimeSnapshot> {
if (readOnly === true || !snapshot.loadFullModelCatalog) {
if (!snapshot.loadFullModelCatalog) {
return snapshot;
}
const modelCatalog =
readOnly === true
? snapshot.readFullModelCatalog?.()
: await snapshot.loadFullModelCatalog({ refresh: refreshFullCatalog === true });
if (!modelCatalog) {
return snapshot;
}
const modelCatalog = await snapshot.loadFullModelCatalog();
const fullAuth = getPreparedModelFullCatalogAuth(modelCatalog);
if (!fullAuth) {
throw new Error("prepared full model catalog omitted its auth generation");
@@ -77,9 +88,13 @@ async function materializeRequestedModelCatalog(
authModes: fullAuth.authModes,
modelCatalog,
});
// A materialized full read owns the worker's refreshed auth generation. Do not copy the
// original loader or Gateway projection would start a second, split refresh after discovery.
setPreparedModelRuntimeAuthStore(materialized, fullAuth.authStore);
// Later explicit auth refreshes stay bound to the original owner generation. Ordinary reads
// consume the full worker's paired auth without invoking this loader.
setPreparedModelRuntimeAuthLoader(
materialized,
async (scope) => (await loadPreparedModelRuntimeAuth(snapshot, scope)) ?? fullAuth,
);
setPreparedModelRuntimeAuthMaterializations(
materialized,
getPreparedModelRuntimeAuthMaterializations(snapshot),
@@ -288,6 +303,7 @@ async function loadPreparedModelCatalogOwnerSnapshotWithPolicy(
return await materializeRequestedModelCatalog(
await resolvePreparedModelCatalogOwnerSnapshotWithPolicy(params, configPolicy),
params.readOnly,
params.refreshFullCatalog,
);
}
+18 -17
View File
@@ -6,8 +6,9 @@ import {
resolveUsableAgentCredentialModes,
} from "./agent-auth-credentials.js";
import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js";
import { overlayExternalAuthProfiles } from "./auth-profiles/external-auth.js";
import { overlayExternalCliAuthProfiles } from "./auth-profiles/external-auth.js";
import { listExternalCliSyncProviderIds } from "./auth-profiles/external-cli-sync.js";
import { mergeRuntimeExternalProfileReferences } from "./auth-profiles/runtime-external-profile-references.js";
import { replaceRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js";
import {
loadAuthProfileStoreWithoutExternalProfiles,
@@ -27,7 +28,7 @@ function refreshAuthStore(params: {
authStore: PreparedModelCatalogWorkerInput["authStore"];
config: PreparedModelCatalogWorkerInput["input"]["config"];
env: NodeJS.ProcessEnv;
workspaceDir?: string;
profileIds?: readonly string[];
providerIds?: readonly string[];
}) {
const durable = preserveResolvedSecretBackedCredentials({
@@ -48,12 +49,15 @@ function refreshAuthStore(params: {
durable.profiles[profileId] = credential;
}
}
return overlayExternalAuthProfiles(durable, {
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
const prepared = mergeRuntimeExternalProfileReferences({
next: durable,
existing: params.authStore,
});
return overlayExternalCliAuthProfiles(prepared, {
config: params.config,
env: params.env,
...(params.providerIds ? { externalCliProviderIds: params.providerIds } : {}),
...(params.profileIds ? { externalCliProfileIds: params.profileIds } : {}),
allowKeychainPrompt: false,
});
}
@@ -66,10 +70,10 @@ export async function runPreparedModelCatalogWorkerInput(
const authStore = refreshAuthStore({
agentDir: value.agentDir,
inheritedAuthDir: value.inheritedAuthDir,
workspaceDir: value.workspaceDir,
authStore: value.authStore,
config: value.config,
env: value.env,
...(value.profileIds ? { profileIds: value.profileIds } : {}),
providerIds: value.providerIds,
});
return {
@@ -100,17 +104,14 @@ export async function runPreparedModelCatalogWorkerInput(
}
// Full discovery is one point-in-time operation: refresh first, then let every provider hook
// and the returned availability projection consume the same exact store.
const authStore = withPluginRuntimeRegistryScope(prepared.pluginGeneration.pluginRegistry, () =>
refreshAuthStore({
agentDir: value.input.agentDir,
inheritedAuthDir: value.input.inheritedAuthDir,
authStore: value.authStore,
config: value.input.config,
env: value.input.env ?? process.env,
providerIds: listExternalCliSyncProviderIds(),
workspaceDir: value.input.workspaceDir,
}),
);
const authStore = refreshAuthStore({
agentDir: value.input.agentDir,
inheritedAuthDir: value.input.inheritedAuthDir,
authStore: value.authStore,
config: value.input.config,
env: value.input.env ?? process.env,
providerIds: listExternalCliSyncProviderIds(),
});
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir: value.input.agentDir, store: authStore }]);
const ambientCredentials = withPluginRuntimeRegistryScope(
prepared.pluginGeneration.pluginRegistry,
+9 -4
View File
@@ -7,12 +7,17 @@ export type PreparedModelRuntimeAuth = Readonly<{
authModes: PreparedAgentCredentialModes;
}>;
export type PreparedModelRuntimeAuthScope = Readonly<{
providerIds: readonly string[];
profileIds?: readonly string[];
}>;
/** Private auth facts owned by an immutable prepared model generation. */
const authStoreBySnapshot = new WeakMap<object, AuthProfileStore>();
const materializationsBySnapshot = new WeakMap<object, readonly RuntimeAuthMaterialization[]>();
const authLoaderBySnapshot = new WeakMap<
object,
(providerIds: readonly string[]) => Promise<PreparedModelRuntimeAuth>
(scope: PreparedModelRuntimeAuthScope) => Promise<PreparedModelRuntimeAuth>
>();
// Secret-bearing state stays lifecycle-owned without becoming part of the public snapshot shape.
@@ -29,18 +34,18 @@ export function getPreparedModelRuntimeAuthStore(snapshot: object): AuthProfileS
export function setPreparedModelRuntimeAuthLoader(
snapshot: object,
loader: (providerIds: readonly string[]) => Promise<PreparedModelRuntimeAuth>,
loader: (scope: PreparedModelRuntimeAuthScope) => Promise<PreparedModelRuntimeAuth>,
): void {
authLoaderBySnapshot.set(snapshot, loader);
}
export async function loadPreparedModelRuntimeAuth(
snapshot: object & { authModes?: PreparedAgentCredentialModes },
providerIds: readonly string[],
scope: PreparedModelRuntimeAuthScope,
): Promise<PreparedModelRuntimeAuth | undefined> {
const loader = authLoaderBySnapshot.get(snapshot);
if (loader) {
return await loader(providerIds);
return await loader(scope);
}
const authStore = authStoreBySnapshot.get(snapshot);
return authStore ? { authStore, authModes: snapshot.authModes ?? {} } : undefined;
+38 -18
View File
@@ -18,6 +18,7 @@ import {
setPreparedModelRuntimeAuthLoader,
setPreparedModelRuntimeAuthStore,
type PreparedModelRuntimeAuth,
type PreparedModelRuntimeAuthScope,
} from "./prepared-model-runtime-auth.js";
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
import {
@@ -49,8 +50,9 @@ const MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS = 1;
const limitFullModelCatalogBuild = pLimit(MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS);
type PreparedModelRuntimeCatalogAccess = Readonly<{
loadFullModelCatalog: () => Promise<ModelCatalogSnapshot>;
loadAuth: (providerIds: readonly string[]) => Promise<PreparedModelRuntimeAuth>;
readFullModelCatalog: () => ModelCatalogSnapshot | undefined;
loadFullModelCatalog: (options?: { refresh?: boolean }) => Promise<ModelCatalogSnapshot>;
loadAuth: (scope: PreparedModelRuntimeAuthScope) => Promise<PreparedModelRuntimeAuth>;
}>;
type PreparedModelRuntimeBuildGuards =
| ReadonlyMap<PreparedModelRuntimeInput, () => boolean>
@@ -119,8 +121,9 @@ function createFullModelCatalogAccess(params: {
agentBuildCompletions: Map<string, Promise<void>>;
isCurrent: () => boolean;
}): PreparedModelRuntimeCatalogAccess {
// Concurrent readers share discovery, but completed results are discarded so
// refreshable providers can publish changed inventory on the next explicit read.
// The completed catalog is generation-owned. Explicit refresh replaces it only after a
// successful build, so failed refreshes cannot discard the last verified inventory.
let fullCatalog: ModelCatalogSnapshot | undefined;
let pending: Promise<ModelCatalogSnapshot> | undefined;
let pendingAuth:
| {
@@ -136,27 +139,25 @@ function createFullModelCatalogAccess(params: {
}
};
return {
loadAuth: (providerIds) => {
loadAuth: ({ providerIds, profileIds }) => {
const key = [...new Set(providerIds)]
.toSorted((left, right) => left.localeCompare(right))
.join("\0");
if (key.length === 0) {
return Promise.resolve({
authStore: params.agentFacts.authStore,
authModes: resolveUsableAgentCredentialModes(params.agentFacts.credentials),
});
}
if (pendingAuth?.key === key) {
const profileKey = [...new Set(profileIds ?? [])]
.toSorted((left, right) => left.localeCompare(right))
.join("\0");
const cacheKey = `${key}\0\0${profileKey}`;
if (pendingAuth?.key === cacheKey) {
return pendingAuth.promise;
}
const input = createPreparedModelAuthRefreshWorkerInput({
agentDir: params.agentFacts.input.agentDir,
inheritedAuthDir: params.agentFacts.input.inheritedAuthDir,
workspaceDir: params.agentFacts.input.workspaceDir,
authStore: params.agentFacts.authStore,
config: params.agentFacts.input.config,
env: params.agentFacts.env,
providerIds,
...(profileIds?.length ? { profileIds } : {}),
});
const promise = runPreparedModelAuthRefreshWorker({
input,
@@ -177,12 +178,24 @@ function createFullModelCatalogAccess(params: {
pendingAuth = undefined;
}
});
pendingAuth = { key, promise };
pendingAuth = { key: cacheKey, promise };
return promise;
},
loadFullModelCatalog: () => {
readFullModelCatalog: () => {
assertCurrent();
return fullCatalog;
},
loadFullModelCatalog: (options) => {
try {
assertCurrent();
} catch (error) {
return Promise.reject(error);
}
if (!options?.refresh && fullCatalog) {
return Promise.resolve(fullCatalog);
}
if (!pending) {
pending = runSerializedPreparedModelRuntimeTask({
const build = runSerializedPreparedModelRuntimeTask({
agentDir: params.agentFacts.input.agentDir,
agentBuildCompletions: params.agentBuildCompletions,
isCurrent: params.isCurrent,
@@ -201,9 +214,15 @@ function createFullModelCatalogAccess(params: {
assertCurrent();
return catalog;
}),
}).finally(() => {
pending = undefined;
});
pending = build
.then((catalog) => {
fullCatalog = catalog;
return catalog;
})
.finally(() => {
pending = undefined;
});
}
return pending;
},
@@ -241,6 +260,7 @@ function createSnapshot(
...(messageToolCatalog ? { messageToolCatalog } : {}),
...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}),
modelCatalog,
readFullModelCatalog: catalogAccess.readFullModelCatalog,
loadFullModelCatalog: catalogAccess.loadFullModelCatalog,
configuredRuntimeModels,
inlineProviderModels,
@@ -25,6 +25,7 @@ import {
discoverModels,
discoverModelsFromCapturedSources,
} from "./agent-model-discovery.js";
import { getPreparedRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/store.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import {
buildInlineProviderModels,
@@ -122,12 +123,25 @@ function prepareAgentFacts(
additionalProviderIds: readonly string[] = [],
): PreparedModelRuntimeAgentBaseFacts {
const env = input.env ?? process.env;
const publishedStore = getPreparedRuntimeAuthProfileStoreSnapshot(
input.agentDir,
input.inheritedAuthDir,
);
// Runtime-only external profiles exist only in the published auth generation. Re-reading the
// durable store here would erase startup hydration before this owner can carry it forward.
const preparedStore =
publishedStore &&
(publishedStore.runtimeExternalProfileIds !== undefined ||
publishedStore.runtimeExternalProfileIdsAuthoritative === true)
? publishedStore
: undefined;
const authFacts = discoverAuthStorageFacts(input.agentDir, {
config: input.config,
// Prepared owners consume only the already-published runtime auth generation. External CLI
// hydration belongs to startup/control-plane and turn-time producers, never rebuilds.
readOnly: true,
ambientCredentials,
...(preparedStore ? { preparedStore } : {}),
...(input.skipCredentials ? { skipCredentials: true } : {}),
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
@@ -168,6 +168,7 @@ vi.mock("./agent-scope.js", () => ({
}));
vi.mock("./auth-profiles/runtime-snapshots.js", () => ({
getPreparedRuntimeAuthProfileStoreSnapshotCore: () => undefined,
registerRuntimeAuthProfileStoreMutationListener: (
listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void,
) => {
@@ -416,7 +417,17 @@ describe("prepared model runtime Gateway catalog mode", () => {
routeVariants: [],
});
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce();
expect(snapshot?.readFullModelCatalog?.()).toEqual({ entries: [], routeVariants: [] });
await snapshot?.loadFullModelCatalog?.({ refresh: true });
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2);
mocks.runPreparedModelCatalogWorker.mockRejectedValueOnce(new Error("refresh failed"));
await expect(snapshot?.loadFullModelCatalog?.({ refresh: true })).rejects.toThrow(
"refresh failed",
);
expect(snapshot?.readFullModelCatalog?.()).toEqual({ entries: [], routeVariants: [] });
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(3);
expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce();
expect(mocks.discoverModels).toHaveBeenCalledOnce();
@@ -425,7 +436,7 @@ describe("prepared model runtime Gateway catalog mode", () => {
affectsInheritedStores: false,
});
await expect(snapshot?.loadFullModelCatalog?.()).rejects.toThrow("superseded");
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2);
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(3);
});
it("publishes exact dynamic configured models without building a live catalog", async () => {
+3 -1
View File
@@ -53,8 +53,10 @@ export type PreparedModelRuntimeSnapshot = Readonly<{
* Full inventory discovery is deliberately outside the startup publication boundary.
*/
modelCatalog: ModelCatalogSnapshot;
/** Reads a completed full catalog without starting provider discovery. */
readFullModelCatalog?: () => ModelCatalogSnapshot | undefined;
/** Builds this generation's full control-plane catalog without replacing turn facts. */
loadFullModelCatalog?: () => Promise<ModelCatalogSnapshot>;
loadFullModelCatalog?: (options?: { refresh?: boolean }) => Promise<ModelCatalogSnapshot>;
/** Full static models for configured refs, resolved once at the lifecycle boundary. */
configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[];
/** Inline provider projection prepared once for all resolutions owned by this snapshot. */
+8 -4
View File
@@ -151,7 +151,7 @@ describe("local gateway request context", () => {
const list = () =>
withLocalGatewayRequestScope({ deps: {} as CliDeps, getRuntimeConfig: () => cfg }, () =>
dispatchGatewayMethodInProcessRaw("models.list", { view: "all" }),
dispatchGatewayMethodInProcessRaw("models.list", { view: "configured", refresh: true }),
);
const loggedIn = await list();
const loggedOut = await list();
@@ -162,10 +162,14 @@ describe("local gateway request context", () => {
});
expect(loggedOut).toMatchObject({
ok: true,
payload: { models: [expect.objectContaining({ id: "local-auth-model", available: false })] },
payload: { models: [] },
});
expect(refreshAuth).toHaveBeenNthCalledWith(1, {
providerIds: ["local-auth-provider"],
});
expect(refreshAuth).toHaveBeenNthCalledWith(2, {
providerIds: ["local-auth-provider"],
});
expect(refreshAuth).toHaveBeenNthCalledWith(1, ["local-auth-provider"]);
expect(refreshAuth).toHaveBeenNthCalledWith(2, ["local-auth-provider"]);
loadOwner.mockRestore();
});
-1
View File
@@ -116,7 +116,6 @@ function createLocalGatewayRequestContext(
loadPreparedGatewayModelCatalogSnapshot({
...loadParams,
getConfig: params.getRuntimeConfig,
refreshAuth: true,
}),
readPrepared: (loadParams) =>
readPreparedGatewayModelCatalogOwnerSnapshot({
+1 -2
View File
@@ -95,8 +95,7 @@ const readPreparedGatewayModelCatalogOwnerSnapshot: ReadPreparedGatewayModelCata
};
registerGatewayModelCatalogPrivateAccess(loadGatewayModelCatalogSnapshot, {
loadDeferred: (params) =>
loadPreparedGatewayModelCatalogSnapshot({ ...params, refreshAuth: true }),
loadDeferred: (params) => loadPreparedGatewayModelCatalogSnapshot(params),
readPrepared: readPreparedGatewayModelCatalogOwnerSnapshot,
});
@@ -32,11 +32,6 @@ const mocks = vi.hoisted(() => ({
agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`,
),
resolveDefaultAgentId: vi.fn(() => "main"),
ensureAuthProfileStore: vi.fn((agentDir?: string, options?: unknown): AuthProfileStore => {
void agentDir;
void options;
return { version: 1, profiles: {} };
}),
ensureAuthProfileStoreWithoutExternalProfiles: vi.fn((agentDir?: string): AuthProfileStore => {
void agentDir;
return { version: 1, profiles: {} };
@@ -49,10 +44,11 @@ const mocks = vi.hoisted(() => ({
resolvePersistedAuthProfileOwnerAgentDir: vi.fn(
(params: { agentDir?: string }) => params.agentDir,
),
clearRuntimeAuthProfileStoreSnapshots: vi.fn(),
refreshActiveProviderAuthRuntimeSnapshot: vi.fn(async () => false),
clearCurrentProviderAuthState: vi.fn(),
warmCurrentProviderAuthStateOffMainThread: vi.fn(async (_cfg: unknown) => {}),
loadDeferredCatalog: vi.fn(),
readPreparedCatalog: vi.fn(),
buildAuthHealthSummary: vi.fn<BuildAuthHealthSummary>(
(): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }),
),
@@ -75,14 +71,12 @@ vi.mock("../../agents/auth-profiles.js", async () => {
);
return {
...actual,
ensureAuthProfileStore: mocks.ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles:
mocks.ensureAuthProfileStoreWithoutExternalProfiles,
listProfilesForProvider: mocks.listProfilesForProvider,
removeAuthProfilesAcrossOwnerStores: mocks.removeAuthProfilesAcrossOwnerStores,
removeProviderAuthProfilesWithLock: mocks.removeProviderAuthProfilesWithLock,
resolvePersistedAuthProfileOwnerAgentDir: mocks.resolvePersistedAuthProfileOwnerAgentDir,
clearRuntimeAuthProfileStoreSnapshots: mocks.clearRuntimeAuthProfileStoreSnapshots,
};
});
@@ -109,6 +103,11 @@ vi.mock("../../agents/model-provider-auth.js", () => ({
warmCurrentProviderAuthStateOffMainThread: mocks.warmCurrentProviderAuthStateOffMainThread,
}));
vi.mock("../server-model-catalog-auth.js", () => ({
loadDeferredCatalog: mocks.loadDeferredCatalog,
readPreparedCatalog: mocks.readPreparedCatalog,
}));
import {
aggregateRefreshableAuthStatus,
invalidateModelAuthStatusCache,
@@ -196,6 +195,35 @@ function createLogoutOptions(
}
const requireRecord = createRequireRecord("record", "expected-non-array-record");
let preparedAuthStore: AuthProfileStore = { version: 1, profiles: {} };
let preparedMetadataSnapshot: unknown;
function setPreparedAuthStore(store: AuthProfileStore): void {
preparedAuthStore = store;
}
function setPreparedMetadataSnapshot(snapshot: unknown): void {
preparedMetadataSnapshot = snapshot;
}
function createPreparedOwnerSnapshot(agentId: string) {
const config = mocks.getRuntimeConfig.mock.results.at(-1)?.value ?? {};
const agentDir =
mocks.resolveAgentDir.mock.results.at(-1)?.value ??
(agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`);
return {
agentId,
agentDir,
workspaceDir: "/tmp/workspace",
config,
entries: [],
routeVariants: [],
authModes: {},
authStore: preparedAuthStore,
authMaterializations: [],
metadataSnapshot: preparedMetadataSnapshot as never,
};
}
function firstRespondCall(
opts: GatewayRequestHandlerOptions & { respond: ReturnType<typeof vi.fn> },
@@ -203,10 +231,6 @@ function firstRespondCall(
return opts.respond.mock.calls[0];
}
function firstEnsureAuthProfileStoreCall() {
return mocks.ensureAuthProfileStore.mock.calls[0];
}
function firstBuildAuthHealthSummaryCall() {
return mocks.buildAuthHealthSummary.mock.calls[0] as unknown as
| [{ providers?: string[]; allowKeychainPrompt?: boolean }]
@@ -246,7 +270,18 @@ function resetAuthStatusMocks(): void {
agentId === "main" ? "/tmp/agent" : `/tmp/agent-${agentId}`,
);
mocks.resolveDefaultAgentId.mockReturnValue("main");
mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
setPreparedAuthStore({ version: 1, profiles: {} });
setPreparedMetadataSnapshot({
index: { plugins: [] },
manifestRegistry: { plugins: [] },
plugins: [],
});
mocks.readPreparedCatalog.mockImplementation(async (_context, agentId: string) =>
createPreparedOwnerSnapshot(agentId),
);
mocks.loadDeferredCatalog.mockImplementation(async (_context, agentId: string) =>
createPreparedOwnerSnapshot(agentId),
);
mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({
version: 1,
profiles: {},
@@ -267,17 +302,20 @@ function resetAuthStatusMocks(): void {
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockResolvedValue(false);
}
function firstDeferredAuthScope() {
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
const [, agentId, options] = mocks.loadDeferredCatalog.mock.calls[0] ?? [];
expect(agentId).toBe("main");
const deferredOptions = requireRecord(options);
expect(deferredOptions.readOnly).toBe(true);
expect(deferredOptions.refreshAuth).toBe(true);
return requireRecord(deferredOptions.authScope);
}
afterEach(() => {
vi.unstubAllEnvs();
});
function firstExternalCliAuthOption() {
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
expect(firstEnsureAuthProfileStoreCall()?.[0]).toBe("/tmp/agent");
const [, options] = firstEnsureAuthProfileStoreCall() ?? [];
return requireRecord(requireRecord(options).externalCli);
}
function expectLogoutFailurePreservesRun(params: {
opts: ReturnType<typeof createLogoutOptions>;
runId: string;
@@ -358,10 +396,7 @@ describe("models.authStatus", () => {
await handler(opts);
expect(mocks.resolveAgentDir).toHaveBeenCalledWith(cfg, expectedAgentId);
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith(
expectedAgentId === "main" ? "/tmp/agent" : "/tmp/agent-writer",
expect.any(Object),
);
expect(mocks.readPreparedCatalog).toHaveBeenCalledWith(expect.anything(), expectedAgentId);
},
);
@@ -374,7 +409,8 @@ describe("models.authStatus", () => {
await handler(opts);
expect(mocks.resolveAgentDir).not.toHaveBeenCalled();
expect(mocks.ensureAuthProfileStore).not.toHaveBeenCalled();
expect(mocks.readPreparedCatalog).not.toHaveBeenCalled();
expect(mocks.loadDeferredCatalog).not.toHaveBeenCalled();
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).not.toHaveBeenCalled();
const [ok, payload, error] = firstRespondCall(opts) ?? [];
expect(ok).toBe(false);
@@ -395,10 +431,7 @@ describe("models.authStatus", () => {
await handler(opts);
expect(mocks.resolveAgentDir).toHaveBeenCalledWith(cfg, "_writer");
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith(
"/tmp/agent-_writer",
expect.any(Object),
);
expect(mocks.readPreparedCatalog).toHaveBeenCalledWith(expect.anything(), "_writer");
expect(firstRespondCall(opts)?.[0]).toBe(true);
});
@@ -413,7 +446,7 @@ describe("models.authStatus", () => {
await handler(opts);
expect(mocks.resolveAgentDir).not.toHaveBeenCalled();
expect(mocks.ensureAuthProfileStore).not.toHaveBeenCalled();
expect(mocks.readPreparedCatalog).not.toHaveBeenCalled();
expect(firstRespondCall(opts)?.[2]).toEqual({
code: "INVALID_REQUEST",
message: `unknown agent id "${agentId}"`,
@@ -422,7 +455,7 @@ describe("models.authStatus", () => {
},
);
it("rebuilds fresh auth snapshots for each requested agent", async () => {
it("reads the published auth owner for each requested agent", async () => {
const cfg = { agents: { list: [{ id: "main", default: true }, { id: "writer" }] } };
mocks.getRuntimeConfig.mockReturnValue(cfg);
mocks.listAgentIds.mockReturnValue(["main", "writer"]);
@@ -432,22 +465,11 @@ describe("models.authStatus", () => {
const freshMain = createOptions({ agentId: "main" });
await handler(freshMain);
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
1,
"/tmp/agent",
expect.any(Object),
);
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
2,
"/tmp/agent-writer",
expect.any(Object),
);
expect(mocks.ensureAuthProfileStore).toHaveBeenNthCalledWith(
3,
"/tmp/agent",
expect.any(Object),
);
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(3);
expect(mocks.readPreparedCatalog).toHaveBeenNthCalledWith(1, expect.anything(), "main");
expect(mocks.readPreparedCatalog).toHaveBeenNthCalledWith(2, expect.anything(), "writer");
expect(mocks.readPreparedCatalog).toHaveBeenNthCalledWith(3, expect.anything(), "main");
expect(mocks.readPreparedCatalog).toHaveBeenCalledTimes(3);
expect(mocks.loadDeferredCatalog).not.toHaveBeenCalled();
expect(firstRespondCall(freshMain)?.[3]).toBeUndefined();
});
@@ -462,13 +484,15 @@ describe("models.authStatus", () => {
await handler(createOptions({ refresh: true }));
expect(mocks.getRuntimeConfig).toHaveBeenCalledTimes(2);
expect(mocks.readPreparedCatalog).not.toHaveBeenCalled();
expect(mocks.loadDeferredCatalog).toHaveBeenCalledOnce();
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledWith(
expect.objectContaining({ cfg: after }),
);
});
it("returns a serialisable snapshot on first call", async () => {
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles: {
"openai:default": {
@@ -509,9 +533,52 @@ describe("models.authStatus", () => {
expect(result.providers[0]?.profiles[0]?.logoutSupported).toBe(true);
});
it("projects provider capabilities from the published lifecycle metadata", async () => {
const plugins = [
{
id: "provider-auth",
origin: "bundled",
providerAuthAliases: { "openai-legacy": "openai" },
providerAuthChoices: [
{
provider: "openai-legacy",
method: "api-key",
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
appGuidedSecret: true,
},
{
provider: "openai",
method: "oauth",
choiceId: "openai-oauth",
choiceLabel: "OpenAI OAuth",
},
{
provider: "github-copilot",
method: "oauth",
choiceId: "github-copilot-oauth",
choiceLabel: "GitHub Copilot OAuth",
},
],
},
];
setPreparedMetadataSnapshot({
index: { plugins: [] },
manifestRegistry: { plugins },
plugins,
});
const result = await readAuthStatus();
expect(result.providerCapabilities).toEqual([
{ provider: "github-copilot", apiKeySupported: false, quickApiKeySetup: false },
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
]);
});
it("does not offer logout for runtime external CLI profiles", async () => {
const health = createOpenAiCodexOauthHealthSummary();
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles: {},
runtimeExternalProfileIds: ["openai:default"],
@@ -540,7 +607,7 @@ describe("models.authStatus", () => {
},
},
});
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles: {
[profileId]: { type: "token", provider: "openrouter", token: "placeholder" },
@@ -736,7 +803,7 @@ describe("models.authStatus", () => {
providers: { anthropic: Object.fromEntries([["apiKey", profileId]]) },
},
});
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles: {
[profileId]: {
@@ -809,20 +876,16 @@ describe("models.authStatus", () => {
await handler(createOptions({ refresh: true }));
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).toHaveBeenCalledTimes(1);
const clearOrder = mocks.clearRuntimeAuthProfileStoreSnapshots.mock.invocationCallOrder[0];
const refreshReadOrder = mocks.ensureAuthProfileStore.mock.invocationCallOrder.at(-1);
expect(clearOrder).toBeLessThan(refreshReadOrder ?? 0);
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
});
it("keeps refreshed secrets runtime snapshots on explicit refresh", async () => {
it("refreshes the transient owner after secrets runtime refresh", async () => {
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockResolvedValueOnce(true);
await handler(createOptions({ refresh: true }));
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).not.toHaveBeenCalled();
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
});
it("keeps last-good secrets runtime snapshots when explicit refresh fails", async () => {
@@ -833,8 +896,7 @@ describe("models.authStatus", () => {
await handler(createOptions({ refresh: true }));
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).not.toHaveBeenCalled();
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
expect(mocks.loadDeferredCatalog).toHaveBeenCalledTimes(1);
});
it("invalidateModelAuthStatusCache() preserves fresh auth reads", async () => {
@@ -1125,7 +1187,7 @@ describe("models.authStatus", () => {
it("does not reuse usage after credentials rotate within the same provider", async () => {
mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary());
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles: {
"openai:default": {
@@ -1154,7 +1216,7 @@ describe("models.authStatus", () => {
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
});
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles: {
"openai:default": {
@@ -1224,7 +1286,7 @@ describe("models.authStatus", () => {
expires: 1_000_000,
},
};
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles,
lastGood: { openai: "openai:first" },
@@ -1246,7 +1308,7 @@ describe("models.authStatus", () => {
expect(warmed.providers[0]?.usage?.windows[0]?.usedPercent).toBe(10);
});
mocks.ensureAuthProfileStore.mockReturnValue({
setPreparedAuthStore({
version: 1,
profiles,
lastGood: { openai: "openai:second" },
@@ -1279,24 +1341,19 @@ describe("models.authStatus", () => {
},
});
await handler(createOptions());
await handler(createOptions({ refresh: true }));
const externalCli = firstExternalCliAuthOption();
expect(externalCli.mode).toBe("scoped");
expect(externalCli.allowKeychainPrompt).toBe(false);
requireRecord(externalCli.config);
expect(externalCli.providerIds).toContain("opencode-go");
expect(externalCli.providerIds).not.toContain("claude-cli");
expect(externalCli.profileIds).toEqual(["opencode-go:default"]);
const authScope = firstDeferredAuthScope();
expect(authScope.providerIds).toContain("opencode-go");
expect(authScope.providerIds).not.toContain("claude-cli");
expect(authScope.profileIds).toEqual(["opencode-go:default"]);
});
it("disables external CLI auth overlays when config has no provider signal", async () => {
await handler(createOptions());
await handler(createOptions({ refresh: true }));
const externalCli = firstExternalCliAuthOption();
expect(externalCli.mode).toBe("none");
expect(externalCli.allowKeychainPrompt).toBe(false);
requireRecord(externalCli.config);
const authScope = firstDeferredAuthScope();
expect(authScope).toEqual({ providerIds: [] });
});
it("still returns providers when usage fetch fails", async () => {
@@ -16,8 +16,6 @@ import {
} from "../../agents/auth-health.js";
import {
type AuthProfileStore,
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
externalCliDiscoveryForConfigStatus,
listProfilesForProvider,
@@ -49,8 +47,11 @@ import { coerceSecretRef, hasConfiguredSecretInput } from "../../config/types.se
import { providerUsageLabel, resolveUsageProviderId } from "../../infra/provider-usage.shared.js";
import type { UsageProviderId } from "../../infra/provider-usage.types.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-choices.js";
import { refreshActiveProviderAuthRuntimeSnapshot } from "../../secrets/runtime.js";
import { supportsSetupManualSecret } from "../../system-agent/setup-inference-auth-options.js";
import { abortChatRunsForProvider, type ChatAbortOps } from "../chat-abort.js";
import { loadDeferredCatalog, readPreparedCatalog } from "../server-model-catalog-auth.js";
import { formatForLog } from "../ws-log.js";
import { modelAuthAgentScopeError, resolveModelAuthAgentScope } from "./model-auth-agent-scope.js";
import {
@@ -64,6 +65,7 @@ import type {
ModelAuthLogoutResult,
ModelAuthStatusProvider,
ModelAuthStatusResult,
ModelProviderCapability,
} from "./models-auth-status.types.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
@@ -73,11 +75,63 @@ export type {
ModelAuthStatusProfile,
ModelAuthStatusProvider,
ModelAuthStatusResult,
ModelProviderCapability,
} from "./models-auth-status.types.js";
const log = createSubsystemLogger("models-auth-status");
const apiKeyUsageStatusProviders = new Set<UsageProviderId>(["clawrouter", "deepseek"]);
function buildProviderCapabilities(params: {
config: OpenClawConfig;
workspaceDir: string;
metadataSnapshot: NonNullable<
Awaited<ReturnType<typeof readPreparedCatalog>>
>["metadataSnapshot"];
}): ModelProviderCapability[] {
const capabilities = new Map<string, ModelProviderCapability>();
for (const choice of resolveManifestProviderAuthChoices({
config: params.config,
workspaceDir: params.workspaceDir,
includeUntrustedWorkspacePlugins: false,
metadataSnapshot: params.metadataSnapshot,
})) {
const provider = resolveProviderIdForAuth(choice.providerId, {
config: params.config,
workspaceDir: params.workspaceDir,
includeUntrustedWorkspacePlugins: false,
metadataSnapshot: params.metadataSnapshot,
});
if (!provider) {
continue;
}
const current = capabilities.get(provider);
const apiKeySupported = choice.methodId === "api-key";
const quickApiKeySetup = apiKeySupported && supportsSetupManualSecret(choice);
capabilities.set(provider, {
provider,
apiKeySupported: current?.apiKeySupported === true || apiKeySupported,
quickApiKeySetup: current?.quickApiKeySetup === true || quickApiKeySetup,
});
}
return [...capabilities.values()].toSorted((a, b) => a.provider.localeCompare(b.provider));
}
function resolveAuthRefreshScope(cfg: OpenClawConfig): {
providerIds: string[];
profileIds?: string[];
} {
const discovery = externalCliDiscoveryForConfigStatus({ cfg });
if (discovery.mode !== "scoped") {
return { providerIds: [] };
}
const providerIds = [...(discovery.providerIds ?? [])];
const profileIds = [...(discovery.profileIds ?? [])];
return {
providerIds,
...(profileIds.length > 0 ? { profileIds } : {}),
};
}
/**
* Invalidate auxiliary usage and prepared provider-auth state after an auth
* mutation. Auth health itself is rebuilt on every request; only outbound
@@ -99,16 +153,10 @@ async function refreshModelAuthStatusRuntimeState(): Promise<void> {
// still uses invalidateModelAuthStatusCache() and clears usage immediately.
clearCurrentProviderAuthState();
try {
if (await refreshActiveProviderAuthRuntimeSnapshot()) {
return;
}
await refreshActiveProviderAuthRuntimeSnapshot();
} catch (err) {
log.warn(`runtime auth snapshot refresh before auth status failed: ${formatForLog(err)}`);
return;
}
// Explicit status refresh follows CLI/doctor repairs. If no secrets runtime is
// active, drop runtime auth snapshots so the next status read observes disk.
clearRuntimeAuthProfileStoreSnapshots();
}
function readProviderParam(params: Record<string, unknown>): string | null {
@@ -578,12 +626,18 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
return;
}
}
const { agentId, agentDir } = scope;
// Use the external-profile-aware store for status reads so the dashboard
// reflects CLI-discovered credentials without persisting them here.
const store = ensureAuthProfileStore(agentDir, {
externalCli: externalCliDiscoveryForConfigStatus({ cfg }),
});
const preparedSnapshot = refreshRequested
? await loadDeferredCatalog(context, scope.agentId, {
readOnly: true,
authScope: resolveAuthRefreshScope(cfg),
refreshAuth: true,
})
: await readPreparedCatalog(context, scope.agentId);
if (!preparedSnapshot) {
throw new Error(`prepared model auth owner is unavailable (${scope.agentId})`);
}
cfg = preparedSnapshot.config;
const { agentId, agentDir, authStore: store, workspaceDir } = preparedSnapshot;
const apiKeys = resolveProviderApiKeys(cfg, store);
const configured = resolveConfiguredProviders(cfg, apiKeys);
const statusProviderIds = new Set(configured.providers);
@@ -601,6 +655,11 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
cfg,
providers: statusProviderIds.size > 0 ? [...statusProviderIds] : undefined,
allowKeychainPrompt: false,
authAliasLookupParams: {
workspaceDir,
metadataSnapshot: preparedSnapshot.metadataSnapshot,
includeUntrustedWorkspacePlugins: false,
},
});
// Usage queries usually need refreshable credentials. Keep API-key status
@@ -657,7 +716,12 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
configBoundProfileIds,
),
);
const result: ModelAuthStatusResult = { ts: now, providers };
const providerCapabilities = buildProviderCapabilities({
config: cfg,
workspaceDir,
metadataSnapshot: preparedSnapshot.metadataSnapshot,
});
const result: ModelAuthStatusResult = { ts: now, providers, providerCapabilities };
respond(true, result, undefined);
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
@@ -47,10 +47,18 @@ export type ModelAuthStatusProvider = {
};
};
export type ModelProviderCapability = {
provider: string;
apiKeySupported: boolean;
quickApiKeySetup: boolean;
};
export type ModelAuthStatusResult = {
/** Snapshot build time, ms since epoch. 0 = never loaded (UI fallback sentinel). */
ts: number;
providers: ModelAuthStatusProvider[];
/** Process-stable provider setup capabilities from the active plugin generation. */
providerCapabilities?: ModelProviderCapability[];
};
export type ModelAuthLogoutResult = {
@@ -511,6 +511,8 @@ export async function buildModelsListResult(
const initialConfig = params.context.getRuntimeConfig();
const initialAgentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(initialConfig));
const view = resolveModelsListView(params.params);
const preparedOnly = params.params.preparedOnly === true;
const refresh = params.params.refresh === true;
const preloadedCatalog =
params.preloadedCatalog?.agentId === initialAgentId &&
preparedModelRuntimeConfigsMatch(params.preloadedCatalog.config, initialConfig)
@@ -532,6 +534,8 @@ export async function buildModelsListResult(
cfg: initialConfig,
agentId: initialAgentId,
view,
preparedOnly,
refresh,
loadCatalog: async (loadParams) => {
loadedReadOnly = loadParams.readOnly ?? true;
// A read-only preload cannot satisfy a full-discovery request. Reuse it only when the
@@ -546,7 +550,11 @@ export async function buildModelsListResult(
if (params.preloadedOnly) {
return { entries: [], routeVariants: [] };
}
loadedSnapshot = await loadDeferredCatalog(params.context, initialAgentId, loadedReadOnly);
loadedSnapshot = await loadDeferredCatalog(params.context, initialAgentId, {
readOnly: loadedReadOnly,
refreshAuth: refresh && loadedReadOnly,
refreshFullCatalog: loadParams.refresh === true,
});
return loadedSnapshot;
},
onTimeout: handleCatalogTimeout,
@@ -554,6 +562,7 @@ export async function buildModelsListResult(
if (
loadedSnapshot &&
loadedReadOnly &&
!preparedOnly &&
modelCatalogBrowseRequiresFullDiscovery({
cfg: loadedSnapshot.config,
agentId: loadedSnapshot.agentId,
@@ -567,8 +576,13 @@ export async function buildModelsListResult(
cfg: loadedSnapshot.config,
agentId: escalationAgentId,
view,
refresh,
loadCatalog: async ({ readOnly }) => {
fullSnapshot = await loadDeferredCatalog(params.context, escalationAgentId, readOnly);
fullSnapshot = await loadDeferredCatalog(params.context, escalationAgentId, {
readOnly,
refreshAuth: refresh && readOnly,
refreshFullCatalog: refresh,
});
return fullSnapshot;
},
timeoutFullDiscovery: true,
+2 -5
View File
@@ -1,6 +1,5 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
// Models gateway methods expose model catalog browse results without triggering
// auth probes or fresh provider discovery on each request.
// Models gateway methods expose prepared, cached, and explicitly refreshed catalog views.
import { validateModelsListParams } from "../../../packages/gateway-protocol/src/index.js";
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
import { buildModelsListResult } from "./models-list-result.js";
@@ -9,9 +8,7 @@ import { assertValidParams } from "./validation.js";
export { buildModelsListResult };
// The gateway model list is a browse API, not an auth probe. It reuses the
// current runtime catalog snapshot and applies visibility rules without doing
// extra runtime discovery on each request.
// Automatic clients opt into preparedOnly; omitted mode preserves shipped wildcard discovery.
export const modelsHandlers: GatewayRequestHandlers = {
"models.list": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateModelsListParams, "models.list", respond)) {
+9 -2
View File
@@ -1,5 +1,6 @@
import type { RuntimeAuthMaterialization } from "../agents/auth-profiles/runtime-materializations.js";
import type { ResolvedPublishedModelCatalogOwner } from "../agents/prepared-model-catalog.types.js";
import type { PreparedModelRuntimeAuthScope } from "../agents/prepared-model-runtime-auth.js";
import type { GatewayRequestContext } from "./server-methods/shared-types.js";
import type { GatewayModelCatalogSnapshot } from "./server-model-catalog.types.js";
@@ -11,7 +12,10 @@ export type PreparedGatewayModelCatalogSnapshot = GatewayModelCatalogSnapshot &
type GatewayModelCatalogReadParams = {
agentId?: string;
agentDir?: string;
authScope?: PreparedModelRuntimeAuthScope;
readOnly?: boolean;
refreshAuth?: boolean;
refreshFullCatalog?: boolean;
workspaceDir?: string;
};
@@ -50,9 +54,12 @@ function requirePrivateAccess(
export async function loadDeferredCatalog(
context: Pick<GatewayRequestContext, "loadGatewayModelCatalogSnapshot">,
agentId: string,
readOnly: boolean,
options: Pick<
GatewayModelCatalogReadParams,
"authScope" | "readOnly" | "refreshAuth" | "refreshFullCatalog"
>,
): Promise<PreparedGatewayModelCatalogSnapshot> {
return await requirePrivateAccess(context).loadDeferred({ agentId, readOnly });
return await requirePrivateAccess(context).loadDeferred({ agentId, ...options });
}
export async function readPreparedCatalog(
+84 -7
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
import type { PublishedModelCatalogOwnerCandidate } from "../agents/prepared-model-catalog.types.js";
import { setPreparedModelRuntimeAuthLoader } from "../agents/prepared-model-runtime-auth.js";
import { PreparedModelRuntimePublicationSupersededError } from "../agents/prepared-model-runtime.errors.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
loadDeferredCatalog,
@@ -134,24 +135,34 @@ describe("gateway prepared model catalog", () => {
}),
);
registerGatewayModelCatalogPrivateAccess(publicLoader, {
loadDeferred: () =>
loadDeferred: (params) =>
loadPreparedGatewayModelCatalogSnapshot({
...params,
getConfig: () => config,
loadPublishedPreparedModelCatalogOwnerSnapshot,
refreshAuth: true,
}),
readPrepared: async () => undefined,
});
const loaded = await loadDeferredCatalog(
{ loadGatewayModelCatalogSnapshot: publicLoader },
"main",
true,
{
readOnly: true,
authScope: {
providerIds: ["openai"],
profileIds: ["openai:refreshed"],
},
refreshAuth: true,
},
);
expect(loaded.authStore).toEqual(
expect.objectContaining({ profiles: { "openai:refreshed": expect.any(Object) } }),
);
expect(loaded.authModes).toEqual({ openai: "api_key" });
expect(loadAuth).toHaveBeenCalledWith(["openai"]);
expect(loadAuth).toHaveBeenCalledWith({
providerIds: ["openai"],
profileIds: ["openai:refreshed"],
});
});
it("removes stale prepared auth modes when deferred auth observes logout", async () => {
@@ -172,24 +183,88 @@ describe("gateway prepared model catalog", () => {
}),
);
registerGatewayModelCatalogPrivateAccess(publicLoader, {
loadDeferred: () =>
loadDeferred: (params) =>
loadPreparedGatewayModelCatalogSnapshot({
...params,
getConfig: () => config,
loadPublishedPreparedModelCatalogOwnerSnapshot: async () => candidate,
refreshAuth: true,
}),
readPrepared: async () => undefined,
});
const loaded = await loadDeferredCatalog(
{ loadGatewayModelCatalogSnapshot: publicLoader },
"main",
true,
{ readOnly: true, refreshAuth: true },
);
expect(loaded.authStore?.profiles).toEqual({});
expect(loaded.authModes).toEqual({});
});
it("retries the whole owner projection when deferred auth supersedes its generation", async () => {
const staleConfig = ownerConfig("main", { logging: { level: "info" } });
const currentConfig = ownerConfig("main", { logging: { level: "debug" } });
const staleCatalog: ModelCatalogSnapshot = {
entries: [{ provider: "openai", id: "stale", name: "Stale" }],
routeVariants: [],
};
const currentCatalog: ModelCatalogSnapshot = {
entries: [{ provider: "openai", id: "current", name: "Current" }],
routeVariants: [],
};
const stale = {
...ownerSnapshot(staleConfig, staleCatalog),
authModes: { openai: "oauth" as const },
authStore: {
version: 1 as const,
profiles: {
"openai:stale": {
type: "token" as const,
provider: "openai",
token: "stale-token-not-real",
},
},
},
};
const current = {
...ownerSnapshot(currentConfig, currentCatalog),
authModes: { openai: "api_key" as const },
authStore: {
version: 1 as const,
profiles: {
"openai:current": {
type: "api_key" as const,
provider: "openai",
key: "current-key-not-real",
},
},
},
};
setPreparedModelRuntimeAuthLoader(stale, async () => {
throw new PreparedModelRuntimePublicationSupersededError("superseded");
});
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi
.fn()
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(current);
await expect(
loadPreparedGatewayModelCatalogSnapshot({
getConfig: () => staleConfig,
loadPublishedPreparedModelCatalogOwnerSnapshot,
refreshAuth: true,
}),
).resolves.toMatchObject({
config: currentConfig,
entries: currentCatalog.entries,
authModes: { openai: "api_key" },
authStore: {
profiles: { "openai:current": expect.any(Object) },
},
});
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledTimes(2);
});
it("rejects an ambiguous owner without an authoritative agent identity", async () => {
const config = {
agents: {
@@ -248,11 +323,13 @@ describe("gateway prepared model catalog", () => {
getConfig: () => config,
loadPublishedPreparedModelCatalogOwnerSnapshot,
readOnly: false,
refreshFullCatalog: true,
}),
).resolves.toMatchObject(snapshot);
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledWith({
config,
readOnly: false,
refreshFullCatalog: true,
});
});
+34 -14
View File
@@ -6,7 +6,9 @@ import type {
import {
getPreparedModelRuntimeAuthMaterializations,
loadPreparedModelRuntimeAuth,
type PreparedModelRuntimeAuthScope,
} from "../agents/prepared-model-runtime-auth.js";
import { PreparedModelRuntimePublicationSupersededError } from "../agents/prepared-model-runtime.errors.js";
// Gateway catalog reads use the atomic prepared runtime generation.
import { getRuntimeConfig } from "../config/io.js";
import type { PreparedGatewayModelCatalogSnapshot } from "./server-model-catalog-auth.js";
@@ -21,6 +23,7 @@ type LoadPublishedPreparedModelCatalogOwnerSnapshot = (params: {
agentDir?: string;
config: GatewayModelCatalogConfig;
readOnly?: boolean;
refreshFullCatalog?: boolean;
workspaceDir?: string;
}) => Promise<PublishedModelCatalogOwnerCandidate>;
type LoadGatewayModelCatalogParams = {
@@ -29,9 +32,11 @@ type LoadGatewayModelCatalogParams = {
getConfig?: () => GatewayModelCatalogConfig;
loadPublishedPreparedModelCatalogOwnerSnapshot?: LoadPublishedPreparedModelCatalogOwnerSnapshot;
readOnly?: boolean;
refreshFullCatalog?: boolean;
workspaceDir?: string;
};
type LoadPreparedGatewayModelCatalogParams = LoadGatewayModelCatalogParams & {
authScope?: PreparedModelRuntimeAuthScope;
refreshAuth?: boolean;
};
@@ -71,6 +76,7 @@ async function loadGatewayModelCatalogOwnerSnapshot(
...(params?.agentDir ? { agentDir: params.agentDir } : {}),
config: (params?.getConfig ?? getRuntimeConfig)(),
readOnly: params?.readOnly !== false,
...(params?.refreshFullCatalog ? { refreshFullCatalog: true } : {}),
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
const owner = resolvePublishedModelCatalogOwner(candidate);
@@ -101,20 +107,34 @@ function projectGatewayModelCatalogSnapshot(
export async function loadPreparedGatewayModelCatalogSnapshot(
params?: LoadPreparedGatewayModelCatalogParams,
): Promise<PreparedGatewayModelCatalogSnapshot> {
const { candidate, owner } = await loadGatewayModelCatalogOwnerSnapshot(params);
const refreshedAuth = params?.refreshAuth
? await loadPreparedModelRuntimeAuth(
candidate,
owner.modelCatalog.entries.map((entry) => entry.provider),
).catch(() => undefined)
: undefined;
return {
...projectGatewayModelCatalogSnapshot(owner),
authModes: refreshedAuth?.authModes ?? owner.authModes,
authStore: refreshedAuth?.authStore ?? owner.authStore,
metadataSnapshot: owner.metadataSnapshot,
authMaterializations: owner.authMaterializations,
};
for (;;) {
const { candidate, owner } = await loadGatewayModelCatalogOwnerSnapshot(params);
let refreshedAuth: Awaited<ReturnType<typeof loadPreparedModelRuntimeAuth>>;
try {
refreshedAuth = params?.refreshAuth
? await loadPreparedModelRuntimeAuth(
candidate,
params.authScope ?? {
providerIds: owner.modelCatalog.entries.map((entry) => entry.provider),
},
)
: undefined;
} catch (error) {
if (error instanceof PreparedModelRuntimePublicationSupersededError) {
// Supersession invalidates every captured owner fact. Reacquire the whole owner so
// replacement auth cannot be combined with stale catalog or metadata.
continue;
}
refreshedAuth = undefined;
}
return {
...projectGatewayModelCatalogSnapshot(owner),
authModes: refreshedAuth?.authModes ?? owner.authModes,
authStore: refreshedAuth?.authStore ?? owner.authStore,
metadataSnapshot: owner.metadataSnapshot,
authMaterializations: owner.authMaterializations,
};
}
}
export async function loadGatewayModelCatalogSnapshot(
+2
View File
@@ -27,6 +27,7 @@ type ModelPickerParams = {
invalid?: boolean;
describedBy?: string;
};
onOpen?: () => void;
onChange: (value: string) => void;
};
@@ -52,6 +53,7 @@ export function renderModelPicker(params: ModelPickerParams) {
title: params.title,
placement: params.placement,
className: `model-picker__select ${params.className ?? ""}`,
onOpen: params.onOpen,
renderLeading: (option) =>
option.provider
? renderProviderBrandIcon(option.provider, { className: "model-picker__provider-icon" })
+2
View File
@@ -17,6 +17,7 @@ export type PickerParams<Option extends PickerOption> = {
className?: string;
title?: string;
placement?: "top" | "bottom";
onOpen?: () => void;
onChange: (value: string) => void;
onChangeTarget?: (value: string, select: HTMLElement) => void;
renderLeading?: (option: Option) => unknown;
@@ -46,6 +47,7 @@ export function renderPicker<Option extends PickerOption>(params: PickerParams<O
placement=${params.placement ?? nothing}
.value=${params.value}
?disabled=${params.disabled}
@wa-show=${() => params.onOpen?.()}
@change=${(event: Event) => {
const value = (event.currentTarget as HTMLElement & { value?: unknown }).value;
const option = typeof value === "string" && options.find((entry) => entry.value === value);
+7 -12
View File
@@ -160,9 +160,7 @@ suite.define(() => {
await expect.poll(() => model.isVisible()).toBe(true);
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
const modelRequests = await gateway.getRequests("models.list");
expect(modelRequests).toHaveLength(1);
expect(modelRequests[0]?.params).toEqual({ view: "configured" });
expect(await gateway.getRequests("models.list")).toHaveLength(0);
await expect.poll(() => contextUsage.isVisible()).toBe(true);
await expect.poll(() => usage.isVisible()).toBe(false);
await expect.poll(() => settings.isVisible()).toBe(true);
@@ -846,9 +844,7 @@ suite.define(() => {
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
)
.toBe(1);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { view: "configured" } }),
]);
expect(await gateway.getRequests("models.list")).toHaveLength(0);
await navigateToControlUiSession(page, "agent:other:main");
const startupRequests = await gateway.getRequests("chat.startup");
@@ -870,10 +866,7 @@ suite.define(() => {
activeComposer().locator('[data-chat-model-option="openai/work-model"]').count(),
)
.toBe(0);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { view: "configured" } }),
expect.objectContaining({ params: { agentId: "other", view: "configured" } }),
]);
expect(await gateway.getRequests("models.list")).toHaveLength(0);
});
});
@@ -921,7 +914,7 @@ suite.define(() => {
"models.list": {
cases: [
{
match: { agentId: "work", view: "configured" },
match: { agentId: "work", view: "configured", preparedOnly: true },
response: { models: [] },
},
],
@@ -946,7 +939,9 @@ suite.define(() => {
.not.toContain("GPT Default");
expect(await gateway.getRequests("chat.metadata")).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({ params: { agentId: "work", view: "configured" } }),
expect.objectContaining({
params: { agentId: "work", view: "configured", preparedOnly: true },
}),
]);
});
});
+50 -6
View File
@@ -71,7 +71,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
await server?.close();
});
it("surfaces rejected provider credentials as the primary setup action", async () => {
it("defers live provider discovery until refresh while preserving model setup", async () => {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
@@ -80,7 +80,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
});
const page = await context.newPage();
const config = { auth: { profiles: { "openai:chatgpt": { provider: "openai" } } } };
await installMockGateway(page, {
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "models.probe", "openclaw.setup.detect"],
methodResponses: {
"config.get": {
@@ -95,7 +95,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
cases: [
{ match: { view: "configured" }, response: { models: [] } },
{
match: { view: "all", includeProviderCapabilities: true },
match: { view: "all", agentId: "main", refresh: true },
response: {
models: [],
providerOutcomes: [{ provider: "openai", status: "auth-rejected" }],
@@ -135,8 +135,19 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
.poll(async () => readiness.textContent())
.toContain("Connect a verified AI model");
await expect.poll(async () => readiness.textContent()).toContain("Model required");
await expect.poll(async () => openaiCard.textContent()).toContain("Credentials rejected");
await expect.poll(async () => openaiCard.textContent()).toContain("Credentials configured");
await expect.poll(async () => openaiCard.textContent()).not.toContain("Signed in");
expect(
(await gateway.getRequests("models.list")).filter(
(request) => (request.params as { view?: string } | undefined)?.view === "all",
),
).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({
params: { agentId: "main", preparedOnly: true, view: "configured" },
}),
]);
expect(await page.getByRole("heading", { name: "Add provider" }).count()).toBe(0);
expect(await page.locator(".model-providers__defaults").count()).toBe(0);
if (recordVisuals) {
@@ -166,6 +177,14 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
await page.setViewportSize({ height: 1000, width: 1440 });
}
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await expect.poll(async () => openaiCard.textContent()).toContain("Credentials rejected");
expect(
(await gateway.getRequests("models.list")).filter(
(request) => (request.params as { view?: string } | undefined)?.view === "all",
),
).toHaveLength(1);
await readiness.getByRole("button", { name: "Connect a verified AI model" }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/model-setup");
} finally {
@@ -189,6 +208,11 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
methodResponses: {
"models.authStatus": {
ts: NOW,
providerCapabilities: [
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "google", apiKeySupported: true, quickApiKeySetup: true },
],
providers: [
{
provider: "claude-cli",
@@ -274,7 +298,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
.poll(async () => claudeCard.locator(".settings-row__desc").first().textContent())
.toContain("anthropic");
await expect.poll(async () => claudeCard.textContent()).toContain("Max 20x");
await expect.poll(async () => claudeCard.textContent()).toContain("Ready");
await expect.poll(async () => claudeCard.textContent()).toContain("Credentials configured");
await expect.poll(async () => claudeCard.textContent()).toContain("$4.20");
await claudeCard.locator(".provider-usage-progress").first().waitFor();
@@ -433,7 +457,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
},
},
{
match: { view: "all", includeProviderCapabilities: true },
match: { view: "all", agentId: "main", refresh: true },
response: {
models: [
{
@@ -464,6 +488,11 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
},
"models.authStatus": {
ts: NOW,
providerCapabilities: [
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "google", apiKeySupported: true, quickApiKeySetup: true },
],
providers: [
{
provider: "openai",
@@ -495,6 +524,16 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
await page.goto(`${server.baseUrl}settings/model-providers`);
const openaiCard = page.locator('[data-provider-id="openai"]');
await openaiCard.waitFor();
expect(
(await gateway.getRequests("models.list")).filter(
(request) => (request.params as { view?: string } | undefined)?.view === "all",
),
).toHaveLength(0);
expect(await gateway.getRequests("models.list")).toEqual([
expect.objectContaining({
params: { agentId: "main", preparedOnly: true, view: "configured" },
}),
]);
await expect.poll(async () => openaiCard.textContent()).toContain("API key set in config");
await expect
.poll(() => modelPickerValue(page.locator(".model-providers__defaults wa-select").first()))
@@ -588,6 +627,11 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => {
});
await gateway.setMethodResponse("models.authStatus", {
ts: NOW,
providerCapabilities: [
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "google", apiKeySupported: true, quickApiKeySetup: true },
],
providers: [
{
provider: "openai",
+1
View File
@@ -4047,6 +4047,7 @@ export const en: TranslationMap = {
missing: "Not signed in",
apiKey: "API key",
denied: "Credentials rejected",
configured: "Credentials configured",
},
expiresIn: "Credential expires in {time}",
models: "{count} models",
+4 -1
View File
@@ -282,7 +282,10 @@ describe("cron controller", () => {
await loadCronModelSuggestions(state);
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
preparedOnly: true,
});
expect(state.cronModelSuggestions).toEqual(["a-model", "z-model"]);
});
+4 -1
View File
@@ -394,7 +394,10 @@ export async function loadCronModelSuggestions(state: CronModelSuggestionsState)
return;
}
try {
const res = await state.client.request("models.list", { view: "configured" });
const res = await state.client.request("models.list", {
view: "configured",
preparedOnly: true,
});
const models = (res as { models?: unknown[] } | null)?.models;
if (!Array.isArray(models)) {
state.cronModelSuggestions = [];
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { loadGatewayDiagnostics } from "./gateway-diagnostics.ts";
describe("loadGatewayDiagnostics", () => {
it("reads only the prepared model catalog during automatic diagnostics", async () => {
const request = vi.fn(async (method: string) =>
method === "models.list" ? { models: [] } : {},
);
await loadGatewayDiagnostics({ request } as unknown as GatewayBrowserClient);
expect(request).toHaveBeenCalledWith(
"models.list",
{ preparedOnly: true },
{ signal: undefined },
);
});
});
+1 -1
View File
@@ -15,7 +15,7 @@ export async function loadGatewayDiagnostics(
const [status, health, models, heartbeat] = await Promise.all([
client.request("status", {}, { signal }),
client.request("health", {}, { signal }),
client.request("models.list", {}, { signal }),
client.request("models.list", { preparedOnly: true }, { signal }),
client.request("last-heartbeat", {}, { signal }),
]);
const modelPayload = models as { models?: unknown[] } | undefined;
@@ -8,6 +8,7 @@ import {
import { readChatSessionActionAccess } from "./chat-session-action-access.ts";
import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import { refreshChatModelCatalogOnDemand } from "./chat-state-refresh.ts";
import type { ChatProps } from "./chat-view.ts";
import { renderChatModelControls } from "./components/chat-model-controls.ts";
@@ -74,6 +75,7 @@ export function renderChatPaneComposerControls(params: {
effortAccess.allowed
? switchChatFastMode(state, next, targetSessionKey)
: Promise.resolve(false),
onModelPickerOpen: () => refreshChatModelCatalogOnDemand(state),
onModelSelect: (next, targetSessionKey) =>
modelAccess.allowed
? switchChatModel(state, next, targetSessionKey)
+8 -15
View File
@@ -363,7 +363,7 @@ describe("refreshChat", () => {
expect(requestUpdate).not.toHaveBeenCalled();
});
it("uses explicit model discovery after startup metadata", async () => {
it("uses prepared models delivered with startup metadata", async () => {
const startup = createDeferred<unknown>();
const host = makeChatHost({
hello: {
@@ -371,16 +371,6 @@ describe("refreshChat", () => {
} as TestChatHost["hello"],
requestHandlers: {
"chat.startup": () => startup.promise,
"models.list": {
models: [
{
available: true,
id: "live-model",
name: "Live Model",
provider: "openai",
},
],
},
},
});
@@ -412,14 +402,14 @@ describe("refreshChat", () => {
expect(host.chatModelCatalog).toEqual([
{
available: true,
id: "live-model",
name: "Live Model",
id: "startup-model",
name: "Startup Model",
provider: "openai",
},
]),
);
expect(host.request).not.toHaveBeenCalledWith("chat.metadata", expect.anything());
expect(host.request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(host.request).not.toHaveBeenCalledWith("models.list", expect.anything());
expect(host.request).not.toHaveBeenCalledWith("commands.list", expect.anything());
});
@@ -479,7 +469,10 @@ describe("refreshChat", () => {
]),
);
expect(SLASH_COMMANDS.some((command) => command.name === "startup-gap-command")).toBe(true);
expect(host.request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(host.request).toHaveBeenCalledWith("models.list", {
view: "configured",
preparedOnly: true,
});
expect(host.request).toHaveBeenCalledWith(
"commands.list",
expect.objectContaining({ includeArgs: true, scope: "text" }),
+36 -5
View File
@@ -185,7 +185,7 @@ async function refreshCompatibilityModelCatalog(
: request.agentId?.trim() || undefined;
const models = await loadModels(request.client, {
...(agentId ? { agentId } : {}),
...(opts?.refresh ? { refresh: true } : {}),
...(opts?.refresh ? { refresh: true } : { preparedOnly: true }),
});
if (ownsChatMetadataRequest(request)) {
request.host.chatModelCatalog = models;
@@ -298,6 +298,39 @@ export async function refreshChatModelAuthStatus(host: ChatPageHost, opts?: { re
}
}
export async function refreshChatModelCatalogOnDemand(host: ChatPageHost): Promise<void> {
if (!host.client || !host.connected) {
return;
}
const client = host.client;
const agentId = resolveChatAgentId(host);
const connectionEpoch = host.connectionEpoch;
host.chatModelsLoading = true;
try {
const models = await loadModels(client, {
...(agentId ? { agentId } : {}),
});
if (
host.client === client &&
host.connected &&
host.connectionEpoch === connectionEpoch &&
resolveChatAgentId(host) === agentId
) {
host.chatModelCatalog = models;
}
} finally {
if (
host.client === client &&
host.connected &&
host.connectionEpoch === connectionEpoch &&
resolveChatAgentId(host) === agentId
) {
host.chatModelsLoading = false;
host.requestUpdate?.();
}
}
}
async function refreshChat(
host: ChatPageHost,
opts?: ChatRefreshOptions & {
@@ -445,11 +478,9 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) {
return;
}
rememberChatMetadata(client, agentId, metadata);
// Startup metadata stays on the published static catalog so opening chat never waits on
// provider discovery. The explicit models.list read below owns the live picker inventory.
const applied = applyChatMetadataResult(host, client, agentId, metadata, { models: false });
const applied = applyChatMetadataResult(host, client, agentId, metadata);
if (!applied.models || !applied.commands) {
await refreshMissingChatMetadata(request, applied, { refreshModelCatalog: true });
await refreshMissingChatMetadata(request, applied);
}
} finally {
if (ownsChatMetadataRequest(request)) {
+2 -2
View File
@@ -1609,7 +1609,7 @@ describe("refreshChatMetadata", () => {
it("loads compatibility models when the gateway does not advertise chat metadata", async () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "models.list") {
expect(params).toEqual({ view: "configured" });
expect(params).toEqual({ view: "configured", preparedOnly: true });
return {
models: [{ id: "compat-model", name: "Compat Model", provider: "openai" }],
};
@@ -1661,7 +1661,7 @@ describe("refreshChatMetadata", () => {
it("loads agent-scoped compatibility models for a non-default agent", async () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "models.list") {
expect(params).toEqual({ view: "configured", agentId: "work" });
expect(params).toEqual({ view: "configured", agentId: "work", preparedOnly: true });
return {
models: [{ id: "work-model", name: "Work Model", provider: "openai" }],
};
+12
View File
@@ -5484,6 +5484,18 @@ describe("chat model controls", () => {
expect(onModelSelect).toHaveBeenCalledWith(modelOption?.dataset.chatModelOption, "main");
});
it("requests live wildcard discovery when the model picker opens", () => {
const { state } = createOpenAiHeaderState();
const onModelPickerOpen = vi.fn();
const container = renderModelControls(state, { onModelPickerOpen });
getChatModelSelect(container).dispatchEvent(
new CustomEvent("wa-show", { bubbles: true, composed: true }),
);
expect(onModelPickerOpen).toHaveBeenCalledOnce();
});
it("keeps model enabled while write-only access disables effort controls", () => {
const { state } = createOpenAiHeaderState();
const onFastModeSelect = vi.fn(async () => true);
@@ -47,6 +47,7 @@ type ChatModelControlsProps = {
thinkingSession?: ChatThinkingTarget;
onFastModeSelect?: (value: ChatFastModeSelectValue, sessionKey: string) => unknown;
onModelSetup?: () => void;
onModelPickerOpen?: () => unknown;
onModelSelect?: (value: string, sessionKey: string) => unknown;
onModelPickerTargetSelect?: (groupId: string, value: string) => unknown;
onRequestUpdate?: () => void;
@@ -336,6 +337,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
triggerModelLabel: formatPickerModelLabel(committedModelLabel),
triggerStatusLabel: catalogTriggerStatus,
onModelSetup: props.onModelSetup,
onOpen: props.onModelPickerOpen,
onModelSelect: async (next, targetSessionKey) =>
props.onModelSelect?.(next, targetSessionKey),
onTargetSelect: props.onModelPickerTargetSelect,
@@ -32,6 +32,7 @@ type ChatModelPickerParams = {
triggerModelLabel: string;
triggerStatusLabel?: string;
onModelSetup?: () => void;
onOpen?: () => unknown;
onModelSelect: (value: string, sessionKey: string) => Promise<unknown>;
onTargetSelect?: (groupId: string, value: string) => unknown;
onRequestUpdate?: () => void;
@@ -378,6 +379,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
resetModelSearch(details);
return;
}
void params.onOpen?.();
queueMicrotask(() => {
const input = details.querySelector<HTMLInputElement>("[data-chat-model-search]");
if (input) {
+33
View File
@@ -19,6 +19,17 @@ describe("loadModels", () => {
]);
});
it("requests only the prepared catalog for automatic reads", async () => {
const request = vi.fn(async () => ({ models: [] }));
await loadModels({ request } as unknown as GatewayBrowserClient, { preparedOnly: true });
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
preparedOnly: true,
});
});
it("reuses the configured model list while the cache is fresh", async () => {
const request = vi.fn(async () => ({
models: [{ id: "gpt-5.5", name: "GPT-5.5", provider: "openai" }],
@@ -82,6 +93,28 @@ describe("loadModels", () => {
expect(await loadModels(client)).toEqual(fresh);
expect(request).toHaveBeenCalledTimes(2);
});
it("coalesces concurrent refreshes without reusing a completed refresh", async () => {
let releaseRefresh: (() => void) | undefined;
const refreshGate = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
const request = vi.fn(async () => {
await refreshGate;
return { models: [{ id: "fresh", name: "Fresh", provider: "openai" }] };
});
const client = { request } as unknown as GatewayBrowserClient;
const first = loadModels(client, { agentId: "writer", refresh: true });
const concurrent = loadModels(client, { agentId: "writer", refresh: true });
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
releaseRefresh?.();
expect(await concurrent).toBe(await first);
await loadModels(client, { agentId: "writer", refresh: true });
expect(request).toHaveBeenCalledTimes(2);
});
});
describe("applyModelCatalogResult", () => {
+10 -4
View File
@@ -8,6 +8,7 @@ type ModelCatalogCacheEntry = {
expiresAt: number;
models: ModelCatalogEntry[];
inFlight?: Promise<ModelCatalogEntry[]>;
inFlightRefresh?: boolean;
};
const modelCatalogCache = new WeakMap<GatewayBrowserClient, Map<string, ModelCatalogCacheEntry>>();
@@ -23,16 +24,17 @@ function modelCatalogCacheFor(client: GatewayBrowserClient): Map<string, ModelCa
export async function loadModels(
client: GatewayBrowserClient,
opts?: { agentId?: string; refresh?: boolean },
opts?: { agentId?: string; preparedOnly?: boolean; refresh?: boolean },
): Promise<ModelCatalogEntry[]> {
const cache = modelCatalogCacheFor(client);
const cacheKey = opts?.agentId?.trim() ?? "";
const agentId = opts?.agentId?.trim() ?? "";
const cacheKey = `${agentId}\0${opts?.preparedOnly ? "prepared" : "exact"}`;
const cached = cache.get(cacheKey);
const now = Date.now();
if (!opts?.refresh && cached?.models && cached.expiresAt > now) {
return cached.models;
}
if (!opts?.refresh && cached?.inFlight) {
if (cached?.inFlight && (!opts?.refresh || cached.inFlightRefresh === true)) {
return cached.inFlight;
}
@@ -42,7 +44,8 @@ export async function loadModels(
const inFlight: Promise<ModelCatalogEntry[]> = requestModels(
client,
cached?.models,
cacheKey || undefined,
agentId || undefined,
opts?.preparedOnly === true,
)
.then((result) => {
const latest = cache.get(cacheKey);
@@ -64,6 +67,7 @@ export async function loadModels(
expiresAt: cached?.expiresAt ?? 0,
models: cached?.models ?? [],
inFlight,
...(opts?.refresh ? { inFlightRefresh: true } : {}),
});
return inFlight;
}
@@ -79,11 +83,13 @@ async function requestModels(
client: GatewayBrowserClient,
fallback: ModelCatalogEntry[] | undefined,
agentId: string | undefined,
preparedOnly: boolean,
): Promise<{ models: ModelCatalogEntry[]; fresh: boolean }> {
try {
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {
view: "configured",
...(agentId ? { agentId } : {}),
...(preparedOnly ? { preparedOnly: true } : {}),
});
return { models: result?.models ?? [], fresh: true };
} catch {
+3
View File
@@ -466,6 +466,8 @@ describe("ConfigPage session observer models", () => {
await firstLoad;
expect(state.sessionObserverModels).toEqual(currentModels);
expect(chatModels.loadModels).toHaveBeenCalledTimes(2);
expect(chatModels.loadModels).toHaveBeenNthCalledWith(1, firstClient, { preparedOnly: true });
expect(chatModels.loadModels).toHaveBeenNthCalledWith(2, secondClient, { preparedOnly: true });
});
it("retries a transient catalog failure on the next status refresh", async () => {
@@ -498,6 +500,7 @@ describe("ConfigPage session observer models", () => {
expect(state.sessionObserverModels).toEqual(recoveredModels);
expect(state.sessionObserverModelsUnavailable).toBe(false);
expect(chatModels.loadModels).toHaveBeenCalledTimes(2);
expect(chatModels.loadModels).toHaveBeenLastCalledWith(client, { preparedOnly: true });
});
});
+1 -1
View File
@@ -754,7 +754,7 @@ export class ConfigPage extends OpenClawLightDomElement {
return existing;
}
const gatewaySource = this.systemInfoGatewaySource;
const promise = loadModels(client)
const promise = loadModels(client, { preparedOnly: true })
.then((models) => {
if (
this.isConnected &&
+12 -7
View File
@@ -19,8 +19,11 @@ function catalogEntry(overrides: Partial<ModelCatalogEntry> & { provider: string
} satisfies ModelCatalogEntry;
}
function authStatus(providers: ModelAuthStatusResult["providers"]): ModelAuthStatusResult {
return { ts: 1, providers };
function authStatus(
providers: ModelAuthStatusResult["providers"],
providerCapabilities?: ModelAuthStatusResult["providerCapabilities"],
): ModelAuthStatusResult {
return { ts: 1, providers, ...(providerCapabilities ? { providerCapabilities } : {}) };
}
function firstCard(cards: ReturnType<typeof buildModelProviderCards>) {
@@ -75,7 +78,10 @@ describe("buildModelProviderCards", () => {
const cards = buildModelProviderCards({
...EMPTY_INPUT,
models: [catalogEntry({ provider: "github-copilot", available: true })],
catalogModels: [catalogEntry({ provider: "github-copilot", apiKeySupported: false })],
authStatus: authStatus(
[],
[{ provider: "github-copilot", apiKeySupported: false, quickApiKeySetup: false }],
),
});
expect(firstCard(cards).apiKeySupported).toBe(false);
});
@@ -365,10 +371,9 @@ describe("model provider configuration data", () => {
it("lists known providers that are not configured", () => {
const options = buildUnconfiguredProviderOptions(
[
catalogEntry({ provider: "openai", apiKeySupported: true }),
catalogEntry({ provider: "anthropic", apiKeySupported: true }),
catalogEntry({ provider: "anthropic", id: "anthropic/other", apiKeySupported: true }),
catalogEntry({ provider: "github-copilot", apiKeySupported: false }),
{ provider: "openai", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
{ provider: "github-copilot", apiKeySupported: true, quickApiKeySetup: false },
],
["openai"],
);
+10 -9
View File
@@ -64,7 +64,6 @@ export type ModelProviderCard = {
type ModelProviderCardsInput = {
authStatus: ModelAuthStatusResult | null;
models: ModelCatalogEntry[] | null;
catalogModels?: ModelCatalogEntry[] | null;
providerOutcomes?: ModelCatalogProviderOutcome[];
configProviderIds?: string[] | null;
configApiKeyProviderIds?: string[] | null;
@@ -194,12 +193,12 @@ function addLogoutTarget(
export function buildModelProviderCards(input: ModelProviderCardsInput): ModelProviderCard[] {
const drafts: CardDraft[] = [];
const apiKeyCapabilities = new Map<string, boolean>();
for (const entry of input.catalogModels ?? []) {
const id = canonicalProviderId(entry.provider);
if (!id || entry.apiKeySupported === undefined) {
for (const capability of input.authStatus?.providerCapabilities ?? []) {
const id = canonicalProviderId(capability.provider);
if (!id) {
continue;
}
apiKeyCapabilities.set(id, apiKeyCapabilities.get(id) === true || entry.apiKeySupported);
apiKeyCapabilities.set(id, apiKeyCapabilities.get(id) === true || capability.apiKeySupported);
}
for (const provider of input.configProviderIds ?? []) {
@@ -462,15 +461,17 @@ export function readModelProviderConfig(config: Record<string, unknown> | null):
export type ProviderOption = { id: string; displayName: string };
type ModelProviderCapability = NonNullable<ModelAuthStatusResult["providerCapabilities"]>[number];
export function buildUnconfiguredProviderOptions(
models: ModelCatalogEntry[] | null,
capabilities: ModelProviderCapability[] | undefined,
configuredProviderIds: Iterable<string>,
): ProviderOption[] {
const configured = new Set(Array.from(configuredProviderIds, canonicalProviderId));
const options = new Map<string, ProviderOption>();
for (const model of models ?? []) {
const id = canonicalProviderId(model.provider);
if (model.apiKeySupported === true && id && !configured.has(id) && !options.has(id)) {
for (const capability of capabilities ?? []) {
const id = canonicalProviderId(capability.provider);
if (capability.quickApiKeySetup && id && !configured.has(id) && !options.has(id)) {
options.set(id, { id, displayName: providerDisplayLabel(id) });
}
}
+79 -1
View File
@@ -3,6 +3,40 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { loadModelProvidersData } from "./load.ts";
describe("loadModelProvidersData", () => {
it("keeps full catalog discovery out of the initial page load", async () => {
const request = vi.fn(async (method: string, _params?: unknown) => {
switch (method) {
case "models.authStatus":
return { ts: 1, providers: [], providerCapabilities: [] };
case "models.list":
return { models: [] };
case "config.get":
return { config: {}, hash: "hash" };
case "usage.status":
return { updatedAt: 1, providers: [] };
case "sessions.usage":
return { aggregates: { byProvider: [] } };
default:
return {};
}
});
const client = { request } as unknown as GatewayBrowserClient;
await loadModelProvidersData(client, { agentId: "writer" });
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
agentId: "writer",
preparedOnly: true,
});
expect(
request.mock.calls.filter(
([method, params]) =>
method === "models.list" && (params as { view?: string } | undefined)?.view === "all",
),
).toHaveLength(0);
});
it("scopes only credential status to the selected agent", async () => {
const request = vi.fn(async (method: string, _params?: unknown) => {
switch (method) {
@@ -28,6 +62,15 @@ describe("loadModelProvidersData", () => {
refresh: true,
agentId: "writer",
});
expect(request).toHaveBeenCalledWith("models.list", {
view: "all",
agentId: "writer",
refresh: true,
});
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
agentId: "writer",
});
expect(request).toHaveBeenCalledWith("usage.status");
const sessionUsageCall = request.mock.calls.find(([method]) => method === "sessions.usage");
expect(sessionUsageCall?.[1]).not.toHaveProperty("agentId");
@@ -57,10 +100,45 @@ describe("loadModelProvidersData", () => {
expect(result.authStatus).toBeNull();
expect(result.models).toEqual([]);
expect(result.catalogModels).toEqual([]);
expect(result.providerOutcomes).toEqual([]);
expect(result.catalogError).toBeNull();
expect(result.config).toEqual({});
expect(result.providerUsage).toEqual({ updatedAt: 1, providers: [] });
expect(result.costByProvider).toEqual([]);
expect(result.error).toBeNull();
});
it("surfaces an explicit catalog refresh failure while retaining cached configured models", async () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "models.list" && (params as { view?: string } | undefined)?.view === "all") {
throw new Error("catalog refresh failed");
}
switch (method) {
case "models.authStatus":
return { ts: 1, providers: [], providerCapabilities: [] };
case "models.list":
return {
models: [{ id: "cached", name: "Cached", provider: "openai" }],
};
case "config.get":
return { config: {}, hash: "hash" };
case "usage.status":
return { updatedAt: 1, providers: [] };
case "sessions.usage":
return { aggregates: { byProvider: [] } };
default:
return {};
}
});
const client = { request } as unknown as GatewayBrowserClient;
const result = await loadModelProvidersData(client, { refresh: true, agentId: "writer" });
expect(result.catalogError).toBe("catalog refresh failed");
expect(result.models).toEqual([{ id: "cached", name: "Cached", provider: "openai" }]);
expect(request).toHaveBeenCalledWith("models.list", {
view: "configured",
agentId: "writer",
});
});
});
+26 -12
View File
@@ -25,8 +25,8 @@ export const MODEL_PROVIDERS_COST_DAYS = 30;
export type ModelProvidersData = {
authStatus: ModelAuthStatusResult | null;
models: ModelCatalogEntry[] | null;
catalogModels: ModelCatalogEntry[] | null;
providerOutcomes: ModelCatalogProviderOutcome[];
catalogError: string | null;
config: Record<string, unknown> | null;
providerUsage: UsageSummary | null;
costByProvider: SessionModelUsage[] | null;
@@ -35,15 +35,14 @@ export type ModelProvidersData = {
};
type ModelProvidersCatalogResult = {
models: ModelCatalogEntry[];
providerOutcomes?: ModelCatalogProviderOutcome[];
};
export const EMPTY_MODEL_PROVIDERS_DATA: ModelProvidersData = {
authStatus: null,
models: null,
catalogModels: null,
providerOutcomes: [],
catalogError: null,
config: null,
providerUsage: null,
costByProvider: null,
@@ -77,19 +76,34 @@ export async function loadModelProvidersData(
: params === undefined
? client.request<T>(method)
: client.request<T>(method, params);
const catalogRefresh = opts?.refresh
? request<ModelProvidersCatalogResult>("models.list", {
view: "all",
...(opts.agentId ? { agentId: opts.agentId } : {}),
refresh: true,
})
.then((result) => ({ ok: true as const, result: result ?? null }))
.catch((error: unknown) => ({ ok: false as const, error }))
: Promise.resolve({ ok: true as const, result: null });
const modelsLoad = opts?.refresh
? catalogRefresh.then(() =>
loadModels(client, {
...(opts.agentId ? { agentId: opts.agentId } : {}),
refresh: true,
}),
)
: loadModels(client, {
...(opts?.agentId ? { agentId: opts.agentId } : {}),
preparedOnly: true,
}).catch(() => null);
const [authStatus, models, catalogResult, config, providerUsage, costByProvider] =
await Promise.all([
loadModelAuthStatus(client, opts).then(
(result) => ({ ok: true as const, result }),
(error: unknown) => ({ ok: false as const, error }),
),
loadModels(client, opts).catch(() => null),
request<ModelProvidersCatalogResult>("models.list", {
view: "all",
includeProviderCapabilities: true,
})
.then((result) => result ?? null)
.catch(() => null),
modelsLoad,
catalogRefresh,
request<ConfigSnapshot>("config.get", {})
.then((snapshot) => resolveEditableSnapshotConfig(snapshot))
.catch(() => null),
@@ -107,8 +121,8 @@ export async function loadModelProvidersData(
authStatus:
authStatus.ok && Array.isArray(authStatus.result?.providers) ? authStatus.result : null,
models,
catalogModels: catalogResult?.models ?? null,
providerOutcomes: catalogResult?.providerOutcomes ?? [],
providerOutcomes: catalogResult.ok ? (catalogResult.result?.providerOutcomes ?? []) : [],
catalogError: catalogResult.ok ? null : errorMessage(catalogResult.error),
config,
providerUsage,
costByProvider,
@@ -57,7 +57,13 @@ function createHarness(initialScopeId: string) {
pendingAuthStatus = null;
await gate;
}
return { ts: 1, providers: [] };
return {
ts: 1,
providers: [],
providerCapabilities: [
{ provider: "anthropic", apiKeySupported: true, quickApiKeySetup: true },
],
};
}
case "models.list":
return { models: [] };
@@ -628,7 +628,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
connected: gatewaySnapshot.phase === "connected",
loading: gatewaySnapshot.phase === "connected" && this.data === null,
refreshing: this.refreshTask.status === TaskStatus.PENDING,
error: data.error,
error: data.error ?? data.catalogError,
updatedAt: data.updatedAt,
costDays: MODEL_PROVIDERS_COST_DAYS,
credentialAgentLabel: selectedAgentLabel,
@@ -638,8 +638,9 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
defaultModelsDirty: this.defaultsDraft !== null,
...modelBehavior,
configBusy,
quickAddSupported: data.authStatus?.providerCapabilities !== undefined,
unconfiguredProviders: buildUnconfiguredProviderOptions(
data.catalogModels,
data.authStatus?.providerCapabilities,
configuredProviderIds,
),
canMutate: this.canMutate(),
+13 -7
View File
@@ -39,10 +39,13 @@ function hasProviderCredentials(card: ModelProviderCard): boolean {
return card.hasConfigApiKey || Boolean(card.apiKey) || card.profiles.length > 0;
}
export function hasValidProviderSignIn(card: ModelProviderCard): boolean {
const catalogUnavailable =
card.catalogStatus === "auth-rejected" || card.catalogStatus === "unavailable";
return card.auth?.kind === "ok" && !catalogUnavailable;
export function hasVerifiedProvider(card: ModelProviderCard): boolean {
return (
card.catalogStatus === "ready" &&
card.auth?.kind !== "expired" &&
card.auth?.kind !== "missing" &&
card.auth?.kind !== "expiring"
);
}
export function renderProviderStatus(card: ModelProviderCard) {
@@ -65,16 +68,19 @@ export function renderProviderStatus(card: ModelProviderCard) {
if (!hasProviderCredentials(card)) {
return renderAuthStatus(card);
}
if (card.availableModelCount > 0 && (hasValidProviderSignIn(card) || !card.auth)) {
if (hasVerifiedProvider(card) && card.availableModelCount > 0) {
return renderSettingsStatus({
kind: "ok",
label: t("modelProviders.status.ready"),
});
}
return hasValidProviderSignIn(card)
return hasVerifiedProvider(card)
? renderSettingsStatus({
kind: "muted",
label: t("modelProviders.status.ok"),
})
: renderAuthStatus(card);
: renderSettingsStatus({
kind: "muted",
label: t("modelProviders.status.configured"),
});
}
+22 -6
View File
@@ -42,6 +42,7 @@ function props(overrides: Partial<ModelProvidersViewProps> = {}): ModelProviders
fastMode: false,
fastModeOverridden: true,
configBusy: false,
quickAddSupported: true,
unconfiguredProviders: [{ id: "anthropic", displayName: "Anthropic" }],
canMutate: true,
mutationBlockedReason: null,
@@ -121,6 +122,19 @@ describe("renderModelProviders", () => {
await i18n.setLocale("en");
});
it("hides quick API-key setup when provider capabilities are unavailable", () => {
const container = mount(
props({
configuredModels: [],
quickAddSupported: false,
unconfiguredProviders: [],
}),
);
expect(text(container)).not.toContain("Add provider");
expect(container.querySelector('[data-model-readiness="model-required"]')).not.toBeNull();
});
afterEach(() => {
for (const container of document.body.querySelectorAll("div")) {
render(nothing, container);
@@ -528,12 +542,14 @@ describe("renderModelProviders", () => {
const readiness = container.querySelector('[data-model-readiness="model-required"]');
expect(text(readiness)).toContain("Connect a verified AI model");
expect(text(readiness)).toContain("No models available");
expect(text(readiness)).toContain("Choose another provider");
expect(text(readiness)).toContain("Model required");
expect(text(readiness)).toContain("Connect a verified AI model");
expect(container.querySelector(".model-providers__defaults")).toBeNull();
expect(text(container.querySelector('[data-provider-id="openai"]'))).toContain("Signed in");
expect(text(container.querySelector('[data-provider-id="openai"]'))).toContain(
"Credentials configured",
);
button(readiness!, "Choose another provider")?.click();
button(readiness!, "Connect a verified AI model")?.click();
expect(onOpenModelSetup).toHaveBeenCalledOnce();
});
@@ -571,7 +587,7 @@ describe("renderModelProviders", () => {
);
const provider = container.querySelector('[data-provider-id="openai"]');
expect(text(provider)).toContain("API key");
expect(text(provider)).toContain("Credentials configured");
expect(text(provider)).not.toContain("Ready");
});
@@ -762,7 +778,7 @@ describe("renderModelProviders", () => {
);
const provider = container.querySelector('[data-provider-id="openai"]');
expect(text(provider)).toContain("Signed in");
expect(text(provider)).toContain("Credentials configured");
expect(text(provider)).toContain("No models available");
expect(text(provider)).not.toContain("Connection failed");
});
+4 -3
View File
@@ -30,7 +30,7 @@ import type {
ProviderOption,
} from "./data.ts";
import { renderDefaultModels } from "./default-models-view.ts";
import { hasValidProviderSignIn, renderProviderStatus } from "./view-status.ts";
import { hasVerifiedProvider, renderProviderStatus } from "./view-status.ts";
export type ModelProviderRowMessage = {
kind: "success" | "error";
@@ -55,6 +55,7 @@ type ModelProvidersViewProps = {
fastMode: FastMode | undefined;
fastModeOverridden: boolean;
configBusy: boolean;
quickAddSupported: boolean;
unconfiguredProviders: ProviderOption[];
canMutate: boolean;
mutationBlockedReason: string | null;
@@ -554,7 +555,7 @@ function renderAddProvider(props: ModelProvidersViewProps) {
}
function renderModelReadiness(props: ModelProvidersViewProps) {
const signedIn = props.cards.some(hasValidProviderSignIn);
const signedIn = props.cards.some(hasVerifiedProvider);
return html`
<div class="model-providers__setup" data-model-readiness="model-required">
${renderSettingsSection(
@@ -650,7 +651,7 @@ export function renderModelProviders(props: ModelProvidersViewProps) {
},
providerRows,
)}
${renderAddProvider(props)}
${props.quickAddSupported ? renderAddProvider(props) : nothing}
${props.mutationBlockedReason
? html`<div class="callout warning">${props.mutationBlockedReason}</div>`
: nothing}