fix: narrow configure model allowlist for Anthropic OAuth

This commit is contained in:
Peter Steinberger
2026-01-21 10:58:56 +00:00
parent cdb35c3aae
commit dc06b225cd
5 changed files with 320 additions and 20 deletions

View File

@@ -34,6 +34,7 @@ type PromptDefaultModelParams = {
};
type PromptDefaultModelResult = { model?: string };
type PromptModelAllowlistResult = { models?: string[] };
function hasAuthForProvider(
provider: string,
@@ -52,6 +53,25 @@ function resolveConfiguredModelRaw(cfg: ClawdbotConfig): string {
return raw?.primary?.trim() ?? "";
}
function resolveConfiguredModelKeys(cfg: ClawdbotConfig): string[] {
const models = cfg.agents?.defaults?.models ?? {};
return Object.keys(models)
.map((key) => String(key ?? "").trim())
.filter((key) => key.length > 0);
}
function normalizeModelKeys(values: string[]): string[] {
const seen = new Set<string>();
const next: string[] = [];
for (const raw of values) {
const value = String(raw ?? "").trim();
if (!value || seen.has(value)) continue;
seen.add(value);
next.push(value);
}
return next;
}
async function promptManualModel(params: {
prompter: WizardPrompter;
allowBlank: boolean;
@@ -245,6 +265,128 @@ export async function promptDefaultModel(
return { model: String(selection) };
}
export async function promptModelAllowlist(params: {
config: ClawdbotConfig;
prompter: WizardPrompter;
message?: string;
agentDir?: string;
allowedKeys?: string[];
initialSelections?: string[];
}): Promise<PromptModelAllowlistResult> {
const cfg = params.config;
const existingKeys = resolveConfiguredModelKeys(cfg);
const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []);
const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null;
const resolved = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const resolvedKey = modelKey(resolved.provider, resolved.model);
const initialSeeds = normalizeModelKeys([
...existingKeys,
resolvedKey,
...(params.initialSelections ?? []),
]);
const initialKeys = allowedKeySet
? initialSeeds.filter((key) => allowedKeySet.has(key))
: initialSeeds;
const catalog = await loadModelCatalog({ config: cfg, useCache: false });
if (catalog.length === 0 && allowedKeys.length === 0) {
const raw = await params.prompter.text({
message:
params.message ??
"Allowlist models (comma-separated provider/model; blank to keep current)",
initialValue: existingKeys.join(", "),
placeholder: "openai-codex/gpt-5.2, anthropic/claude-opus-4-5",
});
const parsed = String(raw ?? "")
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (parsed.length === 0) return {};
return { models: normalizeModelKeys(parsed) };
}
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const authStore = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const authCache = new Map<string, boolean>();
const hasAuth = (provider: string) => {
const cached = authCache.get(provider);
if (cached !== undefined) return cached;
const value = hasAuthForProvider(provider, cfg, authStore);
authCache.set(provider, value);
return value;
};
const options: WizardSelectOption<string>[] = [];
const seen = new Set<string>();
const addModelOption = (entry: {
provider: string;
id: string;
name?: string;
contextWindow?: number;
reasoning?: boolean;
}) => {
const key = modelKey(entry.provider, entry.id);
if (seen.has(key)) return;
if (HIDDEN_ROUTER_MODELS.has(key)) return;
const hints: string[] = [];
if (entry.name && entry.name !== entry.id) hints.push(entry.name);
if (entry.contextWindow) hints.push(`ctx ${formatTokenK(entry.contextWindow)}`);
if (entry.reasoning) hints.push("reasoning");
const aliases = aliasIndex.byKey.get(key);
if (aliases?.length) hints.push(`alias: ${aliases.join(", ")}`);
if (!hasAuth(entry.provider)) hints.push("auth missing");
options.push({
value: key,
label: key,
hint: hints.length > 0 ? hints.join(" · ") : undefined,
});
seen.add(key);
};
const filteredCatalog = allowedKeySet
? catalog.filter((entry) => allowedKeySet.has(modelKey(entry.provider, entry.id)))
: catalog;
for (const entry of filteredCatalog) addModelOption(entry);
const supplementalKeys = allowedKeySet ? allowedKeys : existingKeys;
for (const key of supplementalKeys) {
if (seen.has(key)) continue;
options.push({
value: key,
label: key,
hint: allowedKeySet ? "allowed (not in catalog)" : "configured (not in catalog)",
});
seen.add(key);
}
if (options.length === 0) return {};
const selection = await params.prompter.multiselect({
message: params.message ?? "Models in /model picker (multi-select)",
options,
initialValues: initialKeys.length > 0 ? initialKeys : undefined,
});
const selected = normalizeModelKeys(selection.map((value) => String(value)));
if (selected.length > 0) return { models: selected };
if (existingKeys.length === 0) return { models: [] };
const confirmClear = await params.prompter.confirm({
message: "Clear the model allowlist? (shows all models)",
initialValue: false,
});
if (!confirmClear) return {};
return { models: [] };
}
export function applyPrimaryModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
const defaults = cfg.agents?.defaults;
const existingModel = defaults?.model;
@@ -271,3 +413,36 @@ export function applyPrimaryModel(cfg: ClawdbotConfig, model: string): ClawdbotC
},
};
}
export function applyModelAllowlist(cfg: ClawdbotConfig, models: string[]): ClawdbotConfig {
const defaults = cfg.agents?.defaults;
const normalized = normalizeModelKeys(models);
if (normalized.length === 0) {
if (!defaults?.models) return cfg;
const { models: _ignored, ...restDefaults } = defaults;
return {
...cfg,
agents: {
...cfg.agents,
defaults: restDefaults,
},
};
}
const existingModels = defaults?.models ?? {};
const nextModels: Record<string, { alias?: string }> = {};
for (const key of normalized) {
nextModels[key] = existingModels[key] ?? {};
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...defaults,
models: nextModels,
},
},
};
}