fix: fail closed for invalid cron payload models

This commit is contained in:
Peter Steinberger
2026-04-28 04:12:14 +01:00
parent 00e30ba8d9
commit 343f2d7245
9 changed files with 102 additions and 36 deletions

View File

@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Backup: skip installed plugin `extensions/*/node_modules` dependency trees while keeping plugin manifests and source files in archives, so local backups avoid rebuildable npm payload bloat. Fixes #64144. Thanks @BrilliantWang.
- Cron/models: fail isolated cron runs closed when an explicit `payload.model` is not allowed or cannot be resolved, so scheduled jobs do not silently fall back to an unrelated agent default or paid route before configured provider proxies such as LiteLLM can run. Fixes #73146. Thanks @oneandrewwang.
- Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers.<id>.api` owner, so multi-GPU Ollama setups can dedicate embeddings to providers such as `ollama-5080` without losing the Ollama adapter or local auth semantics. Fixes #73150. Thanks @oneandrewwang.
- CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana.
- CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva.

View File

@@ -129,7 +129,7 @@ This fires ~56 times per month instead of 01 times per month. OpenClaw use
Restrict which tools the job can use, for example `--tools exec,read`.
</ParamField>
`--model` uses the selected allowed model as that job's primary model. It is not the same as a chat-session `/model` override: configured fallback chains still apply when the job primary fails. If the requested model is not allowed, cron logs a warning and falls back to the job's agent/default model selection instead.
`--model` uses the selected allowed model as that job's primary model. It is not the same as a chat-session `/model` override: configured fallback chains still apply when the job primary fails. If the requested model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of silently falling back to the job's agent/default model selection.
Cron jobs can also carry payload-level `fallbacks`. When present, that list replaces the configured fallback chain for the job. Use `fallbacks: []` in the job payload/API when you want a strict cron run that tries only the selected model. If a job has `--model` but neither payload nor configured fallbacks, OpenClaw passes an explicit empty fallback override so the agent primary is not appended as a hidden extra retry target.
@@ -378,7 +378,7 @@ Model override note:
- `openclaw cron add|edit --model ...` changes the job's selected model.
- If the model is allowed, that exact provider/model reaches the isolated agent run.
- If it is not allowed, cron warns and falls back to the job's agent/default model selection.
- If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error.
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.

View File

@@ -100,7 +100,7 @@ Note: cron job definitions live in `jobs.json`, while pending runtime state live
`cron add|edit --model <ref>` selects an allowed model for the job.
<Warning>
If the model is not allowed, cron warns and falls back to the job's agent or default model selection.
If the model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of falling back to the job's agent or default model selection.
</Warning>
Cron `--model` is a **job primary**, not a chat-session `/model` override. That means:

View File

@@ -3,6 +3,40 @@ import type { OpenClawConfig } from "../config/types.js";
import { resolveAllowedModelRef, resolveConfiguredModelRef } from "./model-selection-resolve.js";
describe("model-selection-resolve OpenRouter compat aliases", () => {
it("preserves exact configured proxy provider ids for cron-style aliases", () => {
const cfg = {
agents: {
defaults: {
models: {
"litellm/cron": {},
},
},
},
models: {
providers: {
litellm: {
api: "openai-completions",
baseUrl: "http://127.0.0.1:4000/v1",
models: [{ id: "cron", name: "Cron route" }],
},
},
},
} as unknown as OpenClawConfig;
expect(
resolveAllowedModelRef({
cfg,
catalog: [],
raw: "litellm/cron",
defaultProvider: "ollama",
defaultModel: "qwen35-27b-researcher",
}),
).toEqual({
key: "litellm/cron",
ref: { provider: "litellm", model: "cron" },
});
});
it("resolves openrouter:auto through the canonical OpenRouter auto model", () => {
const cfg = {
agents: {

View File

@@ -197,7 +197,10 @@ describe("cron model formatting and precedence edge cases", () => {
selectModel({
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/" },
}),
).resolves.toEqual({ ok: false, error: "invalid model" });
).resolves.toEqual({
ok: false,
error: "cron payload.model 'openai/' rejected: invalid model",
});
});
it("rejects model with leading slash (empty provider)", async () => {
@@ -205,7 +208,30 @@ describe("cron model formatting and precedence edge cases", () => {
selectModel({
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "/gpt-4.1-mini" },
}),
).resolves.toEqual({ ok: false, error: "invalid model" });
).resolves.toEqual({
ok: false,
error: "cron payload.model '/gpt-4.1-mini' rejected: invalid model",
});
});
it("reports the cron allowlist path when payload.model is not allowed", async () => {
resolveAllowedModelRefMock.mockReturnValueOnce({
error: "model not allowed: anthropic/claude-sonnet-4-6",
});
await expect(
selectModel({
payload: {
kind: "agentTurn",
message: DEFAULT_MESSAGE,
model: "anthropic/claude-sonnet-4-6",
},
}),
).resolves.toEqual({
ok: false,
error:
"cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.models allowlist: anthropic/claude-sonnet-4-6",
});
});
it("normalizes provider casing", async () => {

View File

@@ -169,7 +169,7 @@ describe("runCronIsolatedAgentTurn model overrides", () => {
});
expect(res.status).toBe("error");
expect(res.error).toMatch("invalid model");
expect(res.error).toMatch("cron payload.model 'openai/' rejected: invalid model");
expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled();
});
});

View File

@@ -35,13 +35,20 @@ export type ResolveCronModelSelectionResult =
ok: true;
provider: string;
model: string;
warning?: string;
}
| {
ok: false;
error: string;
};
function formatCronPayloadModelRejection(modelOverride: string, error: string): string {
if (error.startsWith("model not allowed:")) {
const modelRef = error.slice("model not allowed:".length).trim();
return `cron payload.model '${modelOverride}' rejected by agents.defaults.models allowlist: ${modelRef}`;
}
return `cron payload.model '${modelOverride}' rejected: ${error}`;
}
export async function resolveCronModelSelection(
params: ResolveCronModelSelectionParams,
): Promise<ResolveCronModelSelectionResult> {
@@ -112,15 +119,10 @@ export async function resolveCronModelSelection(
defaultModel: resolvedDefault.model,
});
if ("error" in resolvedOverride) {
if (resolvedOverride.error.startsWith("model not allowed:")) {
return {
ok: true,
provider,
model,
warning: `cron: payload.model '${modelOverride}' not allowed, falling back to agent defaults`,
};
}
return { ok: false, error: resolvedOverride.error };
return {
ok: false,
error: formatCronPayloadModelRejection(modelOverride, resolvedOverride.error),
};
}
provider = resolvedOverride.ref.provider;
model = resolvedOverride.ref.model;

View File

@@ -211,30 +211,36 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
expect(runParams.model).toBe("claude-sonnet-4-6");
});
it("falls back to agent defaults when payload.model is not allowed", async () => {
it("fails closed when payload.model is not allowed", async () => {
resolveAllowedModelRefMock.mockReturnValueOnce({
error: "model not allowed: anthropic/claude-sonnet-4-6",
});
await runSkillFilterCase({
cfg: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.4", fallbacks: defaultFallbacks },
const result = await runCronIsolatedAgentTurn(
makeSkillParams({
cfg: {
agents: {
defaults: {
model: { primary: "openai-codex/gpt-5.4", fallbacks: defaultFallbacks },
},
},
},
},
job: makeSkillJob({
payload: { kind: "agentTurn", message: "test", model: "anthropic/claude-sonnet-4-6" },
job: makeSkillJob({
payload: {
kind: "agentTurn",
message: "test",
model: "anthropic/claude-sonnet-4-6",
},
}),
}),
});
expect(logWarnMock).toHaveBeenCalledWith(
"cron: payload.model 'anthropic/claude-sonnet-4-6' not allowed, falling back to agent defaults",
);
expectDefaultModelCall({
primary: "openai-codex/gpt-5.4",
fallbacks: defaultFallbacks,
});
expect(result.status).toBe("error");
expect(result.error).toBe(
"cron payload.model 'anthropic/claude-sonnet-4-6' rejected by agents.defaults.models allowlist: anthropic/claude-sonnet-4-6",
);
expect(logWarnMock).not.toHaveBeenCalled();
expect(runWithModelFallbackMock).not.toHaveBeenCalled();
});
it("returns an error when payload.model is invalid", async () => {
@@ -251,7 +257,7 @@ describe("runCronIsolatedAgentTurn — skill filter", () => {
);
expect(result.status).toBe("error");
expect(result.error).toBe("invalid model: openai/");
expect(result.error).toBe("cron payload.model 'openai/' rejected: invalid model: openai/");
expect(logWarnMock).not.toHaveBeenCalled();
expect(runWithModelFallbackMock).not.toHaveBeenCalled();
});

View File

@@ -575,9 +575,6 @@ async function prepareCronRunContext(params: {
}
let provider = resolvedModelSelection.provider;
let model = resolvedModelSelection.model;
if (resolvedModelSelection.warning) {
logWarn(resolvedModelSelection.warning);
}
const preflight = await (
await loadCronModelPreflightRuntime()