Files
openclaw/src/cli/config-model-validation.env.test.ts
T
Peter Steinberger 8bec61bab1 fix(cli): reject unknown models in config set (#111571)
* fix(cli): validate configured model references

* fix(cli): cover dependent model references

* fix(cli): reject malformed model references

* test(cli): satisfy model validation gates

* fix(cli): harden model validation drafts

* fix(cli): preserve model runtime ownership

* fix(cli): validate expanded model references

* fix(cli): close model validation gaps

* fix(cli): preserve model alias ownership

* fix(cli): secure model validation context

* fix(cli): redact expanded model references

* fix(cli): validate inherited agent catalogs

* fix(cli): align canonical model resolution

* fix(cli): scope inherited catalog validation

* fix(cli): compare resolved model snapshots

* fix(cli): cover model dependency transitions

* fix(cli): isolate model env validation

* fix(cli): redact dependent model checks

* fix(cli): compare agent model identity

* fix(cli): validate expanded model removal

* fix(cli): defer unresolved fallback validation

* fix(cli): preserve authored model changes
2026-07-20 03:31:03 -07:00

368 lines
13 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { checkTouchedTextModelRefs } from "./config-model-validation.js";
type ResolverInput = {
config: OpenClawConfig;
ref: {
path: string;
value: string;
agentIndex?: number;
agentId?: string;
fallback: boolean;
authProfileId?: string;
};
};
describe("config model validation env handling", () => {
it("validates an expanded ref while preserving the authored config", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const config: OpenClawConfig = {
agents: { defaults: { model: { primary: "${MODEL_REF}" } } },
};
const result = await checkTouchedTextModelRefs({
config,
touchedPaths: [["agents", "defaults", "model", "primary"]],
env: { MODEL_REF: "openai/gpt-5.4-mini" },
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledWith({
config: { agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } } },
ref: {
path: "agents.defaults.model.primary",
value: "openai/gpt-5.4-mini",
fallback: false,
},
});
expect(config.agents?.defaults?.model).toEqual({ primary: "${MODEL_REF}" });
});
it("reports an authored placeholder without exposing its expanded value", async () => {
const resolveModelRef = vi.fn(async () => "Unknown model: private-provider/private-model");
const result = await checkTouchedTextModelRefs({
config: { agents: { defaults: { model: { primary: "${MODEL_REF}" } } } },
touchedPaths: [["agents", "defaults", "model", "primary"]],
env: { MODEL_REF: "private-provider/private-model@work" },
resolveModelRef,
});
expect(result.errors).toEqual([expect.stringContaining('model reference "${MODEL_REF}"')]);
expect(result.errors).toEqual([
expect.stringContaining("Unable to resolve authored model reference"),
]);
expect(result.errors.join("\n")).not.toContain("private-provider");
});
it("redacts a fallback selected after provider expansion", async () => {
const resolveModelRef = vi.fn(async ({ ref }: ResolverInput) =>
ref.fallback ? "Unknown model: private-fallback" : undefined,
);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: {
model: { primary: "${PRIMARY_REF}", fallbacks: ["${FALLBACK_REF}"] },
},
},
},
previousConfig: {
agents: {
defaults: {
model: { primary: "openai/current", fallbacks: ["${FALLBACK_REF}"] },
},
},
},
touchedPaths: [["agents", "defaults", "model", "primary"]],
env: { PRIMARY_REF: "provider-b/next", FALLBACK_REF: "private-fallback" },
resolveModelRef,
});
expect(result.errors).toEqual([expect.stringContaining('model reference "${FALLBACK_REF}"')]);
expect(result.errors.join("\n")).not.toContain("private-fallback");
});
it("does not revalidate an unchanged expanded primary in a model replacement", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: {
model: { primary: "${MODEL_REF}", fallbacks: ["provider-b/next"] },
},
},
},
previousConfig: {
agents: {
defaults: {
model: { primary: "${MODEL_REF}", fallbacks: ["provider-b/current"] },
},
},
},
touchedPaths: [["agents", "defaults", "model"]],
env: { MODEL_REF: "provider-a/main" },
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledWith({
config: expect.any(Object),
ref: {
path: "agents.defaults.model.fallbacks.0",
value: "provider-b/next",
fallback: true,
},
});
});
it("validates authored changes even when expansion matches the previous values", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: {
model: { primary: "${PRIMARY_REF}", fallbacks: ["${FALLBACK_REF}"] },
},
},
},
previousConfig: {
agents: {
defaults: {
model: { primary: "provider-a/main", fallbacks: ["provider-b/backup"] },
},
},
},
touchedPaths: [["agents", "defaults", "model"]],
env: { PRIMARY_REF: "provider-a/main", FALLBACK_REF: "provider-b/backup" },
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
expect(resolveModelRef).toHaveBeenCalledTimes(2);
});
it("replaces a stale previous placeholder without requiring its env var", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: { agents: { defaults: { model: { primary: "provider-a/next" } } } },
previousConfig: { agents: { defaults: { model: { primary: "${OLD_MODEL}" } } } },
touchedPaths: [["agents", "defaults", "model", "primary"]],
env: {},
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledOnce();
});
it("ignores a missing placeholder outside model validation inputs", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: { defaults: { model: { primary: "provider-a/next" } } },
channels: { discord: { token: "${DISCORD_TOKEN}" } },
},
previousConfig: {
agents: { defaults: { model: { primary: "provider-a/current" } } },
channels: { discord: { token: "${DISCORD_TOKEN}" } },
},
touchedPaths: [["agents", "defaults", "model", "primary"]],
env: {},
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledOnce();
});
it("redacts provider details inherited indirectly from an expanded primary", async () => {
const resolveModelRef = vi.fn(async ({ ref }: ResolverInput) =>
ref.fallback ? "Unknown model: private-provider/backup" : undefined,
);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: { model: { primary: "${PRIMARY_REF}", fallbacks: ["backup"] } },
},
},
previousConfig: {
agents: {
defaults: { model: { primary: "provider-a/current", fallbacks: ["backup"] } },
},
},
touchedPaths: [["agents", "defaults", "model", "primary"]],
env: { PRIMARY_REF: "private-provider/main" },
resolveModelRef,
});
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain('model reference "backup"');
expect(result.errors[0]).not.toContain("private-provider");
});
it("redacts dependency values when authored spelling is unavailable", async () => {
const resolveModelRef = vi.fn(async ({ ref }: ResolverInput) =>
ref.fallback ? "Unknown model: provider-b/backup" : undefined,
);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: { model: { primary: "provider-b/main", fallbacks: ["backup"] } },
},
},
previousConfig: {
agents: {
defaults: { model: { primary: "provider-a/main", fallbacks: ["backup"] } },
},
},
touchedPaths: [["agents", "defaults", "model", "primary"]],
redactDependencyValues: true,
resolveModelRef,
});
expect(result.errors).toEqual([
expect.stringContaining('model reference "<configured model reference>"'),
]);
expect(result.errors[0]).not.toContain("provider-b");
expect(result.errors[0]).not.toContain('reference "backup"');
});
it("leaves a bare fallback unchecked when its primary provider is env-unresolved", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["backup"] } },
},
},
previousConfig: {
agents: {
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["previous"] } },
},
},
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
env: {},
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 0, refsTotal: 1, errors: [] });
expect(resolveModelRef).not.toHaveBeenCalled();
});
it("validates a bare fallback when its primary provider resolves from env", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["backup"] } },
},
},
previousConfig: {
agents: {
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["previous"] } },
},
},
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
env: { MODEL_REF: "provider-a/main" },
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledOnce();
});
it("validates an explicit fallback when its primary provider is env-unresolved", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: {
model: { primary: "${MODEL_REF}", fallbacks: ["provider-a/backup"] },
},
},
},
previousConfig: {
agents: {
defaults: {
model: { primary: "${MODEL_REF}", fallbacks: ["provider-a/previous"] },
},
},
},
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
env: {},
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledOnce();
});
it("rejects an invalid bare fallback when its primary provider is env-unresolved", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: { model: { primary: "${MODEL_REF}", fallbacks: [" "] } },
},
},
previousConfig: {
agents: {
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["previous"] } },
},
},
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
env: {},
resolveModelRef,
});
expect(result).toEqual({
refsChecked: 1,
refsTotal: 1,
errors: [expect.stringContaining("Model reference is empty")],
});
expect(resolveModelRef).not.toHaveBeenCalled();
});
it("validates a bare fallback when only the primary model is env-unresolved", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: {
defaults: { model: { primary: "provider-a/${MODEL_ID}", fallbacks: ["backup"] } },
},
},
previousConfig: {
agents: {
defaults: { model: { primary: "provider-a/current", fallbacks: ["previous"] } },
},
},
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
env: {},
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledOnce();
});
it("revalidates a fallback when an expanded primary is removed", async () => {
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
const result = await checkTouchedTextModelRefs({
config: {
agents: { defaults: { model: { fallbacks: ["backup"] } } },
},
previousConfig: {
agents: {
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["backup"] } },
},
},
touchedPaths: [["agents", "defaults", "model", "primary"]],
env: { MODEL_REF: "provider-a/main" },
resolveModelRef,
});
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
expect(resolveModelRef).toHaveBeenCalledWith({
config: expect.any(Object),
ref: {
path: "agents.defaults.model.fallbacks.0",
value: "backup",
fallback: true,
dependency: true,
},
});
});
});