Spaces:
Paused
Paused
| 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<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, | |
| ); | |
| }, | |
| }; | |