fix(anthropic): preserve Claude CLI runtime migration

This commit is contained in:
Alex Knight
2026-05-16 21:01:23 +10:00
parent 01eb56e45a
commit 62cf54484f
6 changed files with 196 additions and 34 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- MS Teams/media: sniff inline `data:image/*` attachment bytes before staging them, skipping payloads that are not actually images.
- WebChat/media: require trusted local-media provenance before preserving local audio reply paths for display, so untrusted audio-looking paths go through normal staging and read-policy checks.
- Agents/tool media: preserve trusted local-media provenance when merging generated tool attachments into final reply payloads, so trusted audio/media survives outbound display normalization.
- Anthropic/Claude CLI: write model-scoped `claude-cli` runtime policy when reusing local Claude CLI auth, so upgraded Telegram and Dashboard gateway turns keep using the CLI backend instead of falling through to Anthropic API billing. Fixes #82344. Thanks @amknight.
- Update: let package-swap `doctor --fix` persist core config repairs while plugin schemas are still converging, preventing update failures on externalized channel configs.
- Update: carry plugin-validation bypasses into config mutation pre-write reads, so package update doctor repairs can finish while externalized plugin schemas are converging.
- Update/doctor: keep plugin-validation bypasses on the top-level `$include` config write path, so package repair can update included plugin config files without flattening them into the root config.

View File

@@ -133,12 +133,18 @@ describe("anthropic cli migration", () => {
},
agentRuntime: { id: "claude-cli" },
models: {
"anthropic/claude-opus-4-7": { alias: "Opus" },
"anthropic/claude-sonnet-4-6": {},
"anthropic/claude-opus-4-6": { alias: "Opus" },
"anthropic/claude-opus-4-5": {},
"anthropic/claude-sonnet-4-5": {},
"anthropic/claude-haiku-4-5": {},
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "claude-cli" },
},
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-opus-4-6": {
alias: "Opus",
agentRuntime: { id: "claude-cli" },
},
"anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } },
"openai/gpt-5.2": {},
},
},
@@ -165,12 +171,12 @@ describe("anthropic cli migration", () => {
agentRuntime: { id: "claude-cli" },
models: {
"openai/gpt-5.2": {},
"anthropic/claude-opus-4-7": {},
"anthropic/claude-sonnet-4-6": {},
"anthropic/claude-opus-4-6": {},
"anthropic/claude-opus-4-5": {},
"anthropic/claude-sonnet-4-5": {},
"anthropic/claude-haiku-4-5": {},
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } },
},
},
},
@@ -195,18 +201,55 @@ describe("anthropic cli migration", () => {
model: { primary: "anthropic/claude-opus-4-7" },
agentRuntime: { id: "claude-cli" },
models: {
"anthropic/claude-opus-4-7": {},
"anthropic/claude-sonnet-4-6": {},
"anthropic/claude-opus-4-6": {},
"anthropic/claude-opus-4-5": {},
"anthropic/claude-sonnet-4-5": {},
"anthropic/claude-haiku-4-5": {},
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-opus-4-5": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-haiku-4-5": { agentRuntime: { id: "claude-cli" } },
},
},
},
});
});
it("preserves explicit model runtime policy while filling missing Claude CLI policies", () => {
const result = buildAnthropicCliMigrationResult({
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-7",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
models: {
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "pi" },
},
"anthropic/claude-sonnet-4-6": {
alias: "Sonnet",
agentRuntime: { id: "auto" },
},
},
},
},
});
const defaults = result.configPatch?.agents?.defaults;
if (!defaults) {
throw new Error("Expected Claude CLI migration to return default agent config");
}
expect(defaults.models?.["anthropic/claude-opus-4-7"]).toEqual({
alias: "Opus",
agentRuntime: { id: "pi" },
});
expect(defaults.models?.["anthropic/claude-sonnet-4-6"]).toEqual({
alias: "Sonnet",
agentRuntime: { id: "claude-cli" },
});
});
it("registered cli auth tells users to run claude auth login when local auth is missing", async () => {
readClaudeCliCredentialsForSetup.mockReturnValue(null);
const method = await resolveAnthropicCliAuthMethod();
@@ -340,9 +383,11 @@ describe("anthropic cli migration", () => {
expect(defaults?.agentRuntime?.id).toBe("claude-cli");
expect(defaults?.models?.["anthropic/claude-opus-4-7"]).toEqual({
alias: "Opus",
agentRuntime: { id: "claude-cli" },
});
expect(defaults?.models?.["anthropic/claude-opus-4-6"]).toEqual({
alias: "Opus",
agentRuntime: { id: "claude-cli" },
});
expect(defaults?.models?.["openai/gpt-5.2"]).toEqual({});
});

View File

@@ -35,23 +35,29 @@ function toAnthropicModelRef(raw: string): string | null {
return `anthropic/${modelId}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function rewriteModelSelection(model: AgentDefaultsModel): {
value: AgentDefaultsModel;
primary?: string;
runtimeRefs: string[];
changed: boolean;
} {
if (typeof model === "string") {
const converted = toAnthropicModelRef(model);
return converted
? { value: converted, primary: converted, changed: true }
: { value: model, changed: false };
? { value: converted, primary: converted, runtimeRefs: [converted], changed: true }
: { value: model, runtimeRefs: [], changed: false };
}
if (!model || typeof model !== "object" || Array.isArray(model)) {
return { value: model, changed: false };
return { value: model, runtimeRefs: [], changed: false };
}
const current = model as Record<string, unknown>;
const next: Record<string, unknown> = { ...current };
const runtimeRefs: string[] = [];
let changed = false;
let primary: string | undefined;
@@ -60,15 +66,23 @@ function rewriteModelSelection(model: AgentDefaultsModel): {
if (converted) {
next.primary = converted;
primary = converted;
runtimeRefs.push(converted);
changed = true;
}
}
const currentFallbacks = current.fallbacks;
if (Array.isArray(currentFallbacks)) {
const nextFallbacks = currentFallbacks.map((entry) =>
typeof entry === "string" ? (toAnthropicModelRef(entry) ?? entry) : entry,
);
const nextFallbacks = currentFallbacks.map((entry) => {
if (typeof entry !== "string") {
return entry;
}
const converted = toAnthropicModelRef(entry);
if (converted) {
runtimeRefs.push(converted);
}
return converted ?? entry;
});
if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) {
next.fallbacks = nextFallbacks;
changed = true;
@@ -78,6 +92,7 @@ function rewriteModelSelection(model: AgentDefaultsModel): {
return {
value: changed ? next : model,
...(primary ? { primary } : {}),
runtimeRefs,
changed,
};
}
@@ -116,11 +131,19 @@ function rewriteModelEntryMap(models: Record<string, unknown> | undefined): {
function seedClaudeCliAllowlist(
models: NonNullable<AgentDefaultsModels>,
selectedRefs: readonly string[] = [],
): NonNullable<AgentDefaultsModels> {
const next = { ...models };
const runtimeRefs = new Set<string>();
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
const canonicalRef = toAnthropicModelRef(ref) ?? ref;
next[canonicalRef] = next[canonicalRef] ?? {};
runtimeRefs.add(canonicalRef);
}
for (const ref of selectedRefs) {
runtimeRefs.add(ref);
}
for (const ref of runtimeRefs) {
next[ref] = modelEntryWithClaudeCliRuntime(next[ref]);
}
return next;
}
@@ -136,6 +159,21 @@ function selectClaudeCliRuntime(agentRuntime: AgentDefaultsRuntimePolicy | undef
};
}
function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown> {
const base = isRecord(entry) ? { ...entry } : {};
const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined;
const currentRuntime =
typeof currentRuntimeId === "string" ? normalizeLowercaseStringOrEmpty(currentRuntimeId) : "";
if (currentRuntime && currentRuntime !== "auto") {
return base;
}
base.agentRuntime = {
...(isRecord(base.agentRuntime) ? base.agentRuntime : {}),
id: CLAUDE_CLI_BACKEND_ID,
};
return base;
}
export function hasClaudeCliAuth(options?: { allowKeychainPrompt?: boolean }): boolean {
return Boolean(
options?.allowKeychainPrompt === false
@@ -187,7 +225,10 @@ export function buildAnthropicCliMigrationResult(
const existingModels = (rewrittenModels.value ??
defaults?.models ??
{}) as NonNullable<AgentDefaultsModels>;
const nextModels = seedClaudeCliAllowlist(existingModels);
const nextModels = seedClaudeCliAllowlist(existingModels, [
...rewrittenModel.runtimeRefs,
...rewrittenModels.migrated,
]);
const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-4-7";
return {

View File

@@ -16,6 +16,10 @@ function normalizeProviderId(provider: string): string {
return normalized;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function resolveAnthropicDefaultAuthMode(
config: OpenClawConfig,
env: NodeJS.ProcessEnv,
@@ -154,9 +158,16 @@ function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
if (parsedPrimary?.provider === CLAUDE_CLI_BACKEND_ID) {
return true;
}
return Object.keys(config.agents?.defaults?.models ?? {}).some((key) => {
return Object.entries(config.agents?.defaults?.models ?? {}).some(([key, entry]) => {
const parsed = parseProviderModelRef(key, "anthropic");
return parsed?.provider === CLAUDE_CLI_BACKEND_ID;
if (parsed?.provider === CLAUDE_CLI_BACKEND_ID) {
return true;
}
const runtimeId = isRecord(entry?.agentRuntime) ? entry.agentRuntime.id : undefined;
return (
parsed?.provider === "anthropic" &&
normalizeLowercaseStringOrEmpty(runtimeId) === CLAUDE_CLI_BACKEND_ID
);
});
}
@@ -166,6 +177,63 @@ function toCanonicalAnthropicModelRef(ref: string): string {
: ref;
}
function toClaudeCliRuntimeModelRef(raw: string): string | null {
const ref = resolveAnthropicPrimaryModelRef(raw);
if (!ref) {
return null;
}
const parsed = parseProviderModelRef(ref, "anthropic");
if (!parsed) {
return null;
}
if (parsed.provider !== "anthropic" && parsed.provider !== CLAUDE_CLI_BACKEND_ID) {
return null;
}
if (!normalizeLowercaseStringOrEmpty(parsed.model).startsWith("claude-")) {
return null;
}
return `anthropic/${parsed.model}`;
}
function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown> {
const base = isRecord(entry) ? { ...entry } : {};
const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined;
const currentRuntime = normalizeLowercaseStringOrEmpty(currentRuntimeId);
if (currentRuntime && currentRuntime !== "auto") {
return base;
}
base.agentRuntime = {
...(isRecord(base.agentRuntime) ? base.agentRuntime : {}),
id: CLAUDE_CLI_BACKEND_ID,
};
return base;
}
function collectClaudeCliRuntimeRefs(
model: string | { primary?: string; fallbacks?: string[] } | undefined,
): string[] {
const refs = new Set<string>();
if (typeof model === "string") {
const ref = toClaudeCliRuntimeModelRef(model);
if (ref) {
refs.add(ref);
}
return [...refs];
}
const primary =
typeof model?.primary === "string" ? toClaudeCliRuntimeModelRef(model.primary) : null;
if (primary) {
refs.add(primary);
}
for (const fallback of model?.fallbacks ?? []) {
const ref = toClaudeCliRuntimeModelRef(fallback);
if (ref) {
refs.add(ref);
}
}
return [...refs];
}
function normalizeAnthropicProviderConfig<T extends { api?: string; models?: unknown[] }>(
providerConfig: T,
): T {
@@ -290,12 +358,17 @@ export function applyAnthropicConfigDefaults(params: {
if (authMode === "oauth" && usesClaudeCliModelSelection(params.config)) {
const nextModels = defaults.models ? { ...defaults.models } : {};
let modelsMutated = false;
const runtimeRefs = new Set<string>(collectClaudeCliRuntimeRefs(defaults.model));
for (const rawRef of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
const ref = toCanonicalAnthropicModelRef(rawRef);
if (ref in nextModels) {
runtimeRefs.add(toCanonicalAnthropicModelRef(rawRef));
}
for (const ref of runtimeRefs) {
const current = nextModels[ref];
const updated = modelEntryWithClaudeCliRuntime(current);
if (JSON.stringify(updated) === JSON.stringify(current ?? {})) {
continue;
}
nextModels[ref] = {};
nextModels[ref] = updated;
modelsMutated = true;
}
if (modelsMutated) {

View File

@@ -264,7 +264,7 @@ describe("anthropic provider replay hooks", () => {
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5",
]) {
expect(models[modelId]).toEqual({});
expect(models[modelId]).toEqual({ agentRuntime: { id: "claude-cli" } });
}
});

View File

@@ -370,8 +370,10 @@ describeLive("gateway live (cli backend)", () => {
...(bootstrapWorkspace ? { workspace: bootstrapWorkspace.workspaceRootDir } : {}),
model: { primary: configModelKey },
models: {
[configModelKey]: {},
...(modelSwitchTarget ? { [modelSwitchTarget]: {} } : {}),
[configModelKey]: { agentRuntime: modelSelection.agentRuntime },
...(modelSwitchTarget
? { [modelSwitchTarget]: { agentRuntime: modelSelection.agentRuntime } }
: {}),
},
agentRuntime: modelSelection.agentRuntime,
cliBackends: {