mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Gateway: add path-scoped config schema lookup (#37266)
Merged via squash.
Prepared head SHA: 0c4d187f6f
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
c5828cbc08
commit
ff97195500
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
|
||||
- TUI/session-key alias event matching: treat chat events whose session keys are canonical aliases (for example `agent:<id>:main` vs `main`) as the same session while preserving cross-agent isolation, so assistant replies no longer disappear or surface in another terminal window due to strict key-form mismatch. (#33937) Thanks @yjh1412.
|
||||
- OpenAI Codex OAuth/login hardening: fail OAuth completion early when the returned token is missing `api.responses.write`, and allow `openclaw models auth login --provider openai-codex` to use the built-in OAuth path even when no provider plugins are installed. (#36660) Thanks @driesvints.
|
||||
- OpenAI Codex OAuth/scope request parity: augment the OAuth authorize URL with required API scopes (`api.responses.write`, `model.request`, `api.model.read`) before browser handoff so OAuth tokens include runtime model/request permissions expected by OpenAI API calls. (#24720) Thanks @Skippy-Gunboat.
|
||||
- Agents/config schema lookup: add `gateway` tool action `config.schema.lookup` so agents can inspect one config path at a time before edits without loading the full schema into prompt context.
|
||||
- Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header `ByteString` construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
|
||||
- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
|
||||
- Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
|
||||
|
||||
@@ -23,11 +23,13 @@ Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI.
|
||||
- `wizard.cancel` params: `{ sessionId }`
|
||||
- `wizard.status` params: `{ sessionId }`
|
||||
- `config.schema` params: `{}`
|
||||
- `config.schema.lookup` params: `{ path }`
|
||||
|
||||
Responses (shape)
|
||||
|
||||
- Wizard: `{ sessionId, done, step?, status?, error? }`
|
||||
- Config schema: `{ schema, uiHints, version, generatedAt }`
|
||||
- Config schema lookup: `{ path, schema, hint?, hintPath?, children[] }`
|
||||
|
||||
## UI Hints
|
||||
|
||||
|
||||
@@ -453,6 +453,7 @@ Restart or apply updates to the running Gateway process (in-place).
|
||||
Core actions:
|
||||
|
||||
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `openclaw gateway` restart in-place)
|
||||
- `config.schema.lookup` (inspect one config path at a time without loading the full schema into prompt context)
|
||||
- `config.get`
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `config.patch` (merge partial update + restart + wake)
|
||||
@@ -460,6 +461,7 @@ Core actions:
|
||||
|
||||
Notes:
|
||||
|
||||
- `config.schema.lookup` expects a targeted dot path such as `gateway.auth` or `agents.list.*.heartbeat`.
|
||||
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
||||
- `config.schema` remains available to internal Control UI flows and is not exposed through the agent `gateway` tool.
|
||||
- `restart` is enabled by default; set `commands.restart: false` to disable it.
|
||||
|
||||
@@ -11,6 +11,27 @@ vi.mock("./tools/gateway.js", () => ({
|
||||
if (method === "config.get") {
|
||||
return { hash: "hash-1" };
|
||||
}
|
||||
if (method === "config.schema.lookup") {
|
||||
return {
|
||||
path: "gateway.auth",
|
||||
schema: {
|
||||
type: "object",
|
||||
},
|
||||
hint: { label: "Gateway Auth" },
|
||||
hintPath: "gateway.auth",
|
||||
children: [
|
||||
{
|
||||
key: "token",
|
||||
path: "gateway.auth.token",
|
||||
type: "string",
|
||||
required: true,
|
||||
hasChildren: false,
|
||||
hint: { label: "Token", sensitive: true },
|
||||
hintPath: "gateway.auth.token",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
readGatewayCallOptions: vi.fn(() => ({})),
|
||||
@@ -166,4 +187,36 @@ describe("gateway tool", () => {
|
||||
expect(params).toMatchObject({ timeoutMs: 20 * 60_000 });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a path-scoped schema lookup result", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const tool = requireGatewayTool();
|
||||
|
||||
const result = await tool.execute("call5", {
|
||||
action: "config.schema.lookup",
|
||||
path: "gateway.auth",
|
||||
});
|
||||
|
||||
expect(callGatewayTool).toHaveBeenCalledWith("config.schema.lookup", expect.any(Object), {
|
||||
path: "gateway.auth",
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
path: "gateway.auth",
|
||||
hintPath: "gateway.auth",
|
||||
children: [
|
||||
expect.objectContaining({
|
||||
key: "token",
|
||||
path: "gateway.auth.token",
|
||||
required: true,
|
||||
hintPath: "gateway.auth.token",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const schema = (result.details as { result?: { schema?: { properties?: unknown } } }).result
|
||||
?.schema;
|
||||
expect(schema?.properties).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -443,10 +443,12 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## OpenClaw Self-Update");
|
||||
expect(prompt).toContain("config.schema.lookup");
|
||||
expect(prompt).toContain("config.apply");
|
||||
expect(prompt).toContain("config.patch");
|
||||
expect(prompt).toContain("update.run");
|
||||
expect(prompt).not.toContain("config.schema");
|
||||
expect(prompt).not.toContain("Use config.schema to");
|
||||
expect(prompt).not.toContain("config.schema, config.apply");
|
||||
});
|
||||
|
||||
it("includes skills guidance when skills prompt is present", () => {
|
||||
|
||||
@@ -482,7 +482,8 @@ export function buildAgentSystemPrompt(params: {
|
||||
? [
|
||||
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
|
||||
"Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.",
|
||||
"Actions: config.get, config.apply (validate + write full config, then restart), config.patch (partial update, merges with existing), update.run (update deps or git, then restart).",
|
||||
"Use config.schema.lookup with a specific dot path to inspect only the relevant config subtree before making config changes or answering config-field questions; avoid guessing field names/types.",
|
||||
"Actions: config.schema.lookup, config.get, config.apply (validate + write full config, then restart), config.patch (partial update, merges with existing), update.run (update deps or git, then restart).",
|
||||
"After restart, OpenClaw pings the last active session automatically.",
|
||||
].join("\n")
|
||||
: "",
|
||||
|
||||
@@ -34,6 +34,7 @@ function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined {
|
||||
const GATEWAY_ACTIONS = [
|
||||
"restart",
|
||||
"config.get",
|
||||
"config.schema.lookup",
|
||||
"config.apply",
|
||||
"config.patch",
|
||||
"update.run",
|
||||
@@ -47,10 +48,12 @@ const GatewayToolSchema = Type.Object({
|
||||
// restart
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
reason: Type.Optional(Type.String()),
|
||||
// config.get, config.apply, update.run
|
||||
// config.get, config.schema.lookup, config.apply, update.run
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
// config.schema.lookup
|
||||
path: Type.Optional(Type.String()),
|
||||
// config.apply, config.patch
|
||||
raw: Type.Optional(Type.String()),
|
||||
baseHash: Type.Optional(Type.String()),
|
||||
@@ -73,7 +76,7 @@ export function createGatewayTool(opts?: {
|
||||
name: "gateway",
|
||||
ownerOnly: true,
|
||||
description:
|
||||
"Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.",
|
||||
"Restart, inspect a specific config schema path, apply config, or update the gateway in-place (SIGUSR1). Use config.schema.lookup with a targeted dot path before config edits. Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.",
|
||||
parameters: GatewayToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
@@ -171,6 +174,14 @@ export function createGatewayTool(opts?: {
|
||||
const result = await callGatewayTool("config.get", gatewayOpts, {});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "config.schema.lookup") {
|
||||
const path = readStringParam(params, "path", {
|
||||
required: true,
|
||||
label: "path",
|
||||
});
|
||||
const result = await callGatewayTool("config.schema.lookup", gatewayOpts, { path });
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
if (action === "config.apply") {
|
||||
const { raw, baseHash, sessionKey, note, restartDelayMs } =
|
||||
await resolveConfigWriteParams();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildConfigSchema } from "./schema.js";
|
||||
import { buildConfigSchema, lookupConfigSchema } from "./schema.js";
|
||||
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
|
||||
|
||||
describe("config schema", () => {
|
||||
@@ -202,4 +202,101 @@ describe("config schema", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("looks up a config schema path with immediate child summaries", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "gateway.auth");
|
||||
expect(lookup?.path).toBe("gateway.auth");
|
||||
expect(lookup?.hintPath).toBe("gateway.auth");
|
||||
expect(lookup?.children.some((child) => child.key === "token")).toBe(true);
|
||||
const tokenChild = lookup?.children.find((child) => child.key === "token");
|
||||
expect(tokenChild?.path).toBe("gateway.auth.token");
|
||||
expect(tokenChild?.hint?.sensitive).toBe(true);
|
||||
expect(tokenChild?.hintPath).toBe("gateway.auth.token");
|
||||
const schema = lookup?.schema as { properties?: unknown } | undefined;
|
||||
expect(schema?.properties).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a shallow lookup schema without nested composition keywords", () => {
|
||||
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?.schema).toEqual({});
|
||||
});
|
||||
|
||||
it("matches wildcard ui hints for concrete lookup paths", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar");
|
||||
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
|
||||
expect(lookup?.hintPath).toBe("agents.list.*.identity.avatar");
|
||||
expect(lookup?.hint?.help).toContain("workspace-relative path");
|
||||
});
|
||||
|
||||
it("normalizes bracketed lookup paths", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "agents.list[0].identity.avatar");
|
||||
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
|
||||
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 = {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pair: {
|
||||
type: "array",
|
||||
items: [{ type: "string" }, { type: "number" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
uiHints: {},
|
||||
version: "test",
|
||||
generatedAt: "test",
|
||||
} as unknown as Parameters<typeof lookupConfigSchema>[0];
|
||||
|
||||
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("rejects prototype-chain lookup segments", () => {
|
||||
expect(() => lookupConfigSchema(baseSchema, "constructor")).not.toThrow();
|
||||
expect(lookupConfigSchema(baseSchema, "constructor")).toBeNull();
|
||||
expect(lookupConfigSchema(baseSchema, "__proto__.polluted")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects overly deep lookup paths", () => {
|
||||
const buildNestedObjectSchema = (segments: string[]) => {
|
||||
const [head, ...rest] = segments;
|
||||
if (!head) {
|
||||
return { type: "string" };
|
||||
}
|
||||
return {
|
||||
type: "object",
|
||||
properties: {
|
||||
[head]: buildNestedObjectSchema(rest),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const deepPathSegments = Array.from({ length: 33 }, (_, index) => `a${index}`);
|
||||
const deepSchema = {
|
||||
schema: buildNestedObjectSchema(deepPathSegments),
|
||||
uiHints: {},
|
||||
version: "test",
|
||||
generatedAt: "test",
|
||||
} as unknown as Parameters<typeof lookupConfigSchema>[0];
|
||||
|
||||
expect(lookupConfigSchema(deepSchema, deepPathSegments.join("."))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing config schema paths", () => {
|
||||
expect(lookupConfigSchema(baseSchema, "gateway.notReal.path")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,42 @@ type JsonSchemaObject = JsonSchemaNode & {
|
||||
properties?: Record<string, JsonSchemaObject>;
|
||||
required?: string[];
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
};
|
||||
|
||||
const FORBIDDEN_LOOKUP_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const LOOKUP_SCHEMA_STRING_KEYS = new Set([
|
||||
"$id",
|
||||
"$schema",
|
||||
"title",
|
||||
"description",
|
||||
"format",
|
||||
"pattern",
|
||||
"contentEncoding",
|
||||
"contentMediaType",
|
||||
]);
|
||||
const LOOKUP_SCHEMA_NUMBER_KEYS = new Set([
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([
|
||||
"additionalProperties",
|
||||
"uniqueItems",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
]);
|
||||
const MAX_LOOKUP_PATH_SEGMENTS = 32;
|
||||
|
||||
function cloneSchema<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
@@ -71,6 +105,24 @@ export type ConfigSchemaResponse = {
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type ConfigSchemaLookupChild = {
|
||||
key: string;
|
||||
path: string;
|
||||
type?: string | string[];
|
||||
required: boolean;
|
||||
hasChildren: boolean;
|
||||
hint?: ConfigUiHint;
|
||||
hintPath?: string;
|
||||
};
|
||||
|
||||
export type ConfigSchemaLookupResult = {
|
||||
path: string;
|
||||
schema: JsonSchemaNode;
|
||||
hint?: ConfigUiHint;
|
||||
hintPath?: string;
|
||||
children: ConfigSchemaLookupChild[];
|
||||
};
|
||||
|
||||
export type PluginUiMetadata = {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -430,3 +482,230 @@ export function buildConfigSchema(params?: {
|
||||
setMergedSchemaCache(cacheKey, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function normalizeLookupPath(path: string): string {
|
||||
return path
|
||||
.trim()
|
||||
.replace(/\[(\*|\d*)\]/g, (_match, segment: string) => `.${segment || "*"}`)
|
||||
.replace(/^\.+|\.+$/g, "")
|
||||
.replace(/\.+/g, ".");
|
||||
}
|
||||
|
||||
function splitLookupPath(path: string): string[] {
|
||||
const normalized = normalizeLookupPath(path);
|
||||
return normalized ? normalized.split(".").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function resolveUiHintMatch(
|
||||
uiHints: ConfigUiHints,
|
||||
path: string,
|
||||
): { path: string; hint: ConfigUiHint } | null {
|
||||
const targetParts = splitLookupPath(path);
|
||||
let best: { path: string; hint: ConfigUiHint; wildcardCount: number } | null = null;
|
||||
|
||||
for (const [hintPath, hint] of Object.entries(uiHints)) {
|
||||
const hintParts = splitLookupPath(hintPath);
|
||||
if (hintParts.length !== targetParts.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wildcardCount = 0;
|
||||
let matches = true;
|
||||
for (let index = 0; index < hintParts.length; index += 1) {
|
||||
const hintPart = hintParts[index];
|
||||
const targetPart = targetParts[index];
|
||||
if (hintPart === targetPart) {
|
||||
continue;
|
||||
}
|
||||
if (hintPart === "*") {
|
||||
wildcardCount += 1;
|
||||
continue;
|
||||
}
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
if (!best || wildcardCount < best.wildcardCount) {
|
||||
best = { path: hintPath, hint, wildcardCount };
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { path: best.path, hint: best.hint } : null;
|
||||
}
|
||||
|
||||
function schemaHasChildren(schema: JsonSchemaObject): boolean {
|
||||
if (schema.properties && Object.keys(schema.properties).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(schema.items)) {
|
||||
return schema.items.some((entry) => typeof entry === "object" && entry !== null);
|
||||
}
|
||||
return Boolean(schema.items && typeof schema.items === "object");
|
||||
}
|
||||
|
||||
function resolveItemsSchema(schema: JsonSchemaObject, index?: number): JsonSchemaObject | null {
|
||||
if (Array.isArray(schema.items)) {
|
||||
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;
|
||||
}
|
||||
|
||||
function resolveLookupChildSchema(
|
||||
schema: JsonSchemaObject,
|
||||
segment: string,
|
||||
): JsonSchemaObject | null {
|
||||
if (FORBIDDEN_LOOKUP_SEGMENTS.has(segment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties = schema.properties;
|
||||
if (properties && Object.hasOwn(properties, segment)) {
|
||||
return asSchemaObject(properties[segment]);
|
||||
}
|
||||
|
||||
const itemIndex = /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : undefined;
|
||||
const items = resolveItemsSchema(schema, itemIndex);
|
||||
if ((segment === "*" || itemIndex !== undefined) && items) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
return schema.additionalProperties;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripSchemaForLookup(schema: JsonSchemaObject): JsonSchemaNode {
|
||||
const next: JsonSchemaNode = {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (LOOKUP_SCHEMA_STRING_KEYS.has(key) && typeof value === "string") {
|
||||
next[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (LOOKUP_SCHEMA_NUMBER_KEYS.has(key) && typeof value === "number") {
|
||||
next[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (LOOKUP_SCHEMA_BOOLEAN_KEYS.has(key) && typeof value === "boolean") {
|
||||
next[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "type") {
|
||||
if (typeof value === "string") {
|
||||
next[key] = value;
|
||||
} else if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
|
||||
next[key] = [...value];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === "enum" && Array.isArray(value)) {
|
||||
const entries = value.filter(
|
||||
(entry) =>
|
||||
entry === null ||
|
||||
typeof entry === "string" ||
|
||||
typeof entry === "number" ||
|
||||
typeof entry === "boolean",
|
||||
);
|
||||
if (entries.length === value.length) {
|
||||
next[key] = [...entries];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
key === "const" &&
|
||||
(value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean")
|
||||
) {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildLookupChildren(
|
||||
schema: JsonSchemaObject,
|
||||
path: string,
|
||||
uiHints: ConfigUiHints,
|
||||
): ConfigSchemaLookupChild[] {
|
||||
const children: ConfigSchemaLookupChild[] = [];
|
||||
const required = new Set(schema.required ?? []);
|
||||
|
||||
const pushChild = (key: string, childSchema: JsonSchemaObject, isRequired: boolean) => {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
const resolvedHint = resolveUiHintMatch(uiHints, childPath);
|
||||
children.push({
|
||||
key,
|
||||
path: childPath,
|
||||
type: childSchema.type,
|
||||
required: isRequired,
|
||||
hasChildren: schemaHasChildren(childSchema),
|
||||
hint: resolvedHint?.hint,
|
||||
hintPath: resolvedHint?.path,
|
||||
});
|
||||
};
|
||||
|
||||
for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
|
||||
pushChild(key, childSchema, required.has(key));
|
||||
}
|
||||
|
||||
const wildcardSchema =
|
||||
(schema.additionalProperties &&
|
||||
typeof schema.additionalProperties === "object" &&
|
||||
!Array.isArray(schema.additionalProperties)
|
||||
? schema.additionalProperties
|
||||
: null) ?? resolveItemsSchema(schema);
|
||||
if (wildcardSchema) {
|
||||
pushChild("*", wildcardSchema, false);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function lookupConfigSchema(
|
||||
response: ConfigSchemaResponse,
|
||||
path: string,
|
||||
): ConfigSchemaLookupResult | null {
|
||||
const normalizedPath = normalizeLookupPath(path);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
const parts = splitLookupPath(normalizedPath);
|
||||
if (parts.length === 0 || parts.length > MAX_LOOKUP_PATH_SEGMENTS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let current = asSchemaObject(response.schema);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
for (const segment of parts) {
|
||||
const next = resolveLookupChildSchema(current, segment);
|
||||
if (!next) {
|
||||
return null;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
|
||||
const resolvedHint = resolveUiHintMatch(response.uiHints, normalizedPath);
|
||||
return {
|
||||
path: normalizedPath,
|
||||
schema: stripSchemaForLookup(current),
|
||||
hint: resolvedHint?.hint,
|
||||
hintPath: resolvedHint?.path,
|
||||
children: buildLookupChildren(current, normalizedPath, response.uiHints),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ import { listGatewayMethods } from "./server-methods-list.js";
|
||||
import { coreGatewayHandlers } from "./server-methods.js";
|
||||
|
||||
describe("method scope resolution", () => {
|
||||
it("classifies sessions.resolve as read and poll as write", () => {
|
||||
it("classifies sessions.resolve + config.schema.lookup as read and poll as write", () => {
|
||||
expect(resolveLeastPrivilegeOperatorScopesForMethod("sessions.resolve")).toEqual([
|
||||
"operator.read",
|
||||
]);
|
||||
expect(resolveLeastPrivilegeOperatorScopesForMethod("config.schema.lookup")).toEqual([
|
||||
"operator.read",
|
||||
]);
|
||||
expect(resolveLeastPrivilegeOperatorScopesForMethod("poll")).toEqual(["operator.write"]);
|
||||
});
|
||||
|
||||
@@ -28,6 +31,9 @@ describe("operator scope authorization", () => {
|
||||
expect(authorizeOperatorScopesForMethod("health", ["operator.write"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
expect(authorizeOperatorScopesForMethod("config.schema.lookup", ["operator.read"])).toEqual({
|
||||
allowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires operator.write for write methods", () => {
|
||||
|
||||
@@ -77,6 +77,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"node.describe",
|
||||
"chat.history",
|
||||
"config.get",
|
||||
"config.schema.lookup",
|
||||
"talk.config",
|
||||
"agents.files.list",
|
||||
"agents.files.get",
|
||||
|
||||
@@ -66,6 +66,10 @@ import {
|
||||
ConfigGetParamsSchema,
|
||||
type ConfigPatchParams,
|
||||
ConfigPatchParamsSchema,
|
||||
type ConfigSchemaLookupParams,
|
||||
ConfigSchemaLookupParamsSchema,
|
||||
type ConfigSchemaLookupResult,
|
||||
ConfigSchemaLookupResultSchema,
|
||||
type ConfigSchemaParams,
|
||||
ConfigSchemaParamsSchema,
|
||||
type ConfigSchemaResponse,
|
||||
@@ -318,6 +322,12 @@ export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetPar
|
||||
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
|
||||
export const validateConfigPatchParams = ajv.compile<ConfigPatchParams>(ConfigPatchParamsSchema);
|
||||
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(ConfigSchemaParamsSchema);
|
||||
export const validateConfigSchemaLookupParams = ajv.compile<ConfigSchemaLookupParams>(
|
||||
ConfigSchemaLookupParamsSchema,
|
||||
);
|
||||
export const validateConfigSchemaLookupResult = ajv.compile<ConfigSchemaLookupResult>(
|
||||
ConfigSchemaLookupResultSchema,
|
||||
);
|
||||
export const validateWizardStartParams = ajv.compile<WizardStartParams>(WizardStartParamsSchema);
|
||||
export const validateWizardNextParams = ajv.compile<WizardNextParams>(WizardNextParamsSchema);
|
||||
export const validateWizardCancelParams = ajv.compile<WizardCancelParams>(WizardCancelParamsSchema);
|
||||
@@ -467,7 +477,9 @@ export {
|
||||
ConfigApplyParamsSchema,
|
||||
ConfigPatchParamsSchema,
|
||||
ConfigSchemaParamsSchema,
|
||||
ConfigSchemaLookupParamsSchema,
|
||||
ConfigSchemaResponseSchema,
|
||||
ConfigSchemaLookupResultSchema,
|
||||
WizardStartParamsSchema,
|
||||
WizardNextParamsSchema,
|
||||
WizardCancelParamsSchema,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
const ConfigSchemaLookupPathString = Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 1024,
|
||||
pattern: "^[A-Za-z0-9_.\\[\\]\\-*]+$",
|
||||
});
|
||||
|
||||
export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
export const ConfigSetParamsSchema = Type.Object(
|
||||
@@ -27,6 +33,13 @@ export const ConfigPatchParamsSchema = ConfigApplyLikeParamsSchema;
|
||||
|
||||
export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
export const ConfigSchemaLookupParamsSchema = Type.Object(
|
||||
{
|
||||
path: ConfigSchemaLookupPathString,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const UpdateRunParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
@@ -61,3 +74,27 @@ export const ConfigSchemaResponseSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ConfigSchemaLookupChildSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
path: NonEmptyString,
|
||||
type: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])),
|
||||
required: Type.Boolean(),
|
||||
hasChildren: Type.Boolean(),
|
||||
hint: Type.Optional(ConfigUiHintSchema),
|
||||
hintPath: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ConfigSchemaLookupResultSchema = Type.Object(
|
||||
{
|
||||
path: NonEmptyString,
|
||||
schema: Type.Unknown(),
|
||||
hint: Type.Optional(ConfigUiHintSchema),
|
||||
hintPath: Type.Optional(Type.String()),
|
||||
children: Type.Array(ConfigSchemaLookupChildSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
ConfigApplyParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigPatchParamsSchema,
|
||||
ConfigSchemaLookupParamsSchema,
|
||||
ConfigSchemaLookupResultSchema,
|
||||
ConfigSchemaParamsSchema,
|
||||
ConfigSchemaResponseSchema,
|
||||
ConfigSetParamsSchema,
|
||||
@@ -202,7 +204,9 @@ export const ProtocolSchemas = {
|
||||
ConfigApplyParams: ConfigApplyParamsSchema,
|
||||
ConfigPatchParams: ConfigPatchParamsSchema,
|
||||
ConfigSchemaParams: ConfigSchemaParamsSchema,
|
||||
ConfigSchemaLookupParams: ConfigSchemaLookupParamsSchema,
|
||||
ConfigSchemaResponse: ConfigSchemaResponseSchema,
|
||||
ConfigSchemaLookupResult: ConfigSchemaLookupResultSchema,
|
||||
WizardStartParams: WizardStartParamsSchema,
|
||||
WizardNextParams: WizardNextParamsSchema,
|
||||
WizardCancelParams: WizardCancelParamsSchema,
|
||||
|
||||
@@ -46,7 +46,9 @@ export type ConfigSetParams = SchemaType<"ConfigSetParams">;
|
||||
export type ConfigApplyParams = SchemaType<"ConfigApplyParams">;
|
||||
export type ConfigPatchParams = SchemaType<"ConfigPatchParams">;
|
||||
export type ConfigSchemaParams = SchemaType<"ConfigSchemaParams">;
|
||||
export type ConfigSchemaLookupParams = SchemaType<"ConfigSchemaLookupParams">;
|
||||
export type ConfigSchemaResponse = SchemaType<"ConfigSchemaResponse">;
|
||||
export type ConfigSchemaLookupResult = SchemaType<"ConfigSchemaLookupResult">;
|
||||
export type WizardStartParams = SchemaType<"WizardStartParams">;
|
||||
export type WizardNextParams = SchemaType<"WizardNextParams">;
|
||||
export type WizardCancelParams = SchemaType<"WizardCancelParams">;
|
||||
|
||||
@@ -21,6 +21,7 @@ const BASE_METHODS = [
|
||||
"config.apply",
|
||||
"config.patch",
|
||||
"config.schema",
|
||||
"config.schema.lookup",
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"exec.approvals.node.get",
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
redactConfigSnapshot,
|
||||
restoreRedactedValues,
|
||||
} from "../../config/redact-snapshot.js";
|
||||
import { buildConfigSchema, type ConfigSchemaResponse } from "../../config/schema.js";
|
||||
import {
|
||||
buildConfigSchema,
|
||||
lookupConfigSchema,
|
||||
type ConfigSchemaResponse,
|
||||
} from "../../config/schema.js";
|
||||
import { extractDeliveryInfo } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
@@ -36,9 +40,12 @@ import {
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateConfigApplyParams,
|
||||
validateConfigGetParams,
|
||||
validateConfigPatchParams,
|
||||
validateConfigSchemaLookupParams,
|
||||
validateConfigSchemaLookupResult,
|
||||
validateConfigSchemaParams,
|
||||
validateConfigSetParams,
|
||||
} from "../protocol/index.js";
|
||||
@@ -113,6 +120,14 @@ function parseRawConfigOrRespond(
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
function sanitizeLookupPathForLog(path: string): string {
|
||||
const sanitized = Array.from(path, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
return code < 0x20 || code === 0x7f ? "?" : char;
|
||||
}).join("");
|
||||
return sanitized.length > 120 ? `${sanitized.slice(0, 117)}...` : sanitized;
|
||||
}
|
||||
|
||||
function parseValidateConfigFromRawOrRespond(
|
||||
params: unknown,
|
||||
requestName: string,
|
||||
@@ -258,6 +273,39 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
respond(true, loadSchemaWithPlugins(), undefined);
|
||||
},
|
||||
"config.schema.lookup": ({ params, respond, context }) => {
|
||||
if (
|
||||
!assertValidParams(params, validateConfigSchemaLookupParams, "config.schema.lookup", respond)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const path = (params as { path: string }).path;
|
||||
const schema = loadSchemaWithPlugins();
|
||||
const result = lookupConfigSchema(schema, path);
|
||||
if (!result) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "config schema path not found"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!validateConfigSchemaLookupResult(result)) {
|
||||
const errors = validateConfigSchemaLookupResult.errors ?? [];
|
||||
context.logGateway.warn(
|
||||
`config.schema.lookup produced invalid payload for ${sanitizeLookupPathForLog(path)}: ${formatValidationErrors(errors)}`,
|
||||
);
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "config.schema.lookup returned invalid payload", {
|
||||
details: { errors },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"config.set": async ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validateConfigSetParams, "config.set", respond)) {
|
||||
return;
|
||||
|
||||
@@ -47,6 +47,73 @@ async function resetTempDir(name: string): Promise<string> {
|
||||
}
|
||||
|
||||
describe("gateway config methods", () => {
|
||||
it("returns a path-scoped config schema lookup", async () => {
|
||||
const res = await rpcReq<{
|
||||
path: string;
|
||||
hintPath?: string;
|
||||
children?: Array<{ key: string; path: string; required: boolean; hintPath?: string }>;
|
||||
schema?: { properties?: unknown };
|
||||
}>(requireWs(), "config.schema.lookup", {
|
||||
path: "gateway.auth",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.path).toBe("gateway.auth");
|
||||
expect(res.payload?.hintPath).toBe("gateway.auth");
|
||||
const tokenChild = res.payload?.children?.find((child) => child.key === "token");
|
||||
expect(tokenChild).toMatchObject({
|
||||
key: "token",
|
||||
path: "gateway.auth.token",
|
||||
hintPath: "gateway.auth.token",
|
||||
});
|
||||
expect(res.payload?.schema?.properties).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects config.schema.lookup when the path is missing", async () => {
|
||||
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", {
|
||||
path: "gateway.notReal.path",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message).toBe("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.schema.lookup when the path exceeds the protocol limit", async () => {
|
||||
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", {
|
||||
path: `gateway.${"a".repeat(1020)}`,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("invalid config.schema.lookup params");
|
||||
});
|
||||
|
||||
it("rejects config.schema.lookup when the path contains invalid characters", async () => {
|
||||
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", {
|
||||
path: "gateway.auth\nspoof",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("invalid config.schema.lookup params");
|
||||
});
|
||||
|
||||
it("rejects prototype-chain config.schema.lookup paths without reflecting them", async () => {
|
||||
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", {
|
||||
path: "constructor",
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message).toBe("config schema path not found");
|
||||
});
|
||||
|
||||
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