mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-09 16:21:15 +00:00
Config: harden schema lookup RPC
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: "[]",
|
||||
|
||||
Reference in New Issue
Block a user