import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { CONFIG_PATH, loadConfig, parseConfigJson5, readConfigFileSnapshot, resolveConfigSnapshotHash, validateConfigObjectWithPlugins, writeConfigFile, } from "../../config/config.js"; import { applyLegacyMigrations } from "../../config/legacy.js"; import { applyMergePatch } from "../../config/merge-patch.js"; import { buildConfigSchema } from "../../config/schema.js"; import { formatDoctorNonInteractiveHint, type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; import { ErrorCodes, errorShape, formatValidationErrors, validateConfigApplyParams, validateConfigGetParams, validateConfigPatchParams, validateConfigSchemaParams, validateConfigSetParams, } from "../protocol/index.js"; function resolveBaseHash(params: unknown): string | null { const raw = (params as { baseHash?: unknown })?.baseHash; if (typeof raw !== "string") { return null; } const trimmed = raw.trim(); return trimmed ? trimmed : null; } 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 = resolveBaseHash(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; } export const configHandlers: GatewayRequestHandlers = { "config.get": async ({ params, respond }) => { if (!validateConfigGetParams(params)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`, ), ); return; } const snapshot = await readConfigFileSnapshot(); respond(true, snapshot, undefined); }, "config.schema": ({ params, respond }) => { if (!validateConfigSchemaParams(params)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`, ), ); return; } const cfg = loadConfig(); const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); const pluginRegistry = loadOpenClawPlugins({ config: cfg, workspaceDir, logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }, }); const schema = 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, })), }); respond(true, schema, undefined); }, "config.set": async ({ params, respond }) => { if (!validateConfigSetParams(params)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`, ), ); return; } const snapshot = await readConfigFileSnapshot(); if (!requireConfigBaseHash(params, snapshot, respond)) { return; } const rawValue = (params as { raw?: unknown }).raw; if (typeof rawValue !== "string") { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config.set params: raw (string) required"), ); return; } const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } const validated = validateConfigObjectWithPlugins(parsedRes.parsed); if (!validated.ok) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { details: { issues: validated.issues }, }), ); return; } await writeConfigFile(validated.config); respond( true, { ok: true, path: CONFIG_PATH, config: validated.config, }, undefined, ); }, "config.patch": async ({ params, respond }) => { if (!validateConfigPatchParams(params)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`, ), ); return; } const snapshot = await readConfigFileSnapshot(); 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); const migrated = applyLegacyMigrations(merged); const resolved = migrated.next ?? merged; const validated = validateConfigObjectWithPlugins(resolved); if (!validated.ok) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { details: { issues: validated.issues }, }), ); return; } await writeConfigFile(validated.config); const sessionKey = typeof (params as { sessionKey?: unknown }).sessionKey === "string" ? (params as { sessionKey?: string }).sessionKey?.trim() || undefined : undefined; const note = typeof (params as { note?: unknown }).note === "string" ? (params as { note?: string }).note?.trim() || undefined : undefined; const restartDelayMsRaw = (params as { restartDelayMs?: unknown }).restartDelayMs; const restartDelayMs = typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw) ? Math.max(0, Math.floor(restartDelayMsRaw)) : undefined; const payload: RestartSentinelPayload = { kind: "config-apply", status: "ok", ts: Date.now(), sessionKey, message: note ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: "config.patch", root: CONFIG_PATH, }, }; let sentinelPath: string | null = null; try { sentinelPath = await writeRestartSentinel(payload); } catch { sentinelPath = null; } const restart = scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: "config.patch", }); respond( true, { ok: true, path: CONFIG_PATH, config: validated.config, restart, sentinel: { path: sentinelPath, payload, }, }, undefined, ); }, "config.apply": async ({ params, respond }) => { if (!validateConfigApplyParams(params)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid config.apply params: ${formatValidationErrors(validateConfigApplyParams.errors)}`, ), ); return; } const snapshot = await readConfigFileSnapshot(); if (!requireConfigBaseHash(params, snapshot, respond)) { return; } const rawValue = (params as { raw?: unknown }).raw; if (typeof rawValue !== "string") { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, "invalid config.apply params: raw (string) required", ), ); return; } const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } const validated = validateConfigObjectWithPlugins(parsedRes.parsed); if (!validated.ok) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", { details: { issues: validated.issues }, }), ); return; } await writeConfigFile(validated.config); const sessionKey = typeof (params as { sessionKey?: unknown }).sessionKey === "string" ? (params as { sessionKey?: string }).sessionKey?.trim() || undefined : undefined; const note = typeof (params as { note?: unknown }).note === "string" ? (params as { note?: string }).note?.trim() || undefined : undefined; const restartDelayMsRaw = (params as { restartDelayMs?: unknown }).restartDelayMs; const restartDelayMs = typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw) ? Math.max(0, Math.floor(restartDelayMsRaw)) : undefined; const payload: RestartSentinelPayload = { kind: "config-apply", status: "ok", ts: Date.now(), sessionKey, message: note ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: "config.apply", root: CONFIG_PATH, }, }; let sentinelPath: string | null = null; try { sentinelPath = await writeRestartSentinel(payload); } catch { sentinelPath = null; } const restart = scheduleGatewaySigusr1Restart({ delayMs: restartDelayMs, reason: "config.apply", }); respond( true, { ok: true, path: CONFIG_PATH, config: validated.config, restart, sentinel: { path: sentinelPath, payload, }, }, undefined, ); }, };