Skip to content

Commit ff97195

Browse files
authored
Gateway: add path-scoped config schema lookup (openclaw#37266)
Merged via squash. Prepared head SHA: 0c4d187 Co-authored-by: gumadeiras <[email protected]> Co-authored-by: gumadeiras <[email protected]> Reviewed-by: @gumadeiras
1 parent c5828cb commit ff97195

18 files changed

+633
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- 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.
4848
- 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.
4949
- 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.
50+
- 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.
5051
- 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.
5152
- 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.
5253
- 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.

docs/experiments/onboarding-config-protocol.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI.
2323
- `wizard.cancel` params: `{ sessionId }`
2424
- `wizard.status` params: `{ sessionId }`
2525
- `config.schema` params: `{}`
26+
- `config.schema.lookup` params: `{ path }`
2627

2728
Responses (shape)
2829

2930
- Wizard: `{ sessionId, done, step?, status?, error? }`
3031
- Config schema: `{ schema, uiHints, version, generatedAt }`
32+
- Config schema lookup: `{ path, schema, hint?, hintPath?, children[] }`
3133

3234
## UI Hints
3335

docs/tools/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,13 +453,15 @@ Restart or apply updates to the running Gateway process (in-place).
453453
Core actions:
454454

455455
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `openclaw gateway` restart in-place)
456+
- `config.schema.lookup` (inspect one config path at a time without loading the full schema into prompt context)
456457
- `config.get`
457458
- `config.apply` (validate + write config + restart + wake)
458459
- `config.patch` (merge partial update + restart + wake)
459460
- `update.run` (run update + restart + wake)
460461

461462
Notes:
462463

464+
- `config.schema.lookup` expects a targeted dot path such as `gateway.auth` or `agents.list.*.heartbeat`.
463465
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
464466
- `config.schema` remains available to internal Control UI flows and is not exposed through the agent `gateway` tool.
465467
- `restart` is enabled by default; set `commands.restart: false` to disable it.

src/agents/openclaw-gateway-tool.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,27 @@ vi.mock("./tools/gateway.js", () => ({
1111
if (method === "config.get") {
1212
return { hash: "hash-1" };
1313
}
14+
if (method === "config.schema.lookup") {
15+
return {
16+
path: "gateway.auth",
17+
schema: {
18+
type: "object",
19+
},
20+
hint: { label: "Gateway Auth" },
21+
hintPath: "gateway.auth",
22+
children: [
23+
{
24+
key: "token",
25+
path: "gateway.auth.token",
26+
type: "string",
27+
required: true,
28+
hasChildren: false,
29+
hint: { label: "Token", sensitive: true },
30+
hintPath: "gateway.auth.token",
31+
},
32+
],
33+
};
34+
}
1435
return { ok: true };
1536
}),
1637
readGatewayCallOptions: vi.fn(() => ({})),
@@ -166,4 +187,36 @@ describe("gateway tool", () => {
166187
expect(params).toMatchObject({ timeoutMs: 20 * 60_000 });
167188
}
168189
});
190+
191+
it("returns a path-scoped schema lookup result", async () => {
192+
const { callGatewayTool } = await import("./tools/gateway.js");
193+
const tool = requireGatewayTool();
194+
195+
const result = await tool.execute("call5", {
196+
action: "config.schema.lookup",
197+
path: "gateway.auth",
198+
});
199+
200+
expect(callGatewayTool).toHaveBeenCalledWith("config.schema.lookup", expect.any(Object), {
201+
path: "gateway.auth",
202+
});
203+
expect(result.details).toMatchObject({
204+
ok: true,
205+
result: {
206+
path: "gateway.auth",
207+
hintPath: "gateway.auth",
208+
children: [
209+
expect.objectContaining({
210+
key: "token",
211+
path: "gateway.auth.token",
212+
required: true,
213+
hintPath: "gateway.auth.token",
214+
}),
215+
],
216+
},
217+
});
218+
const schema = (result.details as { result?: { schema?: { properties?: unknown } } }).result
219+
?.schema;
220+
expect(schema?.properties).toBeUndefined();
221+
});
169222
});

src/agents/system-prompt.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,10 +443,12 @@ describe("buildAgentSystemPrompt", () => {
443443
});
444444

445445
expect(prompt).toContain("## OpenClaw Self-Update");
446+
expect(prompt).toContain("config.schema.lookup");
446447
expect(prompt).toContain("config.apply");
447448
expect(prompt).toContain("config.patch");
448449
expect(prompt).toContain("update.run");
449-
expect(prompt).not.toContain("config.schema");
450+
expect(prompt).not.toContain("Use config.schema to");
451+
expect(prompt).not.toContain("config.schema, config.apply");
450452
});
451453

452454
it("includes skills guidance when skills prompt is present", () => {

src/agents/system-prompt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,8 @@ export function buildAgentSystemPrompt(params: {
482482
? [
483483
"Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.",
484484
"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.",
485-
"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).",
485+
"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.",
486+
"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).",
486487
"After restart, OpenClaw pings the last active session automatically.",
487488
].join("\n")
488489
: "",

src/agents/tools/gateway-tool.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined {
3434
const GATEWAY_ACTIONS = [
3535
"restart",
3636
"config.get",
37+
"config.schema.lookup",
3738
"config.apply",
3839
"config.patch",
3940
"update.run",
@@ -47,10 +48,12 @@ const GatewayToolSchema = Type.Object({
4748
// restart
4849
delayMs: Type.Optional(Type.Number()),
4950
reason: Type.Optional(Type.String()),
50-
// config.get, config.apply, update.run
51+
// config.get, config.schema.lookup, config.apply, update.run
5152
gatewayUrl: Type.Optional(Type.String()),
5253
gatewayToken: Type.Optional(Type.String()),
5354
timeoutMs: Type.Optional(Type.Number()),
55+
// config.schema.lookup
56+
path: Type.Optional(Type.String()),
5457
// config.apply, config.patch
5558
raw: Type.Optional(Type.String()),
5659
baseHash: Type.Optional(Type.String()),
@@ -73,7 +76,7 @@ export function createGatewayTool(opts?: {
7376
name: "gateway",
7477
ownerOnly: true,
7578
description:
76-
"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.",
79+
"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.",
7780
parameters: GatewayToolSchema,
7881
execute: async (_toolCallId, args) => {
7982
const params = args as Record<string, unknown>;
@@ -171,6 +174,14 @@ export function createGatewayTool(opts?: {
171174
const result = await callGatewayTool("config.get", gatewayOpts, {});
172175
return jsonResult({ ok: true, result });
173176
}
177+
if (action === "config.schema.lookup") {
178+
const path = readStringParam(params, "path", {
179+
required: true,
180+
label: "path",
181+
});
182+
const result = await callGatewayTool("config.schema.lookup", gatewayOpts, { path });
183+
return jsonResult({ ok: true, result });
184+
}
174185
if (action === "config.apply") {
175186
const { raw, baseHash, sessionKey, note, restartDelayMs } =
176187
await resolveConfigWriteParams();

src/config/schema.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeAll, describe, expect, it } from "vitest";
2-
import { buildConfigSchema } from "./schema.js";
2+
import { buildConfigSchema, lookupConfigSchema } from "./schema.js";
33
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
44

55
describe("config schema", () => {
@@ -202,4 +202,101 @@ describe("config schema", () => {
202202
}
203203
}
204204
});
205+
206+
it("looks up a config schema path with immediate child summaries", () => {
207+
const lookup = lookupConfigSchema(baseSchema, "gateway.auth");
208+
expect(lookup?.path).toBe("gateway.auth");
209+
expect(lookup?.hintPath).toBe("gateway.auth");
210+
expect(lookup?.children.some((child) => child.key === "token")).toBe(true);
211+
const tokenChild = lookup?.children.find((child) => child.key === "token");
212+
expect(tokenChild?.path).toBe("gateway.auth.token");
213+
expect(tokenChild?.hint?.sensitive).toBe(true);
214+
expect(tokenChild?.hintPath).toBe("gateway.auth.token");
215+
const schema = lookup?.schema as { properties?: unknown } | undefined;
216+
expect(schema?.properties).toBeUndefined();
217+
});
218+
219+
it("returns a shallow lookup schema without nested composition keywords", () => {
220+
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime");
221+
expect(lookup?.path).toBe("agents.list.0.runtime");
222+
expect(lookup?.hintPath).toBe("agents.list[].runtime");
223+
expect(lookup?.schema).toEqual({});
224+
});
225+
226+
it("matches wildcard ui hints for concrete lookup paths", () => {
227+
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar");
228+
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
229+
expect(lookup?.hintPath).toBe("agents.list.*.identity.avatar");
230+
expect(lookup?.hint?.help).toContain("workspace-relative path");
231+
});
232+
233+
it("normalizes bracketed lookup paths", () => {
234+
const lookup = lookupConfigSchema(baseSchema, "agents.list[0].identity.avatar");
235+
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
236+
expect(lookup?.hintPath).toBe("agents.list.*.identity.avatar");
237+
});
238+
239+
it("matches ui hints that use empty array brackets", () => {
240+
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime");
241+
expect(lookup?.path).toBe("agents.list.0.runtime");
242+
expect(lookup?.hintPath).toBe("agents.list[].runtime");
243+
expect(lookup?.hint?.label).toBe("Agent Runtime");
244+
});
245+
246+
it("uses the indexed tuple item schema for positional array lookups", () => {
247+
const tupleSchema = {
248+
schema: {
249+
type: "object",
250+
properties: {
251+
pair: {
252+
type: "array",
253+
items: [{ type: "string" }, { type: "number" }],
254+
},
255+
},
256+
},
257+
uiHints: {},
258+
version: "test",
259+
generatedAt: "test",
260+
} as unknown as Parameters<typeof lookupConfigSchema>[0];
261+
262+
const lookup = lookupConfigSchema(tupleSchema, "pair.1");
263+
expect(lookup?.path).toBe("pair.1");
264+
expect(lookup?.schema).toMatchObject({ type: "number" });
265+
expect((lookup?.schema as { items?: unknown } | undefined)?.items).toBeUndefined();
266+
});
267+
268+
it("rejects prototype-chain lookup segments", () => {
269+
expect(() => lookupConfigSchema(baseSchema, "constructor")).not.toThrow();
270+
expect(lookupConfigSchema(baseSchema, "constructor")).toBeNull();
271+
expect(lookupConfigSchema(baseSchema, "__proto__.polluted")).toBeNull();
272+
});
273+
274+
it("rejects overly deep lookup paths", () => {
275+
const buildNestedObjectSchema = (segments: string[]) => {
276+
const [head, ...rest] = segments;
277+
if (!head) {
278+
return { type: "string" };
279+
}
280+
return {
281+
type: "object",
282+
properties: {
283+
[head]: buildNestedObjectSchema(rest),
284+
},
285+
};
286+
};
287+
288+
const deepPathSegments = Array.from({ length: 33 }, (_, index) => `a${index}`);
289+
const deepSchema = {
290+
schema: buildNestedObjectSchema(deepPathSegments),
291+
uiHints: {},
292+
version: "test",
293+
generatedAt: "test",
294+
} as unknown as Parameters<typeof lookupConfigSchema>[0];
295+
296+
expect(lookupConfigSchema(deepSchema, deepPathSegments.join("."))).toBeNull();
297+
});
298+
299+
it("returns null for missing config schema paths", () => {
300+
expect(lookupConfigSchema(baseSchema, "gateway.notReal.path")).toBeNull();
301+
});
205302
});

0 commit comments

Comments
 (0)