import crypto from "node:crypto"; import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { formatDoctorNonInteractiveHint, type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool } from "./gateway.js"; const GATEWAY_ACTIONS = [ "restart", "config.get", "config.schema", "config.apply", "update.run", ] as const; // NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. // The discriminator (action) determines which properties are relevant; runtime validates. const GatewayToolSchema = Type.Object({ action: stringEnum(GATEWAY_ACTIONS), // restart delayMs: Type.Optional(Type.Number()), reason: Type.Optional(Type.String()), // config.get, config.schema, config.apply, update.run gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), // config.apply raw: Type.Optional(Type.String()), baseHash: Type.Optional(Type.String()), // config.apply, update.run sessionKey: Type.Optional(Type.String()), note: Type.Optional(Type.String()), restartDelayMs: Type.Optional(Type.Number()), }); // NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here: // - OpenAI rejects tool schemas that include these keywords at the *top-level*. // - Claude/Vertex has other JSON Schema quirks. // Conditional requirements (like `raw` for config.apply) are enforced at runtime. export function createGatewayTool(opts?: { agentSessionKey?: string; config?: ClawdbotConfig; }): AnyAgentTool { return { label: "Gateway", name: "gateway", description: "Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.apply/update.run to write config or run updates with validation and restart.", parameters: GatewayToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const action = readStringParam(params, "action", { required: true }); if (action === "restart") { if (opts?.config?.commands?.restart !== true) { throw new Error("Gateway restart is disabled. Set commands.restart=true to enable."); } const sessionKey = typeof params.sessionKey === "string" && params.sessionKey.trim() ? params.sessionKey.trim() : opts?.agentSessionKey?.trim() || undefined; const delayMs = typeof params.delayMs === "number" && Number.isFinite(params.delayMs) ? Math.floor(params.delayMs) : undefined; const reason = typeof params.reason === "string" && params.reason.trim() ? params.reason.trim().slice(0, 200) : undefined; const note = typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; const payload: RestartSentinelPayload = { kind: "restart", status: "ok", ts: Date.now(), sessionKey, message: note ?? reason ?? null, doctorHint: formatDoctorNonInteractiveHint(), stats: { mode: "gateway.restart", reason, }, }; try { await writeRestartSentinel(payload); } catch { // ignore: sentinel is best-effort } console.info( `gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`, ); const scheduled = scheduleGatewaySigusr1Restart({ delayMs, reason, }); return jsonResult(scheduled); } const gatewayUrl = typeof params.gatewayUrl === "string" && params.gatewayUrl.trim() ? params.gatewayUrl.trim() : undefined; const gatewayToken = typeof params.gatewayToken === "string" && params.gatewayToken.trim() ? params.gatewayToken.trim() : undefined; const timeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) ? Math.max(1, Math.floor(params.timeoutMs)) : undefined; const gatewayOpts = { gatewayUrl, gatewayToken, timeoutMs }; if (action === "config.get") { const result = await callGatewayTool("config.get", gatewayOpts, {}); return jsonResult({ ok: true, result }); } if (action === "config.schema") { const result = await callGatewayTool("config.schema", gatewayOpts, {}); return jsonResult({ ok: true, result }); } if (action === "config.apply") { const raw = readStringParam(params, "raw", { required: true }); let baseHash = readStringParam(params, "baseHash"); if (!baseHash) { const snapshot = await callGatewayTool("config.get", gatewayOpts, {}); if (snapshot && typeof snapshot === "object") { const hash = (snapshot as { hash?: unknown }).hash; if (typeof hash === "string" && hash.trim()) { baseHash = hash.trim(); } else { const rawSnapshot = (snapshot as { raw?: unknown }).raw; if (typeof rawSnapshot === "string") { baseHash = crypto.createHash("sha256").update(rawSnapshot).digest("hex"); } } } } const sessionKey = typeof params.sessionKey === "string" && params.sessionKey.trim() ? params.sessionKey.trim() : opts?.agentSessionKey?.trim() || undefined; const note = typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; const restartDelayMs = typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs) ? Math.floor(params.restartDelayMs) : undefined; const result = await callGatewayTool("config.apply", gatewayOpts, { raw, baseHash, sessionKey, note, restartDelayMs, }); return jsonResult({ ok: true, result }); } if (action === "update.run") { const sessionKey = typeof params.sessionKey === "string" && params.sessionKey.trim() ? params.sessionKey.trim() : opts?.agentSessionKey?.trim() || undefined; const note = typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; const restartDelayMs = typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs) ? Math.floor(params.restartDelayMs) : undefined; const result = await callGatewayTool("update.run", gatewayOpts, { sessionKey, note, restartDelayMs, timeoutMs, }); return jsonResult({ ok: true, result }); } throw new Error(`Unknown action: ${action}`); }, }; }