fix: narrow configure model allowlist for Anthropic OAuth
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -4,17 +4,9 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
## 2026.1.21
|
## 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
|
### 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.
|
- 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.
|
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 2026.1.20
|
## 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.
|
- **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
|
### 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.
|
- 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: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
|
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ read_when:
|
|||||||
|
|
||||||
Interactive prompt to set up credentials, devices, and agent defaults.
|
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
|
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
|
||||||
`clawdbot config get|set|unset` for non-interactive edits.
|
`clawdbot config get|set|unset` for non-interactive edits.
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,21 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
|
import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-choice.js";
|
||||||
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.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";
|
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: {
|
export function buildGatewayAuthConfig(params: {
|
||||||
existing?: GatewayAuthConfig;
|
existing?: GatewayAuthConfig;
|
||||||
mode: GatewayAuthChoice;
|
mode: GatewayAuthChoice;
|
||||||
@@ -51,19 +62,34 @@ export async function promptAuthConfig(
|
|||||||
setDefaultModel: true,
|
setDefaultModel: true,
|
||||||
});
|
});
|
||||||
next = applied.config;
|
next = applied.config;
|
||||||
// Auth choice already set a sensible default model; skip the model picker.
|
} else {
|
||||||
return next;
|
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,
|
config: next,
|
||||||
prompter,
|
prompter,
|
||||||
allowKeep: true,
|
allowedKeys: anthropicOAuth ? ANTHROPIC_OAUTH_MODEL_KEYS : undefined,
|
||||||
ignoreAllowlist: true,
|
initialSelections: anthropicOAuth ? ["anthropic/claude-opus-4-5"] : undefined,
|
||||||
preferredProvider: resolvePreferredProviderForAuthChoice(authChoice),
|
message: anthropicOAuth ? "Anthropic OAuth models" : undefined,
|
||||||
});
|
});
|
||||||
if (modelSelection.model) {
|
if (allowlistSelection.models) {
|
||||||
next = applyPrimaryModel(next, modelSelection.model);
|
next = applyModelAllowlist(next, allowlistSelection.models);
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { makePrompter } from "./onboarding/__tests__/test-utils.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());
|
const loadModelCatalog = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("../agents/model-catalog.js", () => ({
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type PromptDefaultModelParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PromptDefaultModelResult = { model?: string };
|
type PromptDefaultModelResult = { model?: string };
|
||||||
|
type PromptModelAllowlistResult = { models?: string[] };
|
||||||
|
|
||||||
function hasAuthForProvider(
|
function hasAuthForProvider(
|
||||||
provider: string,
|
provider: string,
|
||||||
@@ -52,6 +53,25 @@ function resolveConfiguredModelRaw(cfg: ClawdbotConfig): string {
|
|||||||
return raw?.primary?.trim() ?? "";
|
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: {
|
async function promptManualModel(params: {
|
||||||
prompter: WizardPrompter;
|
prompter: WizardPrompter;
|
||||||
allowBlank: boolean;
|
allowBlank: boolean;
|
||||||
@@ -245,6 +265,128 @@ export async function promptDefaultModel(
|
|||||||
return { model: String(selection) };
|
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 {
|
export function applyPrimaryModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
|
||||||
const defaults = cfg.agents?.defaults;
|
const defaults = cfg.agents?.defaults;
|
||||||
const existingModel = defaults?.model;
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user