From ff971955009a6314111891b9e353cf0a734f0d4e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 6 Mar 2026 02:50:48 -0500 Subject: [PATCH] Gateway: add path-scoped config schema lookup (#37266) Merged via squash. Prepared head SHA: 0c4d187f6fb66f2799d4047585d6368e433c883a Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + .../experiments/onboarding-config-protocol.md | 2 + docs/tools/index.md | 2 + src/agents/openclaw-gateway-tool.test.ts | 53 ++++ src/agents/system-prompt.test.ts | 4 +- src/agents/system-prompt.ts | 3 +- src/agents/tools/gateway-tool.ts | 15 +- src/config/schema.test.ts | 99 ++++++- src/config/schema.ts | 279 ++++++++++++++++++ src/gateway/method-scopes.test.ts | 8 +- src/gateway/method-scopes.ts | 1 + src/gateway/protocol/index.ts | 12 + src/gateway/protocol/schema/config.ts | 37 +++ .../protocol/schema/protocol-schemas.ts | 4 + src/gateway/protocol/schema/types.ts | 2 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods/config.ts | 50 +++- src/gateway/server.config-patch.test.ts | 67 +++++ 18 files changed, 633 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8812c32e2..d783df82e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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::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. diff --git a/docs/experiments/onboarding-config-protocol.md b/docs/experiments/onboarding-config-protocol.md index 648d24b57eb..424f7726e20 100644 --- a/docs/experiments/onboarding-config-protocol.md +++ b/docs/experiments/onboarding-config-protocol.md @@ -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 diff --git a/docs/tools/index.md b/docs/tools/index.md index 2f9417d75d9..c12cf5f68c5 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -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. diff --git a/src/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index ee09348a53f..9b96ddd6a61 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -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(); + }); }); diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 0d67634a0bf..57dfb26689c 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 991f2918f17..a60ae54306b 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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") : "", diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 03419b17cf8..33b8d86adcf 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -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; @@ -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(); diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 3314543d5b9..2d420efd868 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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[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[0]; + + expect(lookupConfigSchema(deepSchema, deepPathSegments.join("."))).toBeNull(); + }); + + 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 406d61dce77..83227a375d5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -17,8 +17,42 @@ type JsonSchemaObject = JsonSchemaNode & { properties?: Record; 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(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), + }; +} diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 1b85a911e5c..1479611d484 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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", () => { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index b6f9084301b..866d8071a83 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -77,6 +77,7 @@ const METHOD_SCOPE_GROUPS: Record = { "node.describe", "chat.history", "config.get", + "config.schema.lookup", "talk.config", "agents.files.list", "agents.files.get", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 74da1422ccc..507c20025ac 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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(ConfigSetPar export const validateConfigApplyParams = ajv.compile(ConfigApplyParamsSchema); export const validateConfigPatchParams = ajv.compile(ConfigPatchParamsSchema); export const validateConfigSchemaParams = ajv.compile(ConfigSchemaParamsSchema); +export const validateConfigSchemaLookupParams = ajv.compile( + ConfigSchemaLookupParamsSchema, +); +export const validateConfigSchemaLookupResult = ajv.compile( + ConfigSchemaLookupResultSchema, +); export const validateWizardStartParams = ajv.compile(WizardStartParamsSchema); export const validateWizardNextParams = ajv.compile(WizardNextParamsSchema); export const validateWizardCancelParams = ajv.compile(WizardCancelParamsSchema); @@ -467,7 +477,9 @@ export { ConfigApplyParamsSchema, ConfigPatchParamsSchema, ConfigSchemaParamsSchema, + ConfigSchemaLookupParamsSchema, ConfigSchemaResponseSchema, + ConfigSchemaLookupResultSchema, WizardStartParamsSchema, WizardNextParamsSchema, WizardCancelParamsSchema, diff --git a/src/gateway/protocol/schema/config.ts b/src/gateway/protocol/schema/config.ts index 150cd6b4ad1..78159549255 100644 --- a/src/gateway/protocol/schema/config.ts +++ b/src/gateway/protocol/schema/config.ts @@ -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 }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index b60dd181d36..0c55f5f2927 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 491b95795e1..f828bdbc418 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 6449f101c17..c026492568c 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -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", diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index a0c9cad1955..5faf83ec4d6 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -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; diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index e26e878ca70..44daced1684 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -47,6 +47,73 @@ async function resetTempDir(name: string): Promise { } 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: "[]",