From 0a9a085cfb3f11d2895c3b322eb10506c1d2d593 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 14 Mar 2026 09:26:49 -0500 Subject: [PATCH 1/4] fix(doctor): warn about synthetic catalog models missing from allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When operators configure an explicit model allowlist via `agents.defaults.models`, synthetic catalog fallback models (added by `applySyntheticCatalogFallbacks()`) can be present in the runtime catalog but invisible to agents because they are not in the allowlist. Add a new `noteSyntheticAllowlistGaps()` doctor check that compares the runtime catalog against the configured allowlist and warns about synthetic models (openai/openai-codex providers) that are available but not allowlisted. The check is skipped when no allowlist is configured (all models allowed) and never auto-modifies the allowlist — it only warns. Closes #39992 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../doctor-synthetic-allowlist.test.ts | 117 ++++++++++++++++++ src/commands/doctor-synthetic-allowlist.ts | 64 ++++++++++ src/commands/doctor.ts | 2 + 3 files changed, 183 insertions(+) create mode 100644 src/commands/doctor-synthetic-allowlist.test.ts create mode 100644 src/commands/doctor-synthetic-allowlist.ts diff --git a/src/commands/doctor-synthetic-allowlist.test.ts b/src/commands/doctor-synthetic-allowlist.test.ts new file mode 100644 index 00000000000..3b0b452e146 --- /dev/null +++ b/src/commands/doctor-synthetic-allowlist.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(), +})); +vi.mock("../agents/model-selection.js", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, buildAllowedModelSet: vi.fn() }; +}); +vi.mock("../terminal/note.js", () => ({ note: vi.fn() })); + +import type { OpenClawConfig } from "../config/config.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { buildAllowedModelSet } from "../agents/model-selection.js"; +import { note } from "../terminal/note.js"; +import { noteSyntheticAllowlistGaps } from "./doctor-synthetic-allowlist.js"; + +const mockLoadModelCatalog = vi.mocked(loadModelCatalog); +const mockBuildAllowedModelSet = vi.mocked(buildAllowedModelSet); +const mockNote = vi.mocked(note); + +function makeCfg(models?: Record): OpenClawConfig { + return { + agents: { defaults: { models: models as OpenClawConfig["agents"]["defaults"]["models"] } }, + } as OpenClawConfig; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("noteSyntheticAllowlistGaps", () => { + it("skips when no allowlist is configured", async () => { + await noteSyntheticAllowlistGaps(makeCfg()); + expect(mockLoadModelCatalog).not.toHaveBeenCalled(); + expect(mockNote).not.toHaveBeenCalled(); + }); + + it("skips when allowlist is empty", async () => { + await noteSyntheticAllowlistGaps(makeCfg({})); + expect(mockNote).not.toHaveBeenCalled(); + }); + + it("does not warn when all synthetic models are allowlisted", async () => { + const catalog = [ + { id: "gpt-5.4", name: "gpt-5.4", provider: "openai-codex" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" }, + ]; + mockLoadModelCatalog.mockResolvedValue(catalog); + mockBuildAllowedModelSet.mockReturnValue({ + allowAny: false, + allowedCatalog: catalog, + allowedKeys: new Set(["openai-codex/gpt-5.4", "anthropic/claude-opus-4-6"]), + }); + + await noteSyntheticAllowlistGaps( + makeCfg({ "openai-codex/gpt-5.4": {}, "anthropic/claude-opus-4-6": {} }), + ); + expect(mockNote).not.toHaveBeenCalled(); + }); + + it("warns about synthetic models missing from allowlist", async () => { + const catalog = [ + { id: "gpt-5.4", name: "gpt-5.4", provider: "openai-codex" }, + { id: "gpt-5.4", name: "gpt-5.4", provider: "openai" }, + { id: "gpt-5.4-pro", name: "gpt-5.4-pro", provider: "openai" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" }, + ]; + mockLoadModelCatalog.mockResolvedValue(catalog); + mockBuildAllowedModelSet.mockReturnValue({ + allowAny: false, + allowedCatalog: [catalog[0]!, catalog[3]!], + allowedKeys: new Set(["openai-codex/gpt-5.4", "anthropic/claude-opus-4-6"]), + }); + + await noteSyntheticAllowlistGaps( + makeCfg({ "openai-codex/gpt-5.4": {}, "anthropic/claude-opus-4-6": {} }), + ); + + expect(mockNote).toHaveBeenCalledTimes(1); + const noteText = mockNote.mock.calls[0]![0] as string; + expect(noteText).toContain("2 synthetic model"); + expect(noteText).toContain("openai/gpt-5.4"); + expect(noteText).toContain("openai/gpt-5.4-pro"); + }); + + it("ignores non-synthetic providers", async () => { + const catalog = [ + { id: "gemini-3-flash", name: "Gemini 3 Flash", provider: "google" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" }, + ]; + mockLoadModelCatalog.mockResolvedValue(catalog); + mockBuildAllowedModelSet.mockReturnValue({ + allowAny: false, + allowedCatalog: [catalog[1]!], + allowedKeys: new Set(["anthropic/claude-opus-4-6"]), + }); + + await noteSyntheticAllowlistGaps(makeCfg({ "anthropic/claude-opus-4-6": {} })); + expect(mockNote).not.toHaveBeenCalled(); + }); + + it("handles single synthetic gap with correct grammar", async () => { + const catalog = [{ id: "gpt-5.4", name: "gpt-5.4", provider: "openai" }]; + mockLoadModelCatalog.mockResolvedValue(catalog); + mockBuildAllowedModelSet.mockReturnValue({ + allowAny: false, + allowedCatalog: [], + allowedKeys: new Set(["anthropic/claude-opus-4-6"]), + }); + + await noteSyntheticAllowlistGaps(makeCfg({ "anthropic/claude-opus-4-6": {} })); + const noteText = mockNote.mock.calls[0]![0] as string; + expect(noteText).toContain("1 synthetic model available"); + expect(noteText).not.toContain("models available"); + }); +}); diff --git a/src/commands/doctor-synthetic-allowlist.ts b/src/commands/doctor-synthetic-allowlist.ts new file mode 100644 index 00000000000..df472bb77fd --- /dev/null +++ b/src/commands/doctor-synthetic-allowlist.ts @@ -0,0 +1,64 @@ +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { buildAllowedModelSet, modelKey } from "../agents/model-selection.js"; +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { note } from "../terminal/note.js"; + +/** + * Known providers whose models may appear in the runtime catalog via + * `applySyntheticCatalogFallbacks()`. Only these are checked to avoid + * false positives from Pi SDK registry models. + */ +const SYNTHETIC_FALLBACK_PROVIDERS = new Set(["openai", "openai-codex"]); + +/** + * Warn when synthetic catalog fallback models are present in the runtime + * catalog but missing from the operator's explicit model allowlist. + * + * Skips the check when no allowlist is configured (empty + * `agents.defaults.models` means all models are allowed). + * + * @see https://github.com/openclaw/openclaw/issues/39992 + */ +export async function noteSyntheticAllowlistGaps(cfg: OpenClawConfig): Promise { + const modelsConfig = cfg.agents?.defaults?.models; + if (!modelsConfig || Object.keys(modelsConfig).length === 0) { + return; + } + + const catalog = await loadModelCatalog({ config: cfg }); + const { allowedKeys } = buildAllowedModelSet({ + cfg, + catalog, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + + const gaps: Array<{ provider: string; id: string }> = []; + for (const entry of catalog) { + if (!SYNTHETIC_FALLBACK_PROVIDERS.has(entry.provider.toLowerCase())) { + continue; + } + const key = modelKey(entry.provider, entry.id); + if (!allowedKeys.has(key)) { + gaps.push(entry); + } + } + + if (gaps.length === 0) { + return; + } + + const lines = [ + `${gaps.length} synthetic model${gaps.length === 1 ? "" : "s"} available but not in your allowlist:`, + ]; + for (const entry of gaps) { + lines.push(` - ${modelKey(entry.provider, entry.id)}`); + } + lines.push( + `To add: ${formatCliCommand("openclaw models allowlist add ")}`, + "(Models are functional but hidden from agents until allowlisted)", + ); + note(lines.join("\n"), "Synthetic model allowlist"); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bdde2781ff9..a2ea8c986ab 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -49,6 +49,7 @@ import { import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js"; import { noteSecurityWarnings } from "./doctor-security.js"; +import { noteSyntheticAllowlistGaps } from "./doctor-synthetic-allowlist.js"; import { noteSessionLockHealth } from "./doctor-session-locks.js"; import { noteStateIntegrity, noteWorkspaceBackupTip } from "./doctor-state-integrity.js"; import { @@ -306,6 +307,7 @@ export async function doctorCommand( } noteWorkspaceStatus(cfg); + await noteSyntheticAllowlistGaps(cfg); await noteBootstrapFileSize(cfg); // Check and fix shell completion From 3b393e074f152deff231e12452cd2eb9a38e4baa Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 15 Mar 2026 16:47:17 -0500 Subject: [PATCH 2/4] fix: restrict gap check to exact synthetic fallback IDs and guard allowAny - Replace broad provider-based check with exact SYNTHETIC_FALLBACK_KEYS set matching the 4 IDs from applySyntheticCatalogFallbacks(). Prevents false positives for real catalog models from openai/openai-codex. - Add early return when buildAllowedModelSet returns allowAny: true. - Add missing mockLoadModelCatalog assertion in empty-allowlist test. - Add test for allowAny: true path. Addresses review feedback from chatgpt-codex-connector[bot] and greptile-apps[bot]. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../doctor-synthetic-allowlist.test.ts | 26 ++++++++++++++----- src/commands/doctor-synthetic-allowlist.ts | 22 +++++++++++----- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/commands/doctor-synthetic-allowlist.test.ts b/src/commands/doctor-synthetic-allowlist.test.ts index 3b0b452e146..a5a66717729 100644 --- a/src/commands/doctor-synthetic-allowlist.test.ts +++ b/src/commands/doctor-synthetic-allowlist.test.ts @@ -4,14 +4,14 @@ vi.mock("../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(), })); vi.mock("../agents/model-selection.js", async (importOriginal) => { - const actual = (await importOriginal()) as Record; + const actual = await importOriginal(); return { ...actual, buildAllowedModelSet: vi.fn() }; }); vi.mock("../terminal/note.js", () => ({ note: vi.fn() })); -import type { OpenClawConfig } from "../config/config.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { buildAllowedModelSet } from "../agents/model-selection.js"; +import type { OpenClawConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; import { noteSyntheticAllowlistGaps } from "./doctor-synthetic-allowlist.js"; @@ -38,6 +38,20 @@ describe("noteSyntheticAllowlistGaps", () => { it("skips when allowlist is empty", async () => { await noteSyntheticAllowlistGaps(makeCfg({})); + expect(mockLoadModelCatalog).not.toHaveBeenCalled(); + expect(mockNote).not.toHaveBeenCalled(); + }); + + it("skips when allowAny is true", async () => { + const catalog = [{ id: "gpt-5.4", name: "gpt-5.4", provider: "openai" }]; + mockLoadModelCatalog.mockResolvedValue(catalog); + mockBuildAllowedModelSet.mockReturnValue({ + allowAny: true, + allowedCatalog: catalog, + allowedKeys: new Set(["openai/gpt-5.4"]), + }); + + await noteSyntheticAllowlistGaps(makeCfg({ "anthropic/claude-opus-4-6": {} })); expect(mockNote).not.toHaveBeenCalled(); }); @@ -69,7 +83,7 @@ describe("noteSyntheticAllowlistGaps", () => { mockLoadModelCatalog.mockResolvedValue(catalog); mockBuildAllowedModelSet.mockReturnValue({ allowAny: false, - allowedCatalog: [catalog[0]!, catalog[3]!], + allowedCatalog: [catalog[0], catalog[3]], allowedKeys: new Set(["openai-codex/gpt-5.4", "anthropic/claude-opus-4-6"]), }); @@ -78,7 +92,7 @@ describe("noteSyntheticAllowlistGaps", () => { ); expect(mockNote).toHaveBeenCalledTimes(1); - const noteText = mockNote.mock.calls[0]![0] as string; + const noteText = mockNote.mock.calls[0][0]; expect(noteText).toContain("2 synthetic model"); expect(noteText).toContain("openai/gpt-5.4"); expect(noteText).toContain("openai/gpt-5.4-pro"); @@ -92,7 +106,7 @@ describe("noteSyntheticAllowlistGaps", () => { mockLoadModelCatalog.mockResolvedValue(catalog); mockBuildAllowedModelSet.mockReturnValue({ allowAny: false, - allowedCatalog: [catalog[1]!], + allowedCatalog: [catalog[1]], allowedKeys: new Set(["anthropic/claude-opus-4-6"]), }); @@ -110,7 +124,7 @@ describe("noteSyntheticAllowlistGaps", () => { }); await noteSyntheticAllowlistGaps(makeCfg({ "anthropic/claude-opus-4-6": {} })); - const noteText = mockNote.mock.calls[0]![0] as string; + const noteText = mockNote.mock.calls[0][0]; expect(noteText).toContain("1 synthetic model available"); expect(noteText).not.toContain("models available"); }); diff --git a/src/commands/doctor-synthetic-allowlist.ts b/src/commands/doctor-synthetic-allowlist.ts index df472bb77fd..f41eb6f17db 100644 --- a/src/commands/doctor-synthetic-allowlist.ts +++ b/src/commands/doctor-synthetic-allowlist.ts @@ -6,11 +6,16 @@ import type { OpenClawConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; /** - * Known providers whose models may appear in the runtime catalog via - * `applySyntheticCatalogFallbacks()`. Only these are checked to avoid - * false positives from Pi SDK registry models. + * The exact provider/id pairs created by `applySyntheticCatalogFallbacks()`. + * Only these are checked to avoid false positives from real catalog models + * that happen to share the same provider. */ -const SYNTHETIC_FALLBACK_PROVIDERS = new Set(["openai", "openai-codex"]); +const SYNTHETIC_FALLBACK_KEYS = new Set([ + "openai/gpt-5.4", + "openai/gpt-5.4-pro", + "openai-codex/gpt-5.4", + "openai-codex/gpt-5.3-codex-spark", +]); /** * Warn when synthetic catalog fallback models are present in the runtime @@ -28,19 +33,22 @@ export async function noteSyntheticAllowlistGaps(cfg: OpenClawConfig): Promise = []; for (const entry of catalog) { - if (!SYNTHETIC_FALLBACK_PROVIDERS.has(entry.provider.toLowerCase())) { + const key = modelKey(entry.provider, entry.id); + if (!SYNTHETIC_FALLBACK_KEYS.has(key)) { continue; } - const key = modelKey(entry.provider, entry.id); if (!allowedKeys.has(key)) { gaps.push(entry); } From ea5fa228925b08c22b7838d8d4fff1cdbbccaaa1 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 15 Mar 2026 16:58:14 -0500 Subject: [PATCH 3/4] fix: replace nonexistent allowlist CLI command with config set openclaw models allowlist add does not exist. Use openclaw config set to add models to agents.defaults.models, matching the existing config CLI convention used by other doctor hints. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/doctor-synthetic-allowlist.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/doctor-synthetic-allowlist.ts b/src/commands/doctor-synthetic-allowlist.ts index f41eb6f17db..9c01a9b2a21 100644 --- a/src/commands/doctor-synthetic-allowlist.ts +++ b/src/commands/doctor-synthetic-allowlist.ts @@ -65,7 +65,8 @@ export async function noteSyntheticAllowlistGaps(cfg: OpenClawConfig): Promise")}`, + `To add: ${formatCliCommand("openclaw config set agents.defaults.models./ '{}'")}`, + `Or edit the config file directly: ${formatCliCommand("openclaw config file")}`, "(Models are functional but hidden from agents until allowlisted)", ); note(lines.join("\n"), "Synthetic model allowlist"); From 97f53ea1a3879d0c8ec88d4e060fb31bd6cd3ce3 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 15 Mar 2026 17:38:36 -0500 Subject: [PATCH 4/4] style: fix import sort order in doctor.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a2ea8c986ab..41b20dee038 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -49,13 +49,13 @@ import { import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js"; import { noteSecurityWarnings } from "./doctor-security.js"; -import { noteSyntheticAllowlistGaps } from "./doctor-synthetic-allowlist.js"; import { noteSessionLockHealth } from "./doctor-session-locks.js"; import { noteStateIntegrity, noteWorkspaceBackupTip } from "./doctor-state-integrity.js"; import { detectLegacyStateMigrations, runLegacyStateMigrations, } from "./doctor-state-migrations.js"; +import { noteSyntheticAllowlistGaps } from "./doctor-synthetic-allowlist.js"; import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js"; import { noteWorkspaceStatus } from "./doctor-workspace-status.js";