371 lines
10 KiB
TypeScript
371 lines
10 KiB
TypeScript
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
|
import {
|
|
CONFIG_PATH_CLAWDBOT,
|
|
loadConfig,
|
|
parseConfigJson5,
|
|
readConfigFileSnapshot,
|
|
resolveConfigSnapshotHash,
|
|
validateConfigObject,
|
|
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 {
|
|
DOCTOR_NONINTERACTIVE_HINT,
|
|
type RestartSentinelPayload,
|
|
writeRestartSentinel,
|
|
} from "../../infra/restart-sentinel.js";
|
|
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
|
import { loadClawdbotPlugins } 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 = loadClawdbotPlugins({
|
|
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 = validateConfigObject(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_CLAWDBOT,
|
|
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 = validateConfigObject(resolved);
|
|
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_CLAWDBOT,
|
|
config: validated.config,
|
|
},
|
|
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 = validateConfigObject(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: DOCTOR_NONINTERACTIVE_HINT,
|
|
stats: {
|
|
mode: "config.apply",
|
|
root: CONFIG_PATH_CLAWDBOT,
|
|
},
|
|
};
|
|
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_CLAWDBOT,
|
|
config: validated.config,
|
|
restart,
|
|
sentinel: {
|
|
path: sentinelPath,
|
|
payload,
|
|
},
|
|
},
|
|
undefined,
|
|
);
|
|
},
|
|
};
|