From 50721d84f4ea836294e01c77ab3bd58f8de3a1bc Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 6 Mar 2026 01:46:19 -0500 Subject: [PATCH] Config: harden schema lookup RPC --- src/config/schema.test.ts | 29 +++++++++++++++++++++++++ src/config/schema.ts | 17 ++++++++++----- src/gateway/protocol/schema/config.ts | 4 +++- src/gateway/server-methods/config.ts | 11 ++++++++-- src/gateway/server.config-patch.test.ts | 9 ++++++++ 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 1f06a2d49c5..a9d83135920 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -229,6 +229,35 @@ describe("config schema", () => { expect(lookup?.hintPath).toBe("agents.list.*.identity.avatar"); }); + it("matches ui hints that use empty array brackets", () => { + const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime"); + expect(lookup?.path).toBe("agents.list.0.runtime"); + expect(lookup?.hintPath).toBe("agents.list[].runtime"); + expect(lookup?.hint?.label).toBe("Agent Runtime"); + }); + + it("uses the indexed tuple item schema for positional array lookups", () => { + const tupleSchema: Parameters[0] = { + schema: { + type: "object", + properties: { + pair: { + type: "array", + items: [{ type: "string" }, { type: "number" }], + }, + }, + }, + uiHints: {}, + version: "test", + generatedAt: "test", + }; + + const lookup = lookupConfigSchema(tupleSchema, "pair.1"); + expect(lookup?.path).toBe("pair.1"); + expect(lookup?.schema).toMatchObject({ type: "number" }); + expect((lookup?.schema as { items?: unknown } | undefined)?.items).toBeUndefined(); + }); + it("returns null for missing config schema paths", () => { expect(lookupConfigSchema(baseSchema, "gateway.notReal.path")).toBeNull(); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index bcc887b2d44..652bc4a6c3d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -453,7 +453,7 @@ export function buildConfigSchema(params?: { function normalizeLookupPath(path: string): string { return path .trim() - .replace(/\[(\*|\d+)\]/g, ".$1") + .replace(/\[(\*|\d*)\]/g, (_match, segment: string) => `.${segment || "*"}`) .replace(/^\.+|\.+$/g, "") .replace(/\.+/g, "."); } @@ -515,9 +515,13 @@ function schemaHasChildren(schema: JsonSchemaObject): boolean { return Boolean(schema.items && typeof schema.items === "object"); } -function resolveItemsSchema(schema: JsonSchemaObject): JsonSchemaObject | null { +function resolveItemsSchema(schema: JsonSchemaObject, index?: number): JsonSchemaObject | null { if (Array.isArray(schema.items)) { - return schema.items.find((entry) => typeof entry === "object" && entry !== null) ?? null; + const entry = + index === undefined + ? schema.items.find((candidate) => typeof candidate === "object" && candidate !== null) + : schema.items[index]; + return entry && typeof entry === "object" ? entry : null; } return schema.items && typeof schema.items === "object" ? schema.items : null; } @@ -531,8 +535,9 @@ function resolveLookupChildSchema( return explicit; } - const items = resolveItemsSchema(schema); - if ((segment === "*" || /^\d+$/.test(segment)) && items) { + const itemIndex = /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : undefined; + const items = resolveItemsSchema(schema, itemIndex); + if ((segment === "*" || itemIndex !== undefined) && items) { return items; } @@ -549,7 +554,7 @@ function stripNestedSchema(schema: JsonSchemaObject): JsonSchemaNode { if (next.additionalProperties && typeof next.additionalProperties === "object") { delete next.additionalProperties; } - if (next.items && typeof next.items === "object") { + if (next.items !== undefined) { delete next.items; } return next; diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index 6dec1d16374..d3701fe00a0 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -1,6 +1,8 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString } from "./primitives.js"; +const NonWhitespaceString = Type.String({ minLength: 1, pattern: ".*\\S.*" }); + export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: false }); export const ConfigSetParamsSchema = Type.Object( @@ -29,7 +31,7 @@ export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: export const ConfigSchemaLookupParamsSchema = Type.Object( { - path: NonEmptyString, + path: NonWhitespaceString, }, { additionalProperties: false }, ); diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 1ab580df256..c282b9a94d8 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -40,6 +40,7 @@ import { import { ErrorCodes, errorShape, + formatValidationErrors, validateConfigApplyParams, validateConfigGetParams, validateConfigPatchParams, @@ -264,7 +265,7 @@ export const configHandlers: GatewayRequestHandlers = { } respond(true, loadSchemaWithPlugins(), undefined); }, - "config.schema.lookup": ({ params, respond }) => { + "config.schema.lookup": ({ params, respond, context }) => { if ( !assertValidParams(params, validateConfigSchemaLookupParams, "config.schema.lookup", respond) ) { @@ -282,10 +283,16 @@ export const configHandlers: GatewayRequestHandlers = { return; } if (!validateConfigSchemaLookupResult(result)) { + const errors = validateConfigSchemaLookupResult.errors ?? []; + context.logGateway.warn( + `config.schema.lookup produced invalid payload for ${path}: ${formatValidationErrors(errors)}`, + ); respond( false, undefined, - errorShape(ErrorCodes.UNAVAILABLE, "config.schema.lookup returned invalid payload"), + errorShape(ErrorCodes.UNAVAILABLE, "config.schema.lookup returned invalid payload", { + details: { errors }, + }), ); return; } diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index c9f36025b48..61830295542 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -78,6 +78,15 @@ describe("gateway config methods", () => { expect(res.error?.message ?? "").toContain("config schema path not found"); }); + it("rejects config.schema.lookup when the path is only whitespace", async () => { + const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", { + path: " ", + }); + + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("invalid config.schema.lookup params"); + }); + it("rejects config.patch when raw is not an object", async () => { const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { raw: "[]",