From dc06b225cdb159d60da4feb35b1150bcf30e0e82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 10:58:56 +0000 Subject: [PATCH] fix: narrow configure model allowlist for Anthropic OAuth --- CHANGELOG.md | 11 +- docs/cli/configure.md | 3 + src/commands/configure.gateway-auth.ts | 44 +++++-- src/commands/model-picker.test.ts | 107 ++++++++++++++- src/commands/model-picker.ts | 175 +++++++++++++++++++++++++ 5 files changed, 320 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1218c75..c3f233c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,9 @@ Docs: https://docs.clawd.bot ## 2026.1.21 -### Changes -- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. -- Exec approvals: support wildcard agent allowlists (`*`) across all agents. - ### Fixes -- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. -- macOS: exec approvals now respect wildcard agent allowlists (`*`). - UI: remove the chat stop button and keep the composer aligned to the bottom edge. -- Agents: add diagnostics cache trace config and fix cache trace logging edge cases. (#1370) — thanks @parubets. -- Agents: scrub Anthropic refusal test token from prompts and add a live refusal regression probe. -- Memory: make session memory indexing async and delta-gated to avoid blocking searches. +- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. ## 2026.1.20 @@ -98,7 +90,6 @@ Docs: https://docs.clawd.bot - **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any. ### Fixes -- Models: limit `/model list` chat output to configured models when no allowlist is set. - Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry. - Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244) diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 2ffa23de6..d7159b9b8 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -8,6 +8,9 @@ read_when: Interactive prompt to set up credentials, devices, and agent defaults. +Note: The **Model** section now includes a multi-select for the +`agents.defaults.models` allowlist (what shows up in `/model` and the model picker). + Tip: `clawdbot config` without a subcommand opens the same wizard. Use `clawdbot config get|set|unset` for non-interactive edits. diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 11ff4c28d..ba09ac40e 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -4,10 +4,21 @@ import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js"; import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; -import { applyPrimaryModel, promptDefaultModel } from "./model-picker.js"; +import { + applyModelAllowlist, + applyPrimaryModel, + promptDefaultModel, + promptModelAllowlist, +} from "./model-picker.js"; type GatewayAuthChoice = "off" | "token" | "password"; +const ANTHROPIC_OAUTH_MODEL_KEYS = [ + "anthropic/claude-opus-4-5", + "anthropic/claude-sonnet-4-5", + "anthropic/claude-haiku-4-5", +]; + export function buildGatewayAuthConfig(params: { existing?: GatewayAuthConfig; mode: GatewayAuthChoice; @@ -51,19 +62,34 @@ export async function promptAuthConfig( setDefaultModel: true, }); next = applied.config; - // Auth choice already set a sensible default model; skip the model picker. - return next; + } else { + const modelSelection = await promptDefaultModel({ + config: next, + prompter, + allowKeep: true, + ignoreAllowlist: true, + preferredProvider: resolvePreferredProviderForAuthChoice(authChoice), + }); + if (modelSelection.model) { + next = applyPrimaryModel(next, modelSelection.model); + } } - const modelSelection = await promptDefaultModel({ + const anthropicOAuth = + authChoice === "claude-cli" || + authChoice === "setup-token" || + authChoice === "token" || + authChoice === "oauth"; + + const allowlistSelection = await promptModelAllowlist({ config: next, prompter, - allowKeep: true, - ignoreAllowlist: true, - preferredProvider: resolvePreferredProviderForAuthChoice(authChoice), + allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined, + initialSelections: anthropicOAuth ? ["anthropic/claude-opus-4-5"] : undefined, + message: anthropicOAuth ? "Anthropic OAuth models" : undefined, }); - if (modelSelection.model) { - next = applyPrimaryModel(next, modelSelection.model); + if (allowlistSelection.models) { + next = applyModelAllowlist(next, allowlistSelection.models); } return next; diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 12ea171ab..9c4b2627a 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; import { makePrompter } from "./onboarding/__tests__/test-utils.js"; -import { promptDefaultModel } from "./model-picker.js"; +import { applyModelAllowlist, promptDefaultModel, promptModelAllowlist } from "./model-picker.js"; const loadModelCatalog = vi.hoisted(() => vi.fn()); vi.mock("../agents/model-catalog.js", () => ({ @@ -65,3 +65,108 @@ describe("promptDefaultModel", () => { ); }); }); + +describe("promptModelAllowlist", () => { + it("filters internal router models from the selection list", async () => { + loadModelCatalog.mockResolvedValue([ + { + provider: "openrouter", + id: "auto", + name: "OpenRouter Auto", + }, + { + provider: "openrouter", + id: "meta-llama/llama-3.3-70b:free", + name: "Llama 3.3 70B", + }, + ]); + + const multiselect = vi.fn(async (params) => + params.options.map((option: { value: string }) => option.value), + ); + const prompter = makePrompter({ multiselect }); + const config = { agents: { defaults: {} } } as ClawdbotConfig; + + await promptModelAllowlist({ config, prompter }); + + const options = multiselect.mock.calls[0]?.[0]?.options ?? []; + expect(options.some((opt: { value: string }) => opt.value === "openrouter/auto")).toBe(false); + expect( + options.some( + (opt: { value: string }) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free", + ), + ).toBe(true); + }); + + it("filters to allowed keys when provided", async () => { + loadModelCatalog.mockResolvedValue([ + { + provider: "anthropic", + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + }, + { + provider: "anthropic", + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + { + provider: "openai", + id: "gpt-5.2", + name: "GPT-5.2", + }, + ]); + + const multiselect = vi.fn(async (params) => + params.options.map((option: { value: string }) => option.value), + ); + const prompter = makePrompter({ multiselect }); + const config = { agents: { defaults: {} } } as ClawdbotConfig; + + await promptModelAllowlist({ + config, + prompter, + allowedKeys: ["anthropic/claude-opus-4-5"], + }); + + const options = multiselect.mock.calls[0]?.[0]?.options ?? []; + expect(options.map((opt: { value: string }) => opt.value)).toEqual([ + "anthropic/claude-opus-4-5", + ]); + }); +}); + +describe("applyModelAllowlist", () => { + it("preserves existing entries for selected models", () => { + const config = { + agents: { + defaults: { + models: { + "openai/gpt-5.2": { alias: "gpt" }, + "anthropic/claude-opus-4-5": { alias: "opus" }, + }, + }, + }, + } as ClawdbotConfig; + + const next = applyModelAllowlist(config, ["openai/gpt-5.2"]); + expect(next.agents?.defaults?.models).toEqual({ + "openai/gpt-5.2": { alias: "gpt" }, + }); + }); + + it("clears the allowlist when no models remain", () => { + const config = { + agents: { + defaults: { + models: { + "openai/gpt-5.2": { alias: "gpt" }, + }, + }, + }, + } as ClawdbotConfig; + + const next = applyModelAllowlist(config, []); + expect(next.agents?.defaults?.models).toBeUndefined(); + }); +}); diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index c4ce5c4be..cd826b884 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -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(); + 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 { + 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(); + 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[] = []; + const seen = new Set(); + 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 = {}; + for (const key of normalized) { + nextModels[key] = existingModels[key] ?? {}; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + models: nextModels, + }, + }, + }; +}