mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 13:44:03 +00:00
fix(gateway): stop guessing legacy session model providers
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -113,6 +113,27 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
|
||||
- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting.
|
||||
- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting.
|
||||
- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
|
||||
- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
|
||||
- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
|
||||
- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
|
||||
- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
|
||||
- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728.
|
||||
- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
|
||||
- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
|
||||
- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr.
|
||||
- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
|
||||
- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
|
||||
- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
|
||||
- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
|
||||
- CLI/Memory search: accept `--query <text>` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
## 2026.2.23
|
||||
|
||||
@@ -202,6 +202,8 @@ describe("gateway server sessions", () => {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: recent,
|
||||
modelProvider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
thinkingLevel: "low",
|
||||
@@ -456,11 +458,13 @@ describe("gateway server sessions", () => {
|
||||
const reset = await rpcReq<{
|
||||
ok: true;
|
||||
key: string;
|
||||
entry: { sessionId: string };
|
||||
entry: { sessionId: string; modelProvider?: string; model?: string };
|
||||
}>(ws, "sessions.reset", { key: "agent:main:main" });
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.key).toBe("agent:main:main");
|
||||
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
|
||||
expect(reset.payload?.entry.modelProvider).toBe("anthropic");
|
||||
expect(reset.payload?.entry.model).toBe("claude-sonnet-4-6");
|
||||
const filesAfterReset = await fs.readdir(dir);
|
||||
expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
parseGroupKey,
|
||||
pruneLegacyStoreKeys,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionModelIdentityRef,
|
||||
resolveSessionModelRef,
|
||||
resolveSessionStoreKey,
|
||||
} from "./session-utils.js";
|
||||
@@ -340,10 +341,7 @@ describe("resolveSessionModelRef", () => {
|
||||
expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" });
|
||||
});
|
||||
|
||||
test("does not inherit config default provider for legacy session without modelProvider", () => {
|
||||
// Regression: when config default_model is "google-gemini-cli/gemini-3-pro-preview"
|
||||
// and a legacy session entry has model="claude-sonnet-4-6" but modelProvider is
|
||||
// undefined, the TUI footer should NOT show "google-gemini-cli/claude-sonnet-4-6".
|
||||
test("falls back to resolved provider for unprefixed legacy runtime model", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -359,10 +357,10 @@ describe("resolveSessionModelRef", () => {
|
||||
modelProvider: undefined,
|
||||
});
|
||||
|
||||
// The provider must NOT be "google-gemini-cli" — it should fall back to
|
||||
// the system default provider, not the config-resolved provider.
|
||||
expect(resolved.provider).not.toBe("google-gemini-cli");
|
||||
expect(resolved.model).toBe("claude-sonnet-4-6");
|
||||
expect(resolved).toEqual({
|
||||
provider: "google-gemini-cli",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves provider from slash-prefixed model when modelProvider is missing", () => {
|
||||
@@ -387,6 +385,91 @@ describe("resolveSessionModelRef", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionModelIdentityRef", () => {
|
||||
test("does not inherit default provider for unprefixed legacy runtime model", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||
sessionId: "legacy-session",
|
||||
updatedAt: Date.now(),
|
||||
model: "claude-sonnet-4-6",
|
||||
modelProvider: undefined,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ model: "claude-sonnet-4-6" });
|
||||
});
|
||||
|
||||
test("infers provider from configured model allowlist when unambiguous", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||
sessionId: "legacy-session",
|
||||
updatedAt: Date.now(),
|
||||
model: "claude-sonnet-4-6",
|
||||
modelProvider: undefined,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
|
||||
});
|
||||
|
||||
test("keeps provider unknown when configured models are ambiguous", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
"minimax/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||
sessionId: "legacy-session",
|
||||
updatedAt: Date.now(),
|
||||
model: "claude-sonnet-4-6",
|
||||
modelProvider: undefined,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ model: "claude-sonnet-4-6" });
|
||||
});
|
||||
|
||||
test("preserves provider from slash-prefixed runtime model", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||
sessionId: "slash-model",
|
||||
updatedAt: Date.now(),
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
modelProvider: undefined,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSessionTitle", () => {
|
||||
test("returns undefined for undefined entry", () => {
|
||||
expect(deriveSessionTitle(undefined)).toBeUndefined();
|
||||
@@ -575,6 +658,67 @@ describe("listSessionsFromStore search", () => {
|
||||
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
|
||||
});
|
||||
|
||||
test("does not guess provider for legacy runtime model without modelProvider", () => {
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: now,
|
||||
model: "claude-sonnet-4-6",
|
||||
} as SessionEntry,
|
||||
};
|
||||
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.modelProvider).toBeUndefined();
|
||||
expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
test("infers provider for legacy runtime model when allowlist match is unique", () => {
|
||||
const cfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: now,
|
||||
model: "claude-sonnet-4-6",
|
||||
} as SessionEntry,
|
||||
};
|
||||
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: {},
|
||||
});
|
||||
|
||||
expect(result.sessions[0]?.modelProvider).toBe("anthropic");
|
||||
expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
test("exposes unknown totals when freshness is stale or missing", () => {
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
|
||||
@@ -634,6 +634,42 @@ export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults
|
||||
};
|
||||
}
|
||||
|
||||
function inferProviderFromConfiguredModels(
|
||||
cfg: OpenClawConfig,
|
||||
runtimeModel: string,
|
||||
): string | undefined {
|
||||
const model = runtimeModel.trim();
|
||||
if (!model || model.includes("/")) {
|
||||
return undefined;
|
||||
}
|
||||
const configuredModels = cfg.agents?.defaults?.models;
|
||||
if (!configuredModels) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = model.toLowerCase();
|
||||
const providers = new Set<string>();
|
||||
for (const key of Object.keys(configuredModels)) {
|
||||
const ref = key.trim();
|
||||
if (!ref || !ref.includes("/")) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseModelRef(ref, DEFAULT_PROVIDER);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.model === model || parsed.model.toLowerCase() === normalized) {
|
||||
providers.add(parsed.provider);
|
||||
if (providers.size > 1) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (providers.size !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
return providers.values().next().value;
|
||||
}
|
||||
|
||||
export function resolveSessionModelRef(
|
||||
cfg: OpenClawConfig,
|
||||
entry?:
|
||||
@@ -665,19 +701,7 @@ export function resolveSessionModelRef(
|
||||
// provider the user has no credentials for.
|
||||
return { provider: runtimeProvider, model: runtimeModel };
|
||||
}
|
||||
// Legacy session entries may have model recorded without modelProvider.
|
||||
// When runtimeModel has no "/" prefix, parseModelRef would use the fallback
|
||||
// provider — but using `resolved.provider` (from config default_model) is wrong
|
||||
// because the default model may belong to a completely different provider.
|
||||
// Example: config default_model = "google-gemini-cli/gemini-3-pro-preview" but
|
||||
// session model = "claude-sonnet-4-6" → would wrongly resolve to
|
||||
// { provider: "google-gemini-cli", model: "claude-sonnet-4-6" }.
|
||||
// Fix: use DEFAULT_PROVIDER as the fallback for unprefixed model names so we
|
||||
// don't cross-contaminate the provider from an unrelated config default.
|
||||
const fallbackProvider = runtimeModel.includes("/")
|
||||
? provider || DEFAULT_PROVIDER
|
||||
: DEFAULT_PROVIDER;
|
||||
const parsedRuntime = parseModelRef(runtimeModel, fallbackProvider);
|
||||
const parsedRuntime = parseModelRef(runtimeModel, provider || DEFAULT_PROVIDER);
|
||||
if (parsedRuntime) {
|
||||
provider = parsedRuntime.provider;
|
||||
model = parsedRuntime.model;
|
||||
@@ -704,6 +728,35 @@ export function resolveSessionModelRef(
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
export function resolveSessionModelIdentityRef(
|
||||
cfg: OpenClawConfig,
|
||||
entry?:
|
||||
| SessionEntry
|
||||
| Pick<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
|
||||
agentId?: string,
|
||||
): { provider?: string; model: string } {
|
||||
const runtimeModel = entry?.model?.trim();
|
||||
const runtimeProvider = entry?.modelProvider?.trim();
|
||||
if (runtimeModel) {
|
||||
if (runtimeProvider) {
|
||||
return { provider: runtimeProvider, model: runtimeModel };
|
||||
}
|
||||
if (runtimeModel.includes("/")) {
|
||||
const parsedRuntime = parseModelRef(runtimeModel, DEFAULT_PROVIDER);
|
||||
if (parsedRuntime) {
|
||||
return { provider: parsedRuntime.provider, model: parsedRuntime.model };
|
||||
}
|
||||
return { model: runtimeModel };
|
||||
}
|
||||
const inferredProvider = inferProviderFromConfiguredModels(cfg, runtimeModel);
|
||||
return inferredProvider
|
||||
? { provider: inferredProvider, model: runtimeModel }
|
||||
: { model: runtimeModel };
|
||||
}
|
||||
const resolved = resolveSessionModelRef(cfg, entry, agentId);
|
||||
return { provider: resolved.provider, model: resolved.model };
|
||||
}
|
||||
|
||||
export function listSessionsFromStore(params: {
|
||||
cfg: OpenClawConfig;
|
||||
storePath: string;
|
||||
@@ -794,8 +847,8 @@ export function listSessionsFromStore(params: {
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
const parsedAgent = parseAgentSessionKey(key);
|
||||
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
|
||||
const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId);
|
||||
const modelProvider = resolvedModel.provider ?? DEFAULT_PROVIDER;
|
||||
const resolvedModel = resolveSessionModelIdentityRef(cfg, entry, sessionAgentId);
|
||||
const modelProvider = resolvedModel.provider;
|
||||
const model = resolvedModel.model ?? DEFAULT_MODEL;
|
||||
return {
|
||||
key,
|
||||
|
||||
Reference in New Issue
Block a user