| 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<ReturnType<typeof readConfigFileSnapshot>>, |
| 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<ReturnType<typeof readConfigFileSnapshot>>, |
| 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<ConfigValidationIssue>): 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<typeof extractDeliveryInfo>["deliveryContext"]; |
| threadId: ReturnType<typeof extractDeliveryInfo>["threadId"]; |
| } { |
| const { sessionKey, note, restartDelayMs } = parseRestartRequestParams(params); |
|
|
| |
| |
| const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey); |
|
|
| return { |
| sessionKey, |
| note, |
| restartDelayMs, |
| deliveryContext, |
| threadId, |
| }; |
| } |
|
|
| function buildConfigRestartSentinelPayload(params: { |
| kind: RestartSentinelPayload["kind"]; |
| mode: string; |
| sessionKey: string | undefined; |
| deliveryContext: ReturnType<typeof extractDeliveryInfo>["deliveryContext"]; |
| threadId: ReturnType<typeof extractDeliveryInfo>["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<string | null> { |
| 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: () => {}, |
| }, |
| }); |
| |
| |
| |
| 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); |
| }); |
| }, |
| }; |
|
|