fix: follow up config.patch restarts/docs/tests (#1653)

* fix: land config.patch restarts/docs/tests (#1624) (thanks @Glucksberg)

* docs: update changelog entry for config.patch follow-up (#1653) (thanks @Glucksberg)
This commit is contained in:
Peter Steinberger
2026-01-24 23:33:13 +00:00
committed by GitHub
parent 5570e1a946
commit 8e159ab0b7
8 changed files with 141 additions and 27 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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": "<hash-from-config.get>"
"baseHash": "<hash-from-config.get>",
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
"restartDelayMs": 1000
}'
```

View File

@@ -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:

View File

@@ -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()

View File

@@ -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(

View File

@@ -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<string, unknown>;
configForm: Record<string, unknown> | null;
configFormOriginal: Record<string, unknown> | null;
configFormMode: "form" | "raw";
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;

View File

@@ -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();