Compare commits

...

2 Commits

Author SHA1 Message Date
Gustavo Madeira Santana
61feb7eb99 fix(ui): harden config form submit coercion (#13468) (thanks @mcaxtr) 2026-02-11 01:03:09 -05:00
Marcus Castro
1d4108e17c fix(ui): coerce form values to schema types before config.set serialization
HTML <input> elements produce string .value properties, so numeric and boolean
config fields can leak into configForm as strings.  Add coerceFormValues() that
walks the form object tree alongside the JSON Schema and converts string values
back to their schema-defined types before JSON serialization.

Fixes #13348
2026-02-11 01:02:00 -05:00
5 changed files with 695 additions and 8 deletions

View File

@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc.
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
- Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr.
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.

View File

@@ -3,6 +3,7 @@ import {
applyConfigSnapshot,
applyConfig,
runUpdate,
saveConfig,
updateConfigFormValue,
type ConfigState,
} from "./config.ts";
@@ -157,6 +158,96 @@ describe("applyConfig", () => {
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
});
it("coerces schema-typed values before config.apply in form mode", async () => {
const request = vi.fn().mockImplementation(async (method: string) => {
if (method === "config.get") {
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
}
return {};
});
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:web:dm:test";
state.configFormMode = "form";
state.configForm = {
gateway: { port: "18789", debug: "true" },
};
state.configSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
port: { type: "number" },
debug: { type: "boolean" },
},
},
},
};
state.configSnapshot = { hash: "hash-apply-1" };
await applyConfig(state);
expect(request.mock.calls[0]?.[0]).toBe("config.apply");
const params = request.mock.calls[0]?.[1] as {
raw: string;
baseHash: string;
sessionKey: string;
};
const parsed = JSON.parse(params.raw) as {
gateway: { port: unknown; debug: unknown };
};
expect(typeof parsed.gateway.port).toBe("number");
expect(parsed.gateway.port).toBe(18789);
expect(parsed.gateway.debug).toBe(true);
expect(params.baseHash).toBe("hash-apply-1");
expect(params.sessionKey).toBe("agent:main:web:dm:test");
});
});
describe("saveConfig", () => {
it("coerces schema-typed values before config.set in form mode", async () => {
const request = vi.fn().mockImplementation(async (method: string) => {
if (method === "config.get") {
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
}
return {};
});
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.configFormMode = "form";
state.configForm = {
gateway: { port: "18789", enabled: "false" },
};
state.configSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
port: { type: "number" },
enabled: { type: "boolean" },
},
},
},
};
state.configSnapshot = { hash: "hash-save-1" };
await saveConfig(state);
expect(request.mock.calls[0]?.[0]).toBe("config.set");
const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string };
const parsed = JSON.parse(params.raw) as {
gateway: { port: unknown; enabled: unknown };
};
expect(typeof parsed.gateway.port).toBe("number");
expect(parsed.gateway.port).toBe(18789);
expect(parsed.gateway.enabled).toBe(false);
expect(params.baseHash).toBe("hash-save-1");
});
});
describe("runUpdate", () => {

View File

@@ -1,5 +1,7 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts";
import type { JsonSchema } from "../views/config-form.shared.ts";
import { coerceFormValues } from "./config/form-coerce.ts";
import {
cloneConfigObject,
removePathValue,
@@ -99,6 +101,25 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
}
}
/**
* Serialize the form state for submission to `config.set` / `config.apply`.
*
* HTML `<input>` elements produce string `.value` properties, so numeric and
* boolean config fields can leak into `configForm` as strings. We coerce
* them back to their schema-defined types before JSON serialization so the
* gateway's Zod validation always sees correctly typed values.
*/
function serializeFormForSubmit(state: ConfigState): string {
if (state.configFormMode !== "form" || !state.configForm) {
return state.configRaw;
}
const schema = state.configSchema as JsonSchema | null;
const form = schema
? (coerceFormValues(state.configForm, schema) as Record<string, unknown>)
: state.configForm;
return serializeConfigForm(form);
}
export async function saveConfig(state: ConfigState) {
if (!state.client || !state.connected) {
return;
@@ -106,10 +127,7 @@ export async function saveConfig(state: ConfigState) {
state.configSaving = true;
state.lastError = null;
try {
const raw =
state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm)
: state.configRaw;
const raw = serializeFormForSubmit(state);
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";
@@ -132,10 +150,7 @@ export async function applyConfig(state: ConfigState) {
state.configApplying = true;
state.lastError = null;
try {
const raw =
state.configFormMode === "form" && state.configForm
? serializeConfigForm(state.configForm)
: state.configRaw;
const raw = serializeFormForSubmit(state);
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
state.lastError = "Config hash missing; reload and retry.";

View File

@@ -0,0 +1,144 @@
import { schemaType, type JsonSchema } from "../../views/config-form.shared.ts";
/**
* Walk a form value tree alongside its JSON Schema and coerce string values
* to their schema-defined types (number, boolean).
*
* HTML `<input>` elements always produce string `.value` properties. Even
* though the form rendering code converts values correctly for most paths,
* some interactions (map-field repopulation, re-renders, paste, etc.) can
* leak raw strings into the config form state. This utility acts as a
* safety net before serialization so that `config.set` always receives
* correctly typed JSON.
*/
export function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
if (value === null || value === undefined) {
return value;
}
const type = schemaType(schema);
// Handle anyOf/oneOf — try to match the value against a variant
if (schema.anyOf || schema.oneOf) {
const variants = (schema.anyOf ?? schema.oneOf ?? []).filter(
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
);
if (variants.length === 1) {
return coerceFormValues(value, variants[0]);
}
// Try number/boolean coercion for string values
if (typeof value === "string") {
for (const variant of variants) {
const variantType = schemaType(variant);
if (variantType === "number" || variantType === "integer") {
const trimmed = value.trim();
if (trimmed === "") {
return undefined;
}
const parsed = Number(trimmed);
if (!Number.isNaN(parsed)) {
if (variantType === "integer" && !Number.isInteger(parsed)) {
continue;
}
return parsed;
}
}
if (variantType === "boolean") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
}
}
// For non-string values (objects, arrays), try to recurse into matching variant
for (const variant of variants) {
const variantType = schemaType(variant);
if (variantType === "object" && typeof value === "object" && !Array.isArray(value)) {
return coerceFormValues(value, variant);
}
if (variantType === "array" && Array.isArray(value)) {
return coerceFormValues(value, variant);
}
}
return value;
}
if (type === "number" || type === "integer") {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "") {
return undefined;
}
const parsed = Number(trimmed);
if (!Number.isNaN(parsed)) {
if (type === "integer" && !Number.isInteger(parsed)) {
return value;
}
return parsed;
}
}
return value;
}
if (type === "boolean") {
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
return value;
}
if (type === "object") {
if (typeof value !== "object" || Array.isArray(value)) {
return value;
}
const obj = value as Record<string, unknown>;
const props = schema.properties ?? {};
const additional =
schema.additionalProperties && typeof schema.additionalProperties === "object"
? schema.additionalProperties
: null;
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
const propSchema = props[key] ?? additional;
const coerced = propSchema ? coerceFormValues(val, propSchema) : val;
// Omit undefined — "clear field = unset" for optional properties
if (coerced !== undefined) {
result[key] = coerced;
}
}
return result;
}
if (type === "array") {
if (!Array.isArray(value)) {
return value;
}
if (Array.isArray(schema.items)) {
// Tuple form: each index has its own schema
const tuple = schema.items;
return value.map((item, i) => {
const s = i < tuple.length ? tuple[i] : undefined;
return s ? coerceFormValues(item, s) : item;
});
}
const itemsSchema = schema.items;
if (!itemsSchema) {
return value;
}
return value.map((item) => coerceFormValues(item, itemsSchema)).filter((v) => v !== undefined);
}
return value;
}

View File

@@ -0,0 +1,436 @@
import { describe, expect, it } from "vitest";
import type { JsonSchema } from "../../views/config-form.shared.ts";
import { coerceFormValues } from "./form-coerce.ts";
import { cloneConfigObject, serializeConfigForm, setPathValue } from "./form-utils.ts";
/**
* Minimal model provider schema matching the Zod-generated JSON Schema for
* `models.providers` (see zod-schema.core.ts → ModelDefinitionSchema).
*/
const modelDefinitionSchema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
reasoning: { type: "boolean" },
contextWindow: { type: "number" },
maxTokens: { type: "number" },
cost: {
type: "object",
properties: {
input: { type: "number" },
output: { type: "number" },
cacheRead: { type: "number" },
cacheWrite: { type: "number" },
},
},
},
};
const modelProviderSchema: JsonSchema = {
type: "object",
properties: {
baseUrl: { type: "string" },
apiKey: { type: "string" },
models: {
type: "array",
items: modelDefinitionSchema,
},
},
};
const modelsConfigSchema: JsonSchema = {
type: "object",
properties: {
providers: {
type: "object",
additionalProperties: modelProviderSchema,
},
},
};
const topLevelSchema: JsonSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
models: modelsConfigSchema,
},
};
function makeConfigWithProvider(): Record<string, unknown> {
return {
gateway: { auth: { token: "test-token" } },
models: {
providers: {
xai: {
baseUrl: "https://api.x.ai/v1",
models: [
{
id: "grok-4",
name: "Grok 4",
contextWindow: 131072,
maxTokens: 8192,
cost: { input: 0.5, output: 1.0, cacheRead: 0.1, cacheWrite: 0.2 },
},
],
},
},
},
};
}
describe("form-utils preserves numeric types", () => {
it("serializeConfigForm preserves numbers in JSON output", () => {
const form = makeConfigWithProvider();
const raw = serializeConfigForm(form);
const parsed = JSON.parse(raw);
const model = parsed.models.providers.xai.models[0];
expect(typeof model.maxTokens).toBe("number");
expect(model.maxTokens).toBe(8192);
expect(typeof model.contextWindow).toBe("number");
expect(model.contextWindow).toBe(131072);
expect(typeof model.cost.input).toBe("number");
expect(model.cost.input).toBe(0.5);
});
it("cloneConfigObject + setPathValue preserves unrelated numeric fields", () => {
const form = makeConfigWithProvider();
const cloned = cloneConfigObject(form);
setPathValue(cloned, ["gateway", "auth", "token"], "new-token");
const model = cloned.models as Record<string, unknown>;
const providers = model.providers as Record<string, unknown>;
const xai = providers.xai as Record<string, unknown>;
const models = xai.models as Array<Record<string, unknown>>;
const first = models[0];
expect(typeof first.maxTokens).toBe("number");
expect(first.maxTokens).toBe(8192);
expect(typeof first.contextWindow).toBe("number");
expect(typeof first.cost).toBe("object");
expect(typeof (first.cost as Record<string, unknown>).input).toBe("number");
});
});
describe("coerceFormValues", () => {
it("coerces string numbers to numbers based on schema", () => {
const form = {
models: {
providers: {
xai: {
baseUrl: "https://api.x.ai/v1",
models: [
{
id: "grok-4",
name: "Grok 4",
contextWindow: "131072",
maxTokens: "8192",
cost: { input: "0.5", output: "1.0", cacheRead: "0.1", cacheWrite: "0.2" },
},
],
},
},
},
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
const first = model[0];
expect(typeof first.maxTokens).toBe("number");
expect(first.maxTokens).toBe(8192);
expect(typeof first.contextWindow).toBe("number");
expect(first.contextWindow).toBe(131072);
expect(typeof first.cost).toBe("object");
const cost = first.cost as Record<string, number>;
expect(typeof cost.input).toBe("number");
expect(cost.input).toBe(0.5);
expect(typeof cost.output).toBe("number");
expect(cost.output).toBe(1);
expect(typeof cost.cacheRead).toBe("number");
expect(cost.cacheRead).toBe(0.1);
expect(typeof cost.cacheWrite).toBe("number");
expect(cost.cacheWrite).toBe(0.2);
});
it("preserves already-correct numeric values", () => {
const form = makeConfigWithProvider();
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
const first = model[0];
expect(typeof first.maxTokens).toBe("number");
expect(first.maxTokens).toBe(8192);
});
it("does not coerce non-numeric strings to numbers", () => {
const form = {
models: {
providers: {
xai: {
baseUrl: "https://api.x.ai/v1",
models: [
{
id: "grok-4",
name: "Grok 4",
maxTokens: "not-a-number",
},
],
},
},
},
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
const first = model[0];
expect(first.maxTokens).toBe("not-a-number");
});
it("coerces string booleans to booleans based on schema", () => {
const form = {
models: {
providers: {
xai: {
baseUrl: "https://api.x.ai/v1",
models: [
{
id: "grok-4",
name: "Grok 4",
reasoning: "true",
},
],
},
},
},
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
expect(model[0].reasoning).toBe(true);
});
it("handles empty string for number fields as undefined", () => {
const form = {
models: {
providers: {
xai: {
baseUrl: "https://api.x.ai/v1",
models: [
{
id: "grok-4",
name: "Grok 4",
maxTokens: "",
},
],
},
},
},
};
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
const model = (
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
.xai as Record<string, unknown>
).models as Array<Record<string, unknown>>;
expect(model[0].maxTokens).toBeUndefined();
});
it("passes through null and undefined values untouched", () => {
expect(coerceFormValues(null, topLevelSchema)).toBeNull();
expect(coerceFormValues(undefined, topLevelSchema)).toBeUndefined();
});
it("handles anyOf schemas with number variant", () => {
const schema: JsonSchema = {
type: "object",
properties: {
timeout: {
anyOf: [{ type: "number" }, { type: "string" }],
},
},
};
const form = { timeout: "30" };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
expect(typeof coerced.timeout).toBe("number");
expect(coerced.timeout).toBe(30);
});
it("handles integer schema type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
count: { type: "integer" },
},
};
const form = { count: "42" };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
expect(typeof coerced.count).toBe("number");
expect(coerced.count).toBe(42);
});
it("rejects non-integer string for integer schema type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
count: { type: "integer" },
},
};
const form = { count: "1.5" };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
expect(coerced.count).toBe("1.5");
});
it("recurses into object inside anyOf (nullable pattern)", () => {
const schema: JsonSchema = {
type: "object",
properties: {
settings: {
anyOf: [
{
type: "object",
properties: {
port: { type: "number" },
enabled: { type: "boolean" },
},
},
{ type: "null" },
],
},
},
};
const form = { settings: { port: "8080", enabled: "true" } };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
const settings = coerced.settings as Record<string, unknown>;
expect(typeof settings.port).toBe("number");
expect(settings.port).toBe(8080);
expect(settings.enabled).toBe(true);
});
it("recurses into array inside anyOf", () => {
const schema: JsonSchema = {
type: "object",
properties: {
items: {
anyOf: [
{
type: "array",
items: { type: "object", properties: { count: { type: "number" } } },
},
{ type: "null" },
],
},
},
};
const form = { items: [{ count: "5" }] };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
const items = coerced.items as Array<Record<string, unknown>>;
expect(typeof items[0].count).toBe("number");
expect(items[0].count).toBe(5);
});
it("handles tuple array schemas by index", () => {
const schema: JsonSchema = {
type: "object",
properties: {
pair: {
type: "array",
items: [{ type: "string" }, { type: "number" }],
},
},
};
const form = { pair: ["hello", "42"] };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
const pair = coerced.pair as unknown[];
expect(pair[0]).toBe("hello");
expect(typeof pair[1]).toBe("number");
expect(pair[1]).toBe(42);
});
it("preserves tuple indexes when a value is cleared", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tuple: {
type: "array",
items: [{ type: "string" }, { type: "number" }, { type: "string" }],
},
},
};
const form = { tuple: ["left", "", "right"] };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
const tuple = coerced.tuple as unknown[];
expect(tuple).toHaveLength(3);
expect(tuple[0]).toBe("left");
expect(tuple[1]).toBeUndefined();
expect(tuple[2]).toBe("right");
});
it("omits cleared number field from object output", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
port: { type: "number" },
},
};
const form = { name: "test", port: "" };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
expect(coerced.name).toBe("test");
expect("port" in coerced).toBe(false);
});
it("filters undefined from array when number item is cleared", () => {
const schema: JsonSchema = {
type: "object",
properties: {
values: {
type: "array",
items: { type: "number" },
},
},
};
const form = { values: ["1", "", "3"] };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
const values = coerced.values as number[];
expect(values).toEqual([1, 3]);
});
it("coerces boolean in anyOf union", () => {
const schema: JsonSchema = {
type: "object",
properties: {
flag: {
anyOf: [{ type: "boolean" }, { type: "string" }],
},
},
};
const form = { flag: "true" };
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
expect(coerced.flag).toBe(true);
});
});