diff --git a/CHANGELOG.md b/CHANGELOG.md index 626e5acf10..a9e7e0fa9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 342a1e58e6..46948777a0 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -3,6 +3,7 @@ import { applyConfigSnapshot, applyConfig, runUpdate, + saveConfig, updateConfigFormValue, type ConfigState, } from "./config.ts"; @@ -157,6 +158,124 @@ 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"); + }); + + it("skips coercion when schema is not an object", 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" }, + }; + state.configSchema = "invalid-schema"; + state.configSnapshot = { hash: "hash-save-2" }; + + 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 }; + }; + expect(parsed.gateway.port).toBe("18789"); + expect(params.baseHash).toBe("hash-save-2"); + }); }); describe("runUpdate", () => { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 93e6746c14..9ca669aa59 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -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,32 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot } } +function asJsonSchema(value: unknown): JsonSchema | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as JsonSchema; +} + +/** + * Serialize the form state for submission to `config.set` / `config.apply`. + * + * HTML `` 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 = asJsonSchema(state.configSchema); + const form = schema + ? (coerceFormValues(state.configForm, schema) as Record) + : state.configForm; + return serializeConfigForm(form); +} + export async function saveConfig(state: ConfigState) { if (!state.client || !state.connected) { return; @@ -106,10 +134,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 +157,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."; diff --git a/ui/src/ui/controllers/config/form-coerce.ts b/ui/src/ui/controllers/config/form-coerce.ts new file mode 100644 index 0000000000..d5ceab427f --- /dev/null +++ b/ui/src/ui/controllers/config/form-coerce.ts @@ -0,0 +1,160 @@ +import { schemaType, type JsonSchema } from "../../views/config-form.shared.ts"; + +function coerceNumberString(value: string, integer: boolean): number | undefined | string { + const trimmed = value.trim(); + if (trimmed === "") { + return undefined; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + return value; + } + if (integer && !Number.isInteger(parsed)) { + return value; + } + return parsed; +} + +function coerceBooleanString(value: string): boolean | string { + const trimmed = value.trim(); + if (trimmed === "true") { + return true; + } + if (trimmed === "false") { + return false; + } + return value; +} + +/** + * Walk a form value tree alongside its JSON Schema and coerce string values + * to their schema-defined types (number, boolean). + * + * HTML `` 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; + } + + if (schema.allOf && schema.allOf.length > 0) { + let next: unknown = value; + for (const segment of schema.allOf) { + next = coerceFormValues(next, segment); + } + return next; + } + + 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 coerced = coerceNumberString(value, variantType === "integer"); + if (coerced === undefined || typeof coerced === "number") { + return coerced; + } + } + if (variantType === "boolean") { + const coerced = coerceBooleanString(value); + if (typeof coerced === "boolean") { + return coerced; + } + } + } + } + + // 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 coerced = coerceNumberString(value, type === "integer"); + if (coerced === undefined || typeof coerced === "number") { + return coerced; + } + } + return value; + } + + if (type === "boolean") { + if (typeof value === "string") { + const coerced = coerceBooleanString(value); + if (typeof coerced === "boolean") { + return coerced; + } + } + return value; + } + + if (type === "object") { + if (typeof value !== "object" || Array.isArray(value)) { + return value; + } + const obj = value as Record; + const props = schema.properties ?? {}; + const additional = + schema.additionalProperties && typeof schema.additionalProperties === "object" + ? schema.additionalProperties + : null; + const result: Record = {}; + 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; +} diff --git a/ui/src/ui/controllers/config/form-utils.node.test.ts b/ui/src/ui/controllers/config/form-utils.node.test.ts new file mode 100644 index 0000000000..b1d6954a23 --- /dev/null +++ b/ui/src/ui/controllers/config/form-utils.node.test.ts @@ -0,0 +1,471 @@ +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 { + 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; + const providers = model.providers as Record; + const xai = providers.xai as Record; + const models = xai.models as Array>; + 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).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; + const model = ( + ((coerced.models as Record).providers as Record) + .xai as Record + ).models as Array>; + 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; + 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; + const model = ( + ((coerced.models as Record).providers as Record) + .xai as Record + ).models as Array>; + 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; + const model = ( + ((coerced.models as Record).providers as Record) + .xai as Record + ).models as Array>; + 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; + const model = ( + ((coerced.models as Record).providers as Record) + .xai as Record + ).models as Array>; + 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; + const model = ( + ((coerced.models as Record).providers as Record) + .xai as Record + ).models as Array>; + 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; + 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; + 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; + expect(coerced.count).toBe("1.5"); + }); + + it("does not coerce non-finite numeric strings", () => { + const schema: JsonSchema = { + type: "object", + properties: { + timeout: { type: "number" }, + }, + }; + const form = { timeout: "Infinity" }; + const coerced = coerceFormValues(form, schema) as Record; + expect(coerced.timeout).toBe("Infinity"); + }); + + it("supports allOf schema composition", () => { + const schema: JsonSchema = { + allOf: [ + { + type: "object", + properties: { + port: { type: "number" }, + }, + }, + { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + ], + }; + const form = { port: "8080", enabled: "true" }; + const coerced = coerceFormValues(form, schema) as Record; + expect(coerced.port).toBe(8080); + expect(coerced.enabled).toBe(true); + }); + + 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; + const settings = coerced.settings as Record; + 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; + const items = coerced.items as Array>; + 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; + 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; + 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; + 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; + 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; + expect(coerced.flag).toBe(true); + }); +});