mirror of https://github.com/openclaw/openclaw.git
574 lines
17 KiB
TypeScript
574 lines
17 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import {
|
|
createConfigIO,
|
|
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 { loadGatewayRuntimeConfigSchema } from "../../config/runtime-schema.js";
|
|
import { 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 { 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;
|
|
|
|
type ConfigOpenCommand = {
|
|
command: string;
|
|
args: string[];
|
|
};
|
|
|
|
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 escapePowerShellSingleQuotedString(value: string): string {
|
|
return value.replaceAll("'", "''");
|
|
}
|
|
|
|
export function resolveConfigOpenCommand(
|
|
configPath: string,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): ConfigOpenCommand {
|
|
if (platform === "win32") {
|
|
// Use a PowerShell string literal so the path stays data, not code.
|
|
return {
|
|
command: "powershell.exe",
|
|
args: [
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
`Start-Process -LiteralPath '${escapePowerShellSingleQuotedString(configPath)}'`,
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
command: platform === "darwin" ? "open" : "xdg-open",
|
|
args: [configPath],
|
|
};
|
|
}
|
|
|
|
function execConfigOpenCommand(command: ConfigOpenCommand): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(command.command, command.args, (error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function formatConfigOpenError(error: unknown): string {
|
|
if (
|
|
typeof error === "object" &&
|
|
error &&
|
|
"message" in error &&
|
|
typeof error.message === "string"
|
|
) {
|
|
return error.message;
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
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);
|
|
|
|
// 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<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 {
|
|
// Note: We can't easily cache this, as there are no callback that can invalidate
|
|
// our cache. However, loadConfig() and loadOpenClawPlugins() (called inside
|
|
// loadGatewayRuntimeConfigSchema) already cache their results, and buildConfigSchema()
|
|
// is just a cheap transformation.
|
|
return loadGatewayRuntimeConfigSchema();
|
|
}
|
|
|
|
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": async ({ params, respond, context }) => {
|
|
if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) {
|
|
return;
|
|
}
|
|
const configPath = createConfigIO().configPath;
|
|
try {
|
|
await execConfigOpenCommand(resolveConfigOpenCommand(configPath));
|
|
respond(true, { ok: true, path: configPath }, undefined);
|
|
} catch (error) {
|
|
context?.logGateway?.warn(
|
|
`config.openFile failed path=${sanitizeLookupPathForLog(configPath)}: ${formatConfigOpenError(error)}`,
|
|
);
|
|
respond(
|
|
true,
|
|
{ ok: false, path: configPath, error: "failed to open config file" },
|
|
undefined,
|
|
);
|
|
}
|
|
},
|
|
};
|