| import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.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 { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; |
| import { |
| formatDoctorNonInteractiveHint, |
| type RestartSentinelPayload, |
| writeRestartSentinel, |
| } from "../../infra/restart-sentinel.js"; |
| import { listChannelPlugins } from "../../channels/plugins/index.js"; |
| import { loadOpenClawPlugins } from "../../plugins/loader.js"; |
| import { |
| ErrorCodes, |
| errorShape, |
| formatValidationErrors, |
| validateConfigApplyParams, |
| validateConfigGetParams, |
| validateConfigPatchParams, |
| validateConfigSchemaParams, |
| validateConfigSetParams, |
| } from "../protocol/index.js"; |
| import type { GatewayRequestHandlers, RespondFn } from "./types.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<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 = 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, |
| ); |
| }, |
| }; |
|
|