Config: harden schema lookup RPC

This commit is contained in:
Gustavo Madeira Santana
2026-03-06 01:46:19 -05:00
parent 3141b3c2f7
commit 50721d84f4
5 changed files with 61 additions and 9 deletions

View File

@@ -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<typeof lookupConfigSchema>[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();
});

View File

@@ -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;

View File

@@ -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 },
);

View File

@@ -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;
}

View File

@@ -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: "[]",