fix: address Copilot review round 20 — malformed applicators, const-null under anyOf

- rootUnion reads every composition keyword through `membersOf` and every
  `required` through `requiredOf`. These schemas describe the wire and each
  member arrives as `unknown`, so `anyOf: {}` or `required: "x"` really
  can arrive — and reading them as lists threw, taking all three clients down
  rather than declining one malformed tool. Both web and TUI required-field
  checks are guarded the same way.
- admitsNull evaluates a sibling `anyOf` beside a `const: null` instead of
  refusing outright: the union decides in both directions, and
  `{ const: null, anyOf: [{ type: "null" }] }` admits null as plainly as an
  all-string union rejects it. Refusing it left a read-only required field
  seeded with the only value it accepts and submit permanently disabled.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 07:26:49 -04:00
commit d334710d6f

View file

@ -322,7 +322,9 @@ function applyConstants<T>(
function buildFields(schema: JsonSchemaObject): FormField[] {
const fields: FormField[] = [];
const properties = schema.properties || {};
const required = schema.required || [];
// `Array.isArray`, not `|| []`: a nonconforming server can send
// `required: "x"`, and `.includes` on a string silently matches substrings.
const required = Array.isArray(schema.required) ? schema.required : [];
for (const [key, prop] of Object.entries(properties)) {
// `properties` values are `unknown` (the SDK schema admits anything), so
@ -463,7 +465,8 @@ export function missingRequiredFields(
? base
: branches[selectedBranchIndex(base, branches, rawValues)]!.schema;
const properties = effective.properties ?? {};
return (effective.required ?? []).filter((name) => {
const required = Array.isArray(effective.required) ? effective.required : [];
return required.filter((name) => {
// `hasOwn` first: an argument legally named `constructor` would otherwise
// resolve to the inherited one and read as supplied, and the call would go
// out without it.