Preserve provider wildcard allowlist intent

This commit is contained in:
80mills
2026-05-09 02:21:10 +00:00
committed by Peter Steinberger
parent f0ed2b5229
commit 2e495b07f3
12 changed files with 121 additions and 13 deletions
@@ -396,6 +396,13 @@ vi.mock("./model-selection.js", () => ({
: [],
);
},
isModelKeyAllowedBySet: (allowedKeys: ReadonlySet<string>, key: string) => {
if (allowedKeys.has(key)) {
return true;
}
const slash = key.indexOf("/");
return slash > 0 && allowedKeys.has(`${key.slice(0, slash)}/*`);
},
modelKey: (p: string, m: string) => `${p}/${m}`,
normalizeModelRef: (p: string, m: string) => ({ provider: p, model: m }),
parseModelRef: (m: string, p: string) => ({ provider: p, model: m }),
+5 -4
View File
@@ -62,6 +62,7 @@ import { runWithModelFallback } from "./model-fallback.js";
import {
buildAllowedModelSet,
buildConfiguredModelCatalog,
isModelKeyAllowedBySet,
modelKey,
normalizeModelRef,
parseModelRef,
@@ -778,7 +779,7 @@ async function agentCommandInternal(
if (overrideModel) {
const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel);
const key = modelKey(normalizedOverride.provider, normalizedOverride.model);
if (!allowAnyModel && !allowedModelKeys.has(key)) {
if (!allowAnyModel && !isModelKeyAllowedBySet(allowedModelKeys, key)) {
const { updated } = applyModelOverrideToSessionEntry({
entry,
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
@@ -801,7 +802,7 @@ async function agentCommandInternal(
const candidateProvider = storedProviderOverride || defaultProvider;
const normalizedStored = normalizeModelRef(candidateProvider, storedModelOverride);
const key = modelKey(normalizedStored.provider, normalizedStored.model);
if (allowAnyModel || allowedModelKeys.has(key)) {
if (allowAnyModel || isModelKeyAllowedBySet(allowedModelKeys, key)) {
provider = normalizedStored.provider;
model = normalizedStored.model;
}
@@ -819,7 +820,7 @@ async function agentCommandInternal(
throw new Error("Invalid model override.");
}
const explicitKey = modelKey(explicitRef.provider, explicitRef.model);
if (!allowAnyModel && !allowedModelKeys.has(explicitKey)) {
if (!allowAnyModel && !isModelKeyAllowedBySet(allowedModelKeys, explicitKey)) {
throw new Error(
`Model override "${sanitizeForLog(explicitRef.provider)}/${sanitizeForLog(explicitRef.model)}" is not allowed for agent "${sessionAgentId}".`,
);
@@ -1125,7 +1126,7 @@ async function agentCommandInternal(
}
const switchRef = normalizeModelRef(err.provider, err.model);
const switchKey = modelKey(switchRef.provider, switchRef.model);
if (!allowAnyModel && !allowedModelKeys.has(switchKey)) {
if (!allowAnyModel && !isModelKeyAllowedBySet(allowedModelKeys, switchKey)) {
log.info(
`Live session model switch in subagent run ${runId}: ` +
`rejected ${sanitizeForLog(err.provider)}/${sanitizeForLog(err.model)} (not in allowlist)`,
+19 -1
View File
@@ -107,6 +107,21 @@ export function parseConfiguredModelVisibilityEntries(params: { cfg?: OpenClawCo
};
}
export function providerWildcardModelKey(provider: string): string {
return modelKey(normalizeProviderId(provider), "*");
}
export function isModelKeyAllowedBySet(allowedKeys: ReadonlySet<string>, key: string): boolean {
if (allowedKeys.has(key)) {
return true;
}
const separator = key.indexOf("/");
if (separator <= 0) {
return false;
}
return allowedKeys.has(providerWildcardModelKey(key.slice(0, separator)));
}
export function inferUniqueProviderFromConfiguredModels(params: {
cfg: OpenClawConfig;
model: string;
@@ -689,6 +704,9 @@ export function buildAllowedModelSetWithFallbacks(params: {
const allowedKeys = new Set<string>();
const allowedRefs: ModelRef[] = [];
const syntheticCatalogEntries = new Map<string, ModelCatalogEntry>();
for (const provider of visibility.providerWildcards) {
allowedKeys.add(providerWildcardModelKey(provider));
}
const addAllowedCatalogRef = (ref: ModelRef) => {
if (
!allowedRefs.some(
@@ -816,7 +834,7 @@ function getModelRefStatusFromAllowedSet(params: {
}),
),
allowAny: params.allowed.allowAny,
allowed: params.allowed.allowAny || params.allowed.allowedKeys.has(key),
allowed: params.allowed.allowAny || isModelKeyAllowedBySet(params.allowed.allowedKeys, key),
};
}
+27
View File
@@ -7,12 +7,14 @@ import {
buildAllowedModelSet,
buildConfiguredModelCatalog,
inferUniqueProviderFromConfiguredModels,
isModelKeyAllowedBySet,
parseModelRef,
buildModelAliasIndex,
normalizeModelSelection,
normalizeProviderId,
normalizeProviderIdForAuth,
modelKey,
providerWildcardModelKey,
resolvePersistedOverrideModelRef,
resolvePersistedModelRef,
resolvePersistedSelectedModelRef,
@@ -977,6 +979,31 @@ describe("model-selection", () => {
expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(false);
});
it("preserves provider wildcard intent when catalog rows are unavailable", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
models: {
"openai-codex/*": {},
},
},
},
} as unknown as OpenClawConfig;
const result = buildAllowedModelSet({
cfg,
catalog: [],
defaultProvider: "anthropic",
defaultModel: "claude-sonnet-4-6",
});
expect(result.allowAny).toBe(false);
expect(result.allowedCatalog).toEqual([]);
expect(result.allowedKeys.has(providerWildcardModelKey("openai-codex"))).toBe(true);
expect(isModelKeyAllowedBySet(result.allowedKeys, "openai-codex/gpt-added-later")).toBe(true);
expect(isModelKeyAllowedBySet(result.allowedKeys, "anthropic/claude-sonnet-4-6")).toBe(false);
});
it("unions exact model entries with provider wildcard entries", () => {
const cfg: OpenClawConfig = {
agents: {
+4
View File
@@ -37,8 +37,10 @@ import {
getModelRefStatusWithFallbackModels,
inferUniqueProviderFromCatalog,
inferUniqueProviderFromConfiguredModels,
isModelKeyAllowedBySet,
normalizeModelSelection,
parseConfiguredModelVisibilityEntries,
providerWildcardModelKey,
resolveBareModelDefaultProvider,
resolveAllowedModelRefFromAliasIndex,
resolveAllowlistModelKey as resolveAllowlistModelKeyFromShared,
@@ -70,6 +72,7 @@ export {
findNormalizedProviderValue,
inferUniqueProviderFromConfiguredModels,
inferUniqueProviderFromCatalog,
isModelKeyAllowedBySet,
legacyModelKey,
modelKey,
normalizeModelRef,
@@ -78,6 +81,7 @@ export {
normalizeProviderIdForAuth,
parseConfiguredModelVisibilityEntries,
parseModelRef,
providerWildcardModelKey,
resolveBareModelDefaultProvider,
resolveConfiguredModelRef,
resolveHooksGmailModel,
+2 -1
View File
@@ -31,6 +31,7 @@ import {
buildAllowedModelSet,
buildConfiguredModelCatalog,
buildModelAliasIndex,
isModelKeyAllowedBySet,
modelKey,
resolveDefaultModelForAgent,
resolveModelRefFromString,
@@ -305,7 +306,7 @@ async function resolveModelOverride(params: {
throw new Error(`Unrecognized model "${raw}".`);
}
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowed.allowedKeys.size > 0 && !allowed.allowedKeys.has(key)) {
if (allowed.allowedKeys.size > 0 && !isModelKeyAllowedBySet(allowed.allowedKeys, key)) {
throw new Error(`Model "${key}" is not allowed.`);
}
const isDefault =
@@ -1,5 +1,6 @@
import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
import {
isModelKeyAllowedBySet,
type ModelAliasIndex,
modelKey,
resolveModelRefFromString,
@@ -122,7 +123,10 @@ export function resolveModelSelectionFromDirective(params: {
});
if (explicit) {
const explicitKey = modelKey(explicit.ref.provider, explicit.ref.model);
if (params.allowedModelKeys.size === 0 || params.allowedModelKeys.has(explicitKey)) {
if (
params.allowedModelKeys.size === 0 ||
isModelKeyAllowedBySet(params.allowedModelKeys, explicitKey)
) {
modelSelection = {
provider: explicit.ref.provider,
model: explicit.ref.model,
@@ -1,4 +1,5 @@
import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js";
import { isModelKeyAllowedBySet } from "../../agents/model-selection.js";
import { normalizeProviderId } from "../../agents/provider-id.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
@@ -300,6 +301,9 @@ export function resolveModelDirectiveSelection(params: {
}
const provider = normalizeProviderId(key.slice(0, slash));
const model = key.slice(slash + 1);
if (model === "*") {
continue;
}
if (providerFilter && provider !== providerFilter) {
continue;
}
@@ -320,7 +324,7 @@ export function resolveModelDirectiveSelection(params: {
}
for (const match of aliasMatches) {
const key = modelKey(match.provider, match.model);
if (!allowedModelKeys.has(key)) {
if (!isModelKeyAllowedBySet(allowedModelKeys, key)) {
continue;
}
if (!candidates.some((c) => c.provider === match.provider && c.model === match.model)) {
@@ -395,7 +399,7 @@ export function resolveModelDirectiveSelection(params: {
}
const resolvedKey = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(resolvedKey)) {
if (allowedModelKeys.size === 0 || isModelKeyAllowedBySet(allowedModelKeys, resolvedKey)) {
return {
selection: {
provider: resolved.ref.provider,
@@ -659,6 +659,45 @@ describe("createModelSelectionState respects session model override", () => {
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
});
it("keeps wildcard-provider overrides when configured catalog rows are unavailable", async () => {
const cfg = {
agents: {
defaults: {
model: { primary: "anthropic/claude-sonnet-4-6" },
models: {
"anthropic/claude-sonnet-4-6": {},
"openai-codex/*": {},
},
},
},
} as OpenClawConfig;
const sessionKey = "agent:main:telegram:direct:1";
const sessionEntry = makeEntry({
providerOverride: "openai-codex",
modelOverride: "gpt-added-after-startup",
});
const sessionStore = { [sessionKey]: sessionEntry };
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
defaultProvider: "anthropic",
defaultModel: "claude-sonnet-4-6",
provider: "anthropic",
model: "claude-sonnet-4-6",
hasModelDirective: false,
});
expect(state.provider).toBe("openai-codex");
expect(state.model).toBe("gpt-added-after-startup");
expect(state.resetModelOverride).toBe(false);
expect(sessionStore[sessionKey]?.providerOverride).toBe("openai-codex");
expect(sessionStore[sessionKey]?.modelOverride).toBe("gpt-added-after-startup");
});
it("keeps allowed legacy combined session overrides after normalization", async () => {
const cfg = {
agents: {
+3 -2
View File
@@ -7,6 +7,7 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import {
buildConfiguredModelCatalog,
buildAllowedModelSet,
isModelKeyAllowedBySet,
modelKey,
normalizeModelRef,
normalizeProviderId,
@@ -180,7 +181,7 @@ export async function createModelSelectionState(params: {
directStoredOverride.model,
);
const key = modelKey(normalizedOverride.provider, normalizedOverride.model);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
if (allowedModelKeys.size > 0 && !isModelKeyAllowedBySet(allowedModelKeys, key)) {
const { updated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
@@ -220,7 +221,7 @@ export async function createModelSelectionState(params: {
storedOverride.model,
);
const key = modelKey(normalizedStoredOverride.provider, normalizedStoredOverride.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
if (allowedModelKeys.size === 0 || isModelKeyAllowedBySet(allowedModelKeys, key)) {
provider = normalizedStoredOverride.provider;
model = normalizedStoredOverride.model;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { isModelKeyAllowedBySet } from "../../agents/model-selection.js";
import { normalizeProviderId } from "../../agents/provider-id.js";
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
import type { SessionEntry } from "../../config/sessions.js";
@@ -89,7 +90,7 @@ function buildSelectionFromExplicit(params: {
return undefined;
}
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (params.allowedModelKeys.size > 0 && !params.allowedModelKeys.has(key)) {
if (params.allowedModelKeys.size > 0 && !isModelKeyAllowedBySet(params.allowedModelKeys, key)) {
return undefined;
}
const isDefault =
+2 -1
View File
@@ -3,6 +3,7 @@ import type { IncomingMessage } from "node:http";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
buildAllowedModelSet,
isModelKeyAllowedBySet,
modelKey,
parseModelRef,
resolveDefaultModelForAgent,
@@ -103,7 +104,7 @@ export async function resolveOpenAiCompatModelOverride(params: {
agentId: params.agentId,
});
const normalized = modelKey(parsed.provider, parsed.model);
if (!allowed.allowAny && !allowed.allowedKeys.has(normalized)) {
if (!allowed.allowAny && !isModelKeyAllowedBySet(allowed.allowedKeys, normalized)) {
return {
errorMessage: `Model '${normalized}' is not allowed for agent '${params.agentId}'.`,
};