diff --git a/CHANGELOG.md b/CHANGELOG.md index d701df9d9d4..bbbe1c3a627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Plugins/runtime: expose `runHeartbeatOnce` in the plugin runtime `system` namespace so plugins can trigger a single heartbeat cycle with an explicit delivery target override (e.g. `heartbeat: { target: "last" }`). (#40299) Thanks @loveyana. - Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm. - Agents/compaction: preserve the post-compaction AGENTS refresh on stale-usage preflight compaction for both immediate replies and queued followups. (#49479) Thanks @jared596. +- CLI: add `openclaw config schema` to print the generated JSON schema for `openclaw.json`. (#54523) Thanks @kvokka. ### Fixes diff --git a/docs/cli/config.md b/docs/cli/config.md index 1eb376f0fa0..abe18fb492e 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw config` (get/set/unset/file/validate)" +summary: "CLI reference for `openclaw config` (get/set/unset/file/schema/validate)" read_when: - You want to read or edit config non-interactively title: "config" @@ -7,7 +7,7 @@ title: "config" # `openclaw config` -Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/validate +Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/file/schema/validate values by path and print the active config file. Run without a subcommand to open the configure wizard (same as `openclaw configure`). @@ -15,6 +15,7 @@ open the configure wizard (same as `openclaw configure`). ```bash openclaw config file +openclaw config schema openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set agents.defaults.heartbeat.every "2h" @@ -27,7 +28,21 @@ openclaw config validate openclaw config validate --json ``` -## Paths +### `config schema` + +Print the generated JSON schema for `openclaw.json` to stdout as plain text. + +```bash +openclaw config schema +``` + +Pipe it into a file when you want to inspect or validate it with other tools: + +```bash +openclaw config schema > openclaw.schema.json +``` + +### Paths Paths use dot or bracket notation: diff --git a/docs/cli/index.md b/docs/cli/index.md index 41cab9c8beb..0eee66d1f35 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -396,7 +396,7 @@ Interactive configuration wizard (models, channels, skills, gateway). ### `config` -Non-interactive config helpers (get/set/unset/file/validate). Running `openclaw config` with no +Non-interactive config helpers (get/set/unset/file/schema/validate). Running `openclaw config` with no subcommand launches the wizard. Subcommands: @@ -413,6 +413,7 @@ Subcommands: - `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode. - `config unset `: remove a value. - `config file`: print the active config file path. +- `config schema`: print the generated JSON schema for `openclaw.json`. - `config validate`: validate the current config against the schema without starting the gateway. - `config validate --json`: emit machine-readable JSON output. diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index d758cd54c24..667310eb411 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -17,6 +17,7 @@ const mockWriteConfigFile = vi.fn< (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise >(async () => {}); const mockResolveSecretRefValue = vi.fn(); +const mockReadBestEffortRuntimeConfigSchema = vi.fn(); vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: () => mockReadConfigFileSnapshot(), @@ -28,6 +29,10 @@ vi.mock("../secrets/resolve.js", () => ({ resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args), })); +vi.mock("../config/runtime-schema.js", () => ({ + readBestEffortRuntimeConfigSchema: () => mockReadBestEffortRuntimeConfigSchema(), +})); + const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); const mockLog = defaultRuntime.log; const mockError = defaultRuntime.error; @@ -127,6 +132,36 @@ describe("config cli", () => { beforeEach(() => { vi.clearAllMocks(); resetRuntimeCapture(); + mockReadBestEffortRuntimeConfigSchema.mockResolvedValue({ + schema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + channels: { + type: "object", + properties: { + telegram: { + type: "object", + properties: { + token: { type: "string" }, + }, + }, + }, + }, + plugins: { + type: "object", + properties: { + entries: { + type: "object", + }, + }, + }, + }, + }, + uiHints: {}, + version: "test", + generatedAt: "2026-03-25T00:00:00.000Z", + }); mockExit.mockImplementation((code: number) => { const errorMessages = mockError.mock.calls.map((call) => call.join(" ")).join("; "); throw new Error(`__exit__:${code} - ${errorMessages}`); @@ -412,6 +447,74 @@ describe("config cli", () => { }); }); + describe("config schema", () => { + it("prints the generated JSON schema as plain text", async () => { + await runConfigCommand(["config", "schema"]); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockError).not.toHaveBeenCalled(); + expect(defaultRuntime.writeJson).toHaveBeenCalledTimes(1); + const raw = mockLog.mock.calls.at(-1)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + properties?: Record; + }; + expect(payload.properties?.$schema).toEqual({ type: "string" }); + expect(payload.properties?.channels).toEqual({ + type: "object", + properties: { + telegram: { + type: "object", + properties: { + token: { type: "string" }, + }, + }, + }, + }); + expect(payload.properties?.plugins).toEqual({ + type: "object", + properties: { + entries: { + type: "object", + }, + }, + }); + }); + + it("falls back cleanly when best-effort schema loading returns channel-only data", async () => { + mockReadBestEffortRuntimeConfigSchema.mockResolvedValueOnce({ + schema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + channels: { + type: "object", + properties: { + telegram: { + type: "object", + }, + }, + }, + }, + }, + uiHints: {}, + version: "test", + generatedAt: "2026-03-25T00:00:00.000Z", + }); + + await runConfigCommand(["config", "schema"]); + + expect(defaultRuntime.writeJson).toHaveBeenCalledTimes(1); + const payload = JSON.parse(String(mockLog.mock.calls.at(-1)?.[0])) as { + properties?: Record; + }; + expect(payload.properties?.$schema).toEqual({ type: "string" }); + expect(payload.properties?.channels).toBeTruthy(); + expect(payload.properties?.plugins).toBeUndefined(); + expect(mockError).not.toHaveBeenCalled(); + }); + }); + describe("config set parsing flags", () => { it("falls back to raw string when parsing fails and strict mode is off", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 } }; diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index c47a765bf61..658e988ea5e 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -7,6 +7,7 @@ import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-f import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; +import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js"; import { coerceSecretRef, isValidEnvSecretRefId, @@ -1195,6 +1196,30 @@ export async function runConfigFile(opts: { runtime?: RuntimeEnv }) { } } +async function buildCliConfigSchema(): Promise> { + const schema = structuredClone((await readBestEffortRuntimeConfigSchema()).schema) as { + properties?: Record; + required?: string[]; + }; + + schema.properties = { + $schema: { type: "string" }, + ...schema.properties, + }; + + return schema; +} + +export async function runConfigSchema(opts: { runtime?: RuntimeEnv } = {}) { + const runtime = opts.runtime ?? defaultRuntime; + try { + writeRuntimeJson(runtime, await buildCliConfigSchema()); + } catch (err) { + runtime.error(danger(`Config schema error: ${String(err)}`)); + runtime.exit(1); + } +} + export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) { const runtime = opts.runtime ?? defaultRuntime; let outputPath = CONFIG_PATH ?? "openclaw.json"; @@ -1250,7 +1275,7 @@ export function registerConfigCli(program: Command) { const cmd = program .command("config") .description( - "Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for guided setup.", + "Non-interactive config helpers (get/set/unset/file/schema/validate). Run without subcommand for guided setup.", ) .addHelpText( "after", @@ -1370,6 +1395,13 @@ export function registerConfigCli(program: Command) { await runConfigFile({}); }); + cmd + .command("schema") + .description("Print the JSON schema for openclaw.json") + .action(async () => { + await runConfigSchema({}); + }); + cmd .command("validate") .description("Validate the current config against the schema without starting the gateway") diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index e96ea748f46..f813f25067c 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -178,6 +178,7 @@ describe("registerPreActionHooks", () => { .command("validate") .option("--json") .action(() => {}); + config.command("schema").action(() => {}); registerPreActionHooks(program, "9.9.9-test"); return program; } @@ -422,6 +423,15 @@ describe("registerPreActionHooks", () => { expect(ensureConfigReadyMock).not.toHaveBeenCalled(); }); + it("bypasses config guard for config schema", async () => { + await runPreAction({ + parseArgv: ["config", "schema"], + processArgv: ["node", "openclaw", "config", "schema"], + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + }); + it("bypasses config guard for backup create", async () => { await runPreAction({ parseArgv: ["backup", "create"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 732f4da8c0d..e083921236a 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -49,9 +49,7 @@ function shouldBypassConfigGuard(commandPath: string[]): boolean { if (CONFIG_GUARD_BYPASS_COMMANDS.has(primary)) { return true; } - // config validate is the explicit validation command; let it render - // validation failures directly without preflight guard output duplication. - if (primary === "config" && secondary === "validate") { + if (primary === "config" && (secondary === "validate" || secondary === "schema")) { return true; } return false; diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 43dec5acfef..f089e74015c 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -29,6 +29,14 @@ describe("$schema key in config (#14998)", () => { const result = OpenClawSchema.safeParse({ $schema: 123 }); expect(result.success).toBe(false); }); + + it("accepts $schema during full config validation", () => { + const result = validateConfigObject({ + $schema: "./schema.json", + gateway: { port: 18789 }, + }); + expect(result.ok).toBe(true); + }); }); describe("plugins.slots.contextEngine", () => { diff --git a/src/config/runtime-schema.test.ts b/src/config/runtime-schema.test.ts new file mode 100644 index 00000000000..f949d56cd5b --- /dev/null +++ b/src/config/runtime-schema.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; + +const mockLoadConfig = vi.hoisted(() => vi.fn<() => OpenClawConfig>()); +const mockReadConfigFileSnapshot = vi.hoisted(() => vi.fn<() => Promise>()); +const mockLoadOpenClawPlugins = vi.hoisted(() => vi.fn()); +const mockListChannelPlugins = vi.hoisted(() => vi.fn()); + +vi.mock("./config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => mockLoadConfig(), + readConfigFileSnapshot: () => mockReadConfigFileSnapshot(), + }; +}); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: (...args: unknown[]) => mockLoadOpenClawPlugins(...args), +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: (...args: unknown[]) => mockListChannelPlugins(...args), +})); + +function makeSnapshot(params: { valid: boolean; config?: OpenClawConfig }): ConfigFileSnapshot { + return { + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: params.config ?? {}, + resolved: params.config ?? {}, + valid: params.valid, + config: params.config ?? {}, + issues: params.valid ? [] : [{ path: "gateway", message: "invalid" }], + warnings: [], + legacyIssues: [], + }; +} + +describe("readBestEffortRuntimeConfigSchema", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadConfig.mockReturnValue({}); + mockListChannelPlugins.mockReturnValue([]); + }); + + it("uses scoped plugin registry channels for valid configs", async () => { + mockReadConfigFileSnapshot.mockResolvedValueOnce( + makeSnapshot({ + valid: true, + config: { plugins: { entries: { demo: { enabled: true } } } }, + }), + ); + mockLoadOpenClawPlugins.mockReturnValueOnce({ + plugins: [ + { + id: "demo", + name: "Demo", + description: "Demo plugin", + configUiHints: {}, + configJsonSchema: { + type: "object", + properties: { + mode: { type: "string" }, + }, + }, + }, + ], + channels: [ + { + pluginId: "telegram", + pluginName: "Telegram", + source: "bundled", + plugin: { + id: "telegram", + meta: { label: "Telegram", blurb: "Telegram channel" }, + configSchema: { + schema: { + type: "object", + properties: { + botToken: { type: "string" }, + }, + }, + uiHints: {}, + }, + }, + }, + ], + channelSetups: [], + }); + + const { readBestEffortRuntimeConfigSchema } = await import("./runtime-schema.js"); + const result = await readBestEffortRuntimeConfigSchema(); + const schema = result.schema as { properties?: Record }; + const channelsNode = schema.properties?.channels as Record | undefined; + const channelProps = channelsNode?.properties as Record | undefined; + const pluginsNode = schema.properties?.plugins as Record | undefined; + const pluginProps = pluginsNode?.properties as Record | undefined; + const entriesNode = pluginProps?.entries as Record | undefined; + const entryProps = entriesNode?.properties as Record | undefined; + + expect(mockLoadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: { plugins: { entries: { demo: { enabled: true } } } }, + activate: false, + cache: false, + }), + ); + expect(channelProps?.telegram).toBeTruthy(); + expect(entryProps?.demo).toBeTruthy(); + }); + + it("falls back to channel-only schema when config is invalid", async () => { + mockReadConfigFileSnapshot.mockResolvedValueOnce(makeSnapshot({ valid: false })); + mockLoadOpenClawPlugins.mockReturnValueOnce({ + plugins: [], + channels: [ + { + pluginId: "slack", + pluginName: "Slack", + source: "bundled", + plugin: { + id: "slack", + meta: { label: "Slack", blurb: "Slack channel" }, + configSchema: { + schema: { + type: "object", + properties: { + botToken: { type: "string" }, + }, + }, + uiHints: {}, + }, + }, + }, + ], + channelSetups: [ + { + pluginId: "telegram", + pluginName: "Telegram", + source: "bundled", + plugin: { + id: "telegram", + meta: { label: "Telegram", blurb: "Telegram channel" }, + configSchema: { + schema: { + type: "object", + properties: { + botToken: { type: "string" }, + }, + }, + uiHints: {}, + }, + }, + }, + ], + }); + + const { readBestEffortRuntimeConfigSchema } = await import("./runtime-schema.js"); + const result = await readBestEffortRuntimeConfigSchema(); + const schema = result.schema as { properties?: Record }; + const channelsNode = schema.properties?.channels as Record | undefined; + const channelProps = channelsNode?.properties as Record | undefined; + const pluginsNode = schema.properties?.plugins as Record | undefined; + const pluginProps = pluginsNode?.properties as Record | undefined; + const entriesNode = pluginProps?.entries as Record | undefined; + const entryProps = entriesNode?.properties as Record | undefined; + + expect(mockLoadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: { plugins: { enabled: true } }, + activate: false, + cache: false, + includeSetupOnlyChannelPlugins: true, + }), + ); + expect(channelProps?.telegram).toBeTruthy(); + expect(channelProps?.slack).toBeTruthy(); + expect(entryProps?.demo).toBeUndefined(); + }); + + it("does not fall back to active registry channels when invalid fallback load throws", async () => { + mockReadConfigFileSnapshot.mockResolvedValueOnce(makeSnapshot({ valid: false })); + mockLoadOpenClawPlugins.mockImplementationOnce(() => { + throw new Error("plugin load failed"); + }); + mockListChannelPlugins.mockReturnValueOnce([ + { + id: "telegram", + meta: { label: "Telegram", blurb: "Telegram channel" }, + configSchema: { + schema: { + type: "object", + properties: { + botToken: { type: "string" }, + }, + }, + uiHints: {}, + }, + }, + ]); + + const { readBestEffortRuntimeConfigSchema } = await import("./runtime-schema.js"); + const result = await readBestEffortRuntimeConfigSchema(); + const schema = result.schema as { properties?: Record }; + const channelsNode = schema.properties?.channels as Record | undefined; + const channelProps = channelsNode?.properties as Record | undefined; + + expect(channelProps?.telegram).toBeUndefined(); + }); +}); + +describe("loadGatewayRuntimeConfigSchema", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadConfig.mockReturnValue({ plugins: { entries: { demo: { enabled: true } } } }); + }); + + it("preserves gateway channel source and loader options", async () => { + mockLoadOpenClawPlugins.mockReturnValueOnce({ + plugins: [ + { + id: "demo", + name: "Demo", + description: "Demo plugin", + configUiHints: {}, + configJsonSchema: { + type: "object", + properties: { + mode: { type: "string" }, + }, + }, + }, + ], + channels: [ + { + pluginId: "scoped-only", + pluginName: "Scoped Only", + source: "bundled", + plugin: { + id: "scoped-only", + meta: { label: "Scoped Only" }, + }, + }, + ], + channelSetups: [], + }); + mockListChannelPlugins.mockReturnValueOnce([ + { + id: "telegram", + meta: { label: "Telegram", blurb: "Telegram channel" }, + configSchema: { + schema: { + type: "object", + properties: { + botToken: { type: "string" }, + }, + }, + uiHints: {}, + }, + }, + ]); + + const { loadGatewayRuntimeConfigSchema } = await import("./runtime-schema.js"); + const result = loadGatewayRuntimeConfigSchema(); + const schema = result.schema as { properties?: Record }; + const channelsNode = schema.properties?.channels as Record | undefined; + const channelProps = channelsNode?.properties as Record | undefined; + + expect(mockLoadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: { plugins: { entries: { demo: { enabled: true } } } }, + cache: true, + }), + ); + expect(mockLoadOpenClawPlugins.mock.calls[0]?.[0]?.activate).toBeUndefined(); + expect(channelProps?.telegram).toBeTruthy(); + expect(channelProps?.["scoped-only"]).toBeUndefined(); + }); +}); diff --git a/src/config/runtime-schema.ts b/src/config/runtime-schema.ts new file mode 100644 index 00000000000..619955f6729 --- /dev/null +++ b/src/config/runtime-schema.ts @@ -0,0 +1,130 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listChannelPlugins, type ChannelPlugin } from "../channels/plugins/index.js"; +import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { loadConfig, readConfigFileSnapshot } from "./config.js"; +import type { OpenClawConfig } from "./config.js"; +import { buildConfigSchema, type ChannelUiMetadata, type ConfigSchemaResponse } from "./schema.js"; + +const silentSchemaLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, +}; + +function loadPluginSchemaRegistry( + config: OpenClawConfig, + opts?: { + activate?: boolean; + cache?: boolean; + includeSetupOnlyChannelPlugins?: boolean; + }, +) { + const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); + return loadOpenClawPlugins({ + config, + cache: opts?.cache, + activate: opts?.activate, + includeSetupOnlyChannelPlugins: opts?.includeSetupOnlyChannelPlugins, + workspaceDir, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + logger: silentSchemaLogger, + }); +} + +function mapPluginSchemaMetadataFromRegistry( + pluginRegistry: ReturnType, +) { + return pluginRegistry.plugins.map((plugin) => ({ + id: plugin.id, + name: plugin.name, + description: plugin.description, + configUiHints: plugin.configUiHints, + configSchema: plugin.configJsonSchema, + })); +} + +function mapChannelSchemaMetadataFromEntries( + entries: Array>, +): ChannelUiMetadata[] { + return entries.map((entry) => ({ + id: entry.id, + label: entry.meta.label, + description: entry.meta.blurb, + configSchema: entry.configSchema?.schema, + configUiHints: entry.configSchema?.uiHints, + })); +} + +function mapActiveChannelSchemaMetadata(): ChannelUiMetadata[] { + return mapChannelSchemaMetadataFromEntries(listChannelPlugins()); +} + +function mapChannelSchemaMetadataFromRegistry( + pluginRegistry: ReturnType, +) { + const entries = [ + ...pluginRegistry.channelSetups.map((entry) => entry.plugin), + ...pluginRegistry.channels.map((entry) => entry.plugin), + ]; + if (entries.length > 0) { + const deduped = new Map>(); + for (const entry of entries) { + deduped.set(entry.id, entry); + } + return mapChannelSchemaMetadataFromEntries([...deduped.values()]); + } + return mapActiveChannelSchemaMetadata(); +} + +export function loadGatewayRuntimeConfigSchema(): ConfigSchemaResponse { + const cfg = loadConfig(); + const pluginRegistry = loadPluginSchemaRegistry(cfg, { cache: true }); + return buildConfigSchema({ + plugins: mapPluginSchemaMetadataFromRegistry(pluginRegistry), + channels: mapActiveChannelSchemaMetadata(), + }); +} + +function readFallbackChannelSchemaMetadata(): ChannelUiMetadata[] { + try { + const pluginRegistry = loadPluginSchemaRegistry( + { + plugins: { + enabled: true, + }, + }, + { + activate: false, + cache: false, + includeSetupOnlyChannelPlugins: true, + }, + ); + return mapChannelSchemaMetadataFromRegistry(pluginRegistry); + } catch { + return []; + } +} + +export async function readBestEffortRuntimeConfigSchema(): Promise { + const snapshot = await readConfigFileSnapshot(); + + if (!snapshot.valid) { + return buildConfigSchema({ channels: readFallbackChannelSchemaMetadata() }); + } + + try { + const pluginRegistry = loadPluginSchemaRegistry(snapshot.config, { + activate: false, + cache: false, + }); + return buildConfigSchema({ + plugins: mapPluginSchemaMetadataFromRegistry(pluginRegistry), + channels: mapChannelSchemaMetadataFromRegistry(pluginRegistry), + }); + } catch { + return buildConfigSchema({ channels: readFallbackChannelSchemaMetadata() }); + } +} diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 977a59f00b5..7b5674021a3 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,9 +1,6 @@ import { exec } from "node:child_process"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { listChannelPlugins } from "../../channels/plugins/index.js"; import { createConfigIO, - loadConfig, parseConfigJson5, readConfigFileSnapshot, readConfigFileSnapshotForWrite, @@ -19,11 +16,8 @@ import { redactConfigSnapshot, restoreRedactedValues, } from "../../config/redact-snapshot.js"; -import { - buildConfigSchema, - lookupConfigSchema, - type ConfigSchemaResponse, -} from "../../config/schema.js"; +import { loadGatewayRuntimeConfigSchema } from "../../config/runtime-schema.js"; +import { lookupConfigSchema, type ConfigSchemaResponse } from "../../config/schema.js"; import { extractDeliveryInfo } from "../../config/sessions.js"; import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -32,7 +26,6 @@ import { writeRestartSentinel, } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; -import { loadOpenClawPlugins } from "../../plugins/loader.js"; import { diffConfigPaths } from "../config-reload.js"; import { formatControlPlaneActor, @@ -243,41 +236,11 @@ async function tryWriteRestartSentinelPayload( } function loadSchemaWithPlugins(): ConfigSchemaResponse { - const cfg = loadConfig(); - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); - const pluginRegistry = loadOpenClawPlugins({ - config: cfg, - cache: true, - workspaceDir, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - logger: { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - }, - }); // Note: We can't easily cache this, as there are no callback that can invalidate - // our cache. However, both loadConfig() and loadOpenClawPlugins() already cache - // their results, and buildConfigSchema() is just a cheap transformation. - return buildConfigSchema({ - plugins: pluginRegistry.plugins.map((plugin) => ({ - id: plugin.id, - name: plugin.name, - description: plugin.description, - configUiHints: plugin.configUiHints, - configSchema: plugin.configJsonSchema, - })), - channels: listChannelPlugins().map((entry) => ({ - id: entry.id, - label: entry.meta.label, - description: entry.meta.blurb, - configSchema: entry.configSchema?.schema, - configUiHints: entry.configSchema?.uiHints, - })), - }); + // our cache. However, loadConfig() and loadOpenClawPlugins() (called inside + // loadGatewayRuntimeConfigSchema) already cache their results, and buildConfigSchema() + // is just a cheap transformation. + return loadGatewayRuntimeConfigSchema(); } export const configHandlers: GatewayRequestHandlers = { diff --git a/src/logging/config.test.ts b/src/logging/config.test.ts new file mode 100644 index 00000000000..85201a3caf1 --- /dev/null +++ b/src/logging/config.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const loadConfigMock = vi.hoisted(() => vi.fn()); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfigMock(), +})); + +const originalArgv = process.argv; + +describe("readLoggingConfig", () => { + afterEach(() => { + process.argv = originalArgv; + loadConfigMock.mockReset(); + }); + + it("skips mutating config loads for config schema", async () => { + process.argv = ["node", "openclaw", "config", "schema"]; + loadConfigMock.mockImplementation(() => { + throw new Error("loadConfig should not be called"); + }); + + const { readLoggingConfig } = await import("./config.js"); + + expect(readLoggingConfig()).toBeUndefined(); + expect(loadConfigMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/logging/config.ts b/src/logging/config.ts index 04dee9c0912..fc02e9fda3e 100644 --- a/src/logging/config.ts +++ b/src/logging/config.ts @@ -1,8 +1,17 @@ +import { getCommandPathWithRootOptions } from "../cli/argv.js"; import { loadConfig, type OpenClawConfig } from "../config/config.js"; type LoggingConfig = OpenClawConfig["logging"]; +export function shouldSkipMutatingLoggingConfigRead(argv: string[] = process.argv): boolean { + const [primary, secondary] = getCommandPathWithRootOptions(argv, 2); + return primary === "config" && (secondary === "schema" || secondary === "validate"); +} + export function readLoggingConfig(): LoggingConfig | undefined { + if (shouldSkipMutatingLoggingConfigRead()) { + return undefined; + } try { const parsed = loadConfig(); const logging = parsed?.logging; diff --git a/src/logging/console-settings.test.ts b/src/logging/console-settings.test.ts index e80962dc7e9..d2f90899b5f 100644 --- a/src/logging/console-settings.test.ts +++ b/src/logging/console-settings.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { captureConsoleSnapshot, type ConsoleSnapshot } from "./test-helpers/console-snapshot.js"; +const shouldSkipMutatingLoggingConfigReadMock = vi.hoisted(() => vi.fn(() => false)); + vi.mock("./config.js", () => ({ readLoggingConfig: () => undefined, + shouldSkipMutatingLoggingConfigRead: () => shouldSkipMutatingLoggingConfigReadMock(), })); vi.mock("./logger.js", () => ({ @@ -30,6 +33,8 @@ beforeAll(async () => { beforeEach(() => { loadConfigCalls = 0; + shouldSkipMutatingLoggingConfigReadMock.mockReset(); + shouldSkipMutatingLoggingConfigReadMock.mockReturnValue(false); snapshot = captureConsoleSnapshot(); originalIsTty = process.stdout.isTTY; originalOpenClawTestConsole = process.env.OPENCLAW_TEST_CONSOLE; diff --git a/src/logging/console.ts b/src/logging/console.ts index 73291f9edc9..3b765876759 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -2,7 +2,7 @@ import util from "node:util"; import type { OpenClawConfig } from "../config/types.js"; import { isVerbose } from "../global-state.js"; import { stripAnsi } from "../terminal/ansi.js"; -import { readLoggingConfig } from "./config.js"; +import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js"; import { resolveEnvLogLevelOverride } from "./env-log-level.js"; import { type LogLevel, normalizeLogLevel } from "./levels.js"; import { getLogger, type LoggerSettings } from "./logger.js"; @@ -73,7 +73,7 @@ function resolveConsoleSettings(): ConsoleSettings { let cfg: OpenClawConfig["logging"] | undefined = (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig(); - if (!cfg) { + if (!cfg && !shouldSkipMutatingLoggingConfigRead()) { if (loggingState.resolvingConsoleSettings) { cfg = undefined; } else { diff --git a/src/logging/logger-settings.test.ts b/src/logging/logger-settings.test.ts index 89aaedd2259..b594b24c3ac 100644 --- a/src/logging/logger-settings.test.ts +++ b/src/logging/logger-settings.test.ts @@ -1,14 +1,17 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { fallbackRequireMock, readLoggingConfigMock } = vi.hoisted(() => ({ - readLoggingConfigMock: vi.fn(() => undefined), - fallbackRequireMock: vi.fn(() => { - throw new Error("config fallback should not be used in this test"); - }), -})); +const { fallbackRequireMock, readLoggingConfigMock, shouldSkipMutatingLoggingConfigReadMock } = + vi.hoisted(() => ({ + readLoggingConfigMock: vi.fn(() => undefined), + shouldSkipMutatingLoggingConfigReadMock: vi.fn(() => false), + fallbackRequireMock: vi.fn(() => { + throw new Error("config fallback should not be used in this test"); + }), + })); vi.mock("./config.js", () => ({ readLoggingConfig: readLoggingConfigMock, + shouldSkipMutatingLoggingConfigRead: shouldSkipMutatingLoggingConfigReadMock, })); vi.mock("./node-require.js", () => ({ @@ -29,6 +32,8 @@ beforeEach(() => { delete process.env.OPENCLAW_TEST_FILE_LOG; delete process.env.OPENCLAW_LOG_LEVEL; readLoggingConfigMock.mockClear(); + shouldSkipMutatingLoggingConfigReadMock.mockReset(); + shouldSkipMutatingLoggingConfigReadMock.mockReturnValue(false); fallbackRequireMock.mockClear(); logging.resetLogger(); logging.setLoggerOverride(null); @@ -63,4 +68,14 @@ describe("getResolvedLoggerSettings", () => { const settings = logging.getResolvedLoggerSettings(); expect(settings.level).toBe("info"); }); + + it("skips fallback config loads for config schema", () => { + process.env.OPENCLAW_TEST_FILE_LOG = "1"; + shouldSkipMutatingLoggingConfigReadMock.mockReturnValue(true); + + const settings = logging.getResolvedLoggerSettings(); + + expect(settings.level).toBe("info"); + expect(fallbackRequireMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/logging/logger.settings.test.ts b/src/logging/logger.settings.test.ts index 39cc3f3d73c..77616a5a9ee 100644 --- a/src/logging/logger.settings.test.ts +++ b/src/logging/logger.settings.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it } from "vitest"; import { __test__ } from "./logger.js"; -describe("shouldSkipLoadConfigFallback", () => { - it("matches config validate invocations", () => { - expect(__test__.shouldSkipLoadConfigFallback(["node", "openclaw", "config", "validate"])).toBe( - true, - ); +describe("shouldSkipMutatingLoggingConfigRead", () => { + it("matches config schema and validate invocations", () => { + expect( + __test__.shouldSkipMutatingLoggingConfigRead(["node", "openclaw", "config", "schema"]), + ).toBe(true); + expect( + __test__.shouldSkipMutatingLoggingConfigRead(["node", "openclaw", "config", "validate"]), + ).toBe(true); }); it("handles root flags before config validate", () => { expect( - __test__.shouldSkipLoadConfigFallback([ + __test__.shouldSkipMutatingLoggingConfigRead([ "node", "openclaw", "--profile", @@ -25,8 +28,10 @@ describe("shouldSkipLoadConfigFallback", () => { it("does not match other commands", () => { expect( - __test__.shouldSkipLoadConfigFallback(["node", "openclaw", "config", "get", "foo"]), + __test__.shouldSkipMutatingLoggingConfigRead(["node", "openclaw", "config", "get", "foo"]), ).toBe(false); - expect(__test__.shouldSkipLoadConfigFallback(["node", "openclaw", "status"])).toBe(false); + expect(__test__.shouldSkipMutatingLoggingConfigRead(["node", "openclaw", "status"])).toBe( + false, + ); }); }); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 5f2a2623298..0f67641f53b 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,13 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { Logger as TsLogger } from "tslog"; -import { getCommandPathWithRootOptions } from "../cli/argv.js"; import type { OpenClawConfig } from "../config/types.js"; import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir, } from "../infra/tmp-openclaw-dir.js"; -import { readLoggingConfig } from "./config.js"; +import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js"; import type { ConsoleStyle } from "./console.js"; import { resolveEnvLogLevelOverride } from "./env-log-level.js"; import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js"; @@ -72,11 +71,6 @@ export type LogTransport = (logObj: LogTransportRecord) => void; const externalTransports = new Set(); -function shouldSkipLoadConfigFallback(argv: string[] = process.argv): boolean { - const [primary, secondary] = getCommandPathWithRootOptions(argv, 2); - return primary === "config" && secondary === "validate"; -} - function attachExternalTransport(logger: TsLogger, transport: LogTransport): void { logger.attachTransport((logObj: LogObj) => { if (!externalTransports.has(transport)) { @@ -121,7 +115,7 @@ function resolveSettings(): ResolvedSettings { let cfg: OpenClawConfig["logging"] | undefined = (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig(); - if (!cfg && !shouldSkipLoadConfigFallback()) { + if (!cfg && !shouldSkipMutatingLoggingConfigRead()) { try { const loaded = requireConfig?.("../config/config.js") as | { @@ -333,7 +327,7 @@ export function registerLogTransport(transport: LogTransport): () => void { } export const __test__ = { - shouldSkipLoadConfigFallback, + shouldSkipMutatingLoggingConfigRead, }; function formatLocalDate(date: Date): string {