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, resolveConfigSnapshotHash, validateConfigObjectWithPlugins, writeConfigFile, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { applyLegacyMigrations } from "../../config/legacy.js"; import { applyMergePatch } from "../../config/merge-patch.js"; import { redactConfigObject, redactConfigSnapshot, restoreRedactedValues, } from "../../config/redact-snapshot.js"; import { buildConfigSchema, lookupConfigSchema, type ConfigSchemaResponse, } from "../../config/schema.js"; import { extractDeliveryInfo } from "../../config/sessions.js"; import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js"; import { formatDoctorNonInteractiveHint, type RestartSentinelPayload, 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, resolveControlPlaneActor, summarizeChangedPaths, } from "../control-plane-audit.js"; import { ErrorCodes, errorShape, formatValidationErrors, validateConfigApplyParams, validateConfigGetParams, validateConfigPatchParams, validateConfigSchemaLookupParams, validateConfigSchemaLookupResult, validateConfigSchemaParams, validateConfigSetParams, } from "../protocol/index.js"; import { resolveBaseHashParam } from "./base-hash.js"; import { parseRestartRequestParams } from "./restart-request.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; const MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE = 3; function requireConfigBaseHash( params: unknown, snapshot: Awaited>, respond: RespondFn, ): boolean { if (!snapshot.exists) { return true; } const snapshotHash = resolveConfigSnapshotHash(snapshot); if (!snapshotHash) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, "config base hash unavailable; re-run config.get and retry", ), ); return false; } const baseHash = resolveBaseHashParam(params); if (!baseHash) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, "config base hash required; re-run config.get and retry", ), ); return false; } if (baseHash !== snapshotHash) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, "config changed since last load; re-run config.get and retry", ), ); return false; } return true; } function parseRawConfigOrRespond( params: unknown, requestName: string, respond: RespondFn, ): string | null { const rawValue = (params as { raw?: unknown }).raw; if (typeof rawValue !== "string") { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid ${requestName} params: raw (string) required`, ), ); return null; } 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, snapshot: Awaited>, respond: RespondFn, ): { config: OpenClawConfig; schema: ConfigSchemaResponse } | null { const rawValue = parseRawConfigOrRespond(params, requestName, respond); if (!rawValue) { return null; } const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return null; } const schema = loadSchemaWithPlugins(); const restored = restoreRedactedValues(parsedRes.parsed, snapshot.config, schema.uiHints); if (!restored.ok) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, restored.humanReadableMessage ?? "invalid config"), ); return null; } const validated = validateConfigObjectWithPlugins(restored.result); if (!validated.ok) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), { details: { issues: validated.issues }, }), ); return null; } return { config: validated.config, schema }; } function summarizeConfigValidationIssues(issues: ReadonlyArray): string { const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE); const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true }) .map((line) => line.trim()) .filter(Boolean); if (lines.length === 0) { return "invalid config"; } const hiddenCount = Math.max(0, issues.length - lines.length); return `invalid config: ${lines.join("; ")}${ hiddenCount > 0 ? ` (+${hiddenCount} more issue${hiddenCount === 1 ? "" : "s"})` : "" }`; } function resolveConfigRestartRequest(params: unknown): { sessionKey: string | undefined; note: string | undefined; restartDelayMs: number | undefined; deliveryContext: ReturnType["deliveryContext"]; threadId: ReturnType["threadId"]; } { const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params); // Extract deliveryContext + threadId for routing after restart // Supports both :thread: (most channels) and :topic: (Telegram) const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey); return { sessionKey, note, restartDelayMs, deliveryContext, threadId, }; } function buildConfigRestartSentinelPayload(params: { kind: RestartSentinelPayload["kind"]; mode: string; sessionKey: string | undefined; deliveryContext: ReturnType["deliveryContext"]; threadId: ReturnType["threadId"]; note: string | undefined; }): RestartSentinelPayload { const configPath = createConfigIO().configPath; return { kind: params.kind, status: "ok", ts: Date.now(), sessionKey: params.sessionKey, deliveryContext: params.deliveryContext, threadId: params.threadId, message: params.note ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: params.mode, root: configPath, }, }; } async function tryWriteRestartSentinelPayload( payload: RestartSentinelPayload, ): Promise { try { return await writeRestartSentinel(payload); } catch { return null; } } function loadSchemaWithPlugins(): ConfigSchemaResponse { const cfg = loadConfig(); const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); const pluginRegistry = loadOpenClawPlugins({ config: cfg, cache: true, workspaceDir, 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, })), }); } export const configHandlers: GatewayRequestHandlers = { "config.get": async ({ params, respond }) => { if (!assertValidParams(params, validateConfigGetParams, "config.get", respond)) { return; } const snapshot = await readConfigFileSnapshot(); const schema = loadSchemaWithPlugins(); respond(true, redactConfigSnapshot(snapshot, schema.uiHints), undefined); }, "config.schema": ({ params, respond }) => { if (!assertValidParams(params, validateConfigSchemaParams, "config.schema", respond)) { return; } 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; } const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); if (!requireConfigBaseHash(params, snapshot, respond)) { return; } const parsed = parseValidateConfigFromRawOrRespond(params, "config.set", snapshot, respond); if (!parsed) { return; } await writeConfigFile(parsed.config, writeOptions); respond( true, { ok: true, path: createConfigIO().configPath, config: redactConfigObject(parsed.config, parsed.schema.uiHints), }, undefined, ); }, "config.patch": async ({ params, respond, client, context }) => { if (!assertValidParams(params, validateConfigPatchParams, "config.patch", respond)) { return; } const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); if (!requireConfigBaseHash(params, snapshot, respond)) { return; } if (!snapshot.valid) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config; fix before patching"), ); return; } const rawValue = (params as { raw?: unknown }).raw; if (typeof rawValue !== "string") { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, "invalid config.patch params: raw (string) required", ), ); return; } const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } if ( !parsedRes.parsed || typeof parsedRes.parsed !== "object" || Array.isArray(parsedRes.parsed) ) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "config.patch raw must be an object"), ); return; } const merged = applyMergePatch(snapshot.config, parsedRes.parsed, { mergeObjectArraysById: true, }); const schemaPatch = loadSchemaWithPlugins(); const restoredMerge = restoreRedactedValues(merged, snapshot.config, schemaPatch.uiHints); if (!restoredMerge.ok) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, restoredMerge.humanReadableMessage ?? "invalid config", ), ); return; } const migrated = applyLegacyMigrations(restoredMerge.result); const resolved = migrated.next ?? restoredMerge.result; const validated = validateConfigObjectWithPlugins(resolved); if (!validated.ok) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), { details: { issues: validated.issues }, }), ); return; } const changedPaths = diffConfigPaths(snapshot.config, validated.config); const actor = resolveControlPlaneActor(client); context?.logGateway?.info( `config.patch write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.patch`, ); await writeConfigFile(validated.config, writeOptions); const { sessionKey, note, restartDelayMs, deliveryContext, threadId } = resolveConfigRestartRequest(params); const payload = buildConfigRestartSentinelPayload({ kind: "config-patch", mode: "config.patch", sessionKey, deliveryContext, threadId, note, }); const sentinelPath = await tryWriteRestartSentinelPayload(payload); const restart = scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: "config.patch", audit: { actor: actor.actor, deviceId: actor.deviceId, clientIp: actor.clientIp, changedPaths, }, }); if (restart.coalesced) { context?.logGateway?.warn( `config.patch restart coalesced ${formatControlPlaneActor(actor)} delayMs=${restart.delayMs}`, ); } respond( true, { ok: true, path: createConfigIO().configPath, config: redactConfigObject(validated.config, schemaPatch.uiHints), restart, sentinel: { path: sentinelPath, payload, }, }, undefined, ); }, "config.apply": async ({ params, respond, client, context }) => { if (!assertValidParams(params, validateConfigApplyParams, "config.apply", respond)) { return; } const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); if (!requireConfigBaseHash(params, snapshot, respond)) { return; } const parsed = parseValidateConfigFromRawOrRespond(params, "config.apply", snapshot, respond); if (!parsed) { return; } const changedPaths = diffConfigPaths(snapshot.config, parsed.config); const actor = resolveControlPlaneActor(client); context?.logGateway?.info( `config.apply write ${formatControlPlaneActor(actor)} changedPaths=${summarizeChangedPaths(changedPaths)} restartReason=config.apply`, ); await writeConfigFile(parsed.config, writeOptions); const { sessionKey, note, restartDelayMs, deliveryContext, threadId } = resolveConfigRestartRequest(params); const payload = buildConfigRestartSentinelPayload({ kind: "config-apply", mode: "config.apply", sessionKey, deliveryContext, threadId, note, }); const sentinelPath = await tryWriteRestartSentinelPayload(payload); const restart = scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: "config.apply", audit: { actor: actor.actor, deviceId: actor.deviceId, clientIp: actor.clientIp, changedPaths, }, }); if (restart.coalesced) { context?.logGateway?.warn( `config.apply restart coalesced ${formatControlPlaneActor(actor)} delayMs=${restart.delayMs}`, ); } respond( true, { ok: true, path: createConfigIO().configPath, config: redactConfigObject(parsed.config, parsed.schema.uiHints), restart, sentinel: { path: sentinelPath, payload, }, }, undefined, ); }, "config.openFile": ({ params, respond }) => { if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) { return; } const configPath = createConfigIO().configPath; const platform = process.platform; const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open"; exec(`${cmd} ${JSON.stringify(configPath)}`, (err) => { if (err) { respond(true, { ok: false, path: configPath, error: err.message }, undefined); return; } respond(true, { ok: true, path: configPath }, undefined); }); }, };