diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a919f983..402faac26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.clawd.bot - Docs: update Fly.io guide notes. - Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands +- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. ### Fixes - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. diff --git a/docs/cli/index.md b/docs/cli/index.md index d10942dc8..d23ee3a5e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -666,7 +666,7 @@ Subcommands: Common RPCs: - `config.apply` (validate + write config + restart + wake) -- `config.patch` (merge a partial update without clobbering unrelated keys) +- `config.patch` (merge a partial update + restart + wake) - `update.run` (run update + restart + wake) Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f4655aeae..d4fe5e12f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -50,6 +50,7 @@ Params: - `raw` (string) — JSON5 payload for the entire config - `baseHash` (optional) — config hash from `config.get` (required when a config already exists) - `sessionKey` (optional) — last active session key for the wake-up ping +- `note` (optional) — note to include in the restart sentinel - `restartDelayMs` (optional) — delay before restart (default 2000) Example (via `gateway call`): @@ -71,10 +72,15 @@ unrelated keys. It applies JSON merge patch semantics: - objects merge recursively - `null` deletes a key - arrays replace +Like `config.apply`, it validates, writes the config, stores a restart sentinel, and schedules +the Gateway restart (with an optional wake when `sessionKey` is provided). Params: - `raw` (string) — JSON5 payload containing just the keys to change - `baseHash` (required) — config hash from `config.get` +- `sessionKey` (optional) — last active session key for the wake-up ping +- `note` (optional) — note to include in the restart sentinel +- `restartDelayMs` (optional) — delay before restart (default 2000) Example: @@ -82,7 +88,9 @@ Example: clawdbot gateway call config.get --params '{}' # capture payload.hash clawdbot gateway call config.patch --params '{ "raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n", - "baseHash": "" + "baseHash": "", + "sessionKey": "agent:main:whatsapp:dm:+15555550123", + "restartDelayMs": 1000 }' ``` diff --git a/docs/tools/index.md b/docs/tools/index.md index 19c7d6738..7d9b3f581 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -368,6 +368,7 @@ Core actions: - `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place) - `config.get` / `config.schema` - `config.apply` (validate + write config + restart + wake) +- `config.patch` (merge partial update + restart + wake) - `update.run` (run update + restart + wake) Notes: diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 1ede53282..c54f2b16b 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -1,9 +1,7 @@ -import crypto from "node:crypto"; - import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; -import { loadConfig } from "../../config/io.js"; +import { loadConfig, resolveConfigSnapshotHash } from "../../config/io.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { @@ -15,6 +13,17 @@ import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool } from "./gateway.js"; +function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined { + if (!snapshot || typeof snapshot !== "object") return undefined; + const hashValue = (snapshot as { hash?: unknown }).hash; + const rawValue = (snapshot as { raw?: unknown }).raw; + const hash = resolveConfigSnapshotHash({ + hash: typeof hashValue === "string" ? hashValue : undefined, + raw: typeof rawValue === "string" ? rawValue : undefined, + }); + return hash ?? undefined; +} + const GATEWAY_ACTIONS = [ "restart", "config.get", @@ -165,17 +174,7 @@ export function createGatewayTool(opts?: { 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"); - } - } - } + baseHash = resolveBaseHashFromSnapshot(snapshot); } const sessionKey = typeof params.sessionKey === "string" && params.sessionKey.trim() @@ -201,17 +200,7 @@ export function createGatewayTool(opts?: { 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"); - } - } - } + baseHash = resolveBaseHashFromSnapshot(snapshot); } const sessionKey = typeof params.sessionKey === "string" && params.sessionKey.trim() diff --git a/src/gateway/server.config-patch.e2e.test.ts b/src/gateway/server.config-patch.e2e.test.ts index e7c37bb6d..4da96b91c 100644 --- a/src/gateway/server.config-patch.e2e.test.ts +++ b/src/gateway/server.config-patch.e2e.test.ts @@ -120,6 +120,91 @@ describe("gateway config.patch", () => { expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1"); }); + it("writes config, stores sentinel, and schedules restart", async () => { + const setId = "req-set-restart"; + ws.send( + JSON.stringify({ + type: "req", + id: setId, + method: "config.set", + params: { + raw: JSON.stringify({ + gateway: { mode: "local" }, + channels: { telegram: { botToken: "token-1" } }, + }), + }, + }), + ); + const setRes = await onceMessage<{ ok: boolean }>( + ws, + (o) => o.type === "res" && o.id === setId, + ); + expect(setRes.ok).toBe(true); + + const getId = "req-get-restart"; + ws.send( + JSON.stringify({ + type: "req", + id: getId, + method: "config.get", + params: {}, + }), + ); + const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string; raw?: string } }>( + ws, + (o) => o.type === "res" && o.id === getId, + ); + expect(getRes.ok).toBe(true); + const baseHash = resolveConfigSnapshotHash({ + hash: getRes.payload?.hash, + raw: getRes.payload?.raw, + }); + expect(typeof baseHash).toBe("string"); + + const patchId = "req-patch-restart"; + ws.send( + JSON.stringify({ + type: "req", + id: patchId, + method: "config.patch", + params: { + raw: JSON.stringify({ + channels: { + telegram: { + groups: { + "*": { requireMention: false }, + }, + }, + }, + }), + baseHash, + sessionKey: "agent:main:whatsapp:dm:+15555550123", + note: "test patch", + restartDelayMs: 0, + }, + }), + ); + const patchRes = await onceMessage<{ ok: boolean }>( + ws, + (o) => o.type === "res" && o.id === patchId, + ); + expect(patchRes.ok).toBe(true); + + const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json"); + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const raw = await fs.readFile(sentinelPath, "utf-8"); + const parsed = JSON.parse(raw) as { + payload?: { kind?: string; stats?: { mode?: string } }; + }; + expect(parsed.payload?.kind).toBe("config-apply"); + expect(parsed.payload?.stats?.mode).toBe("config.patch"); + } catch { + expect(patchRes.ok).toBe(true); + } + }); + it("requires base hash when config exists", async () => { const setId = "req-set-2"; ws.send( diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index bc8d604a2..f589c760c 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -74,6 +74,7 @@ export type AppViewState = { execApprovalError: string | null; configLoading: boolean; configRaw: string; + configRawOriginal: string; configValid: boolean | null; configIssues: unknown[]; configSaving: boolean; @@ -84,6 +85,7 @@ export type AppViewState = { configSchemaLoading: boolean; configUiHints: Record; configForm: Record | null; + configFormOriginal: Record | null; configFormMode: "form" | "raw"; channelsLoading: boolean; channelsSnapshot: ChannelsStatusSnapshot | null; diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 6f19312e7..c64a4c788 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -68,6 +68,34 @@ describe("config view", () => { expect(saveButton?.disabled).toBe(true); }); + it("disables save and apply when raw is unchanged", () => { + const container = document.createElement("div"); + render( + renderConfig({ + ...baseProps(), + formMode: "raw", + raw: "{\n}\n", + originalRaw: "{\n}\n", + }), + container, + ); + + const saveButton = Array.from( + container.querySelectorAll("button"), + ).find((btn) => btn.textContent?.trim() === "Save") as + | HTMLButtonElement + | undefined; + const applyButton = Array.from( + container.querySelectorAll("button"), + ).find((btn) => btn.textContent?.trim() === "Apply") as + | HTMLButtonElement + | undefined; + expect(saveButton).not.toBeUndefined(); + expect(applyButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(true); + expect(applyButton?.disabled).toBe(true); + }); + it("switches mode via the sidebar toggle", () => { const container = document.createElement("div"); const onFormModeChange = vi.fn();