feat(gateway-tool): add config.patch action for safe partial config updates (#1624)

* fix(ui): enable save button only when config has changes

The save button in the Control UI config editor was not properly gating
on whether actual changes were made. This adds:
- `configRawOriginal` state to track the original raw config for comparison
- Change detection for both form mode (via computeDiff) and raw mode
- `hasChanges` check in canSave/canApply logic
- Set `configFormDirty` when raw mode edits occur
- Handle raw mode UI correctly (badge shows "Unsaved changes", no diff panel)

Fixes #1609

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(gateway-tool): add config.patch action for safe partial config updates

Exposes the existing config.patch server method to agents, allowing safe
partial config updates that merge with existing config instead of replacing it.

- Add config.patch to GATEWAY_ACTIONS in gateway tool
- Add restart + sentinel logic to config.patch server method
- Extend ConfigPatchParamsSchema with sessionKey, note, restartDelayMs
- Add unit test for config.patch gateway tool action

Closes #1617

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Glucksberg
2026-01-24 19:30:32 -04:00
committed by GitHub
parent 0752ae6d6d
commit 60661441b1
10 changed files with 180 additions and 25 deletions

View File

@@ -89,6 +89,32 @@ describe("gateway tool", () => {
);
});
it("passes config.patch through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createClawdbotTools({
agentSessionKey: "agent:main:whatsapp:dm:+15555550123",
}).find((candidate) => candidate.name === "gateway");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
const raw = '{\n channels: { telegram: { groups: { "*": { requireMention: false } } } }\n}\n';
await tool.execute("call4", {
action: "config.patch",
raw,
});
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
expect(callGatewayTool).toHaveBeenCalledWith(
"config.patch",
expect.any(Object),
expect.objectContaining({
raw: raw.trim(),
baseHash: "hash-1",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
}),
);
});
it("passes update.run through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createClawdbotTools({

View File

@@ -20,6 +20,7 @@ const GATEWAY_ACTIONS = [
"config.get",
"config.schema",
"config.apply",
"config.patch",
"update.run",
] as const;
@@ -35,10 +36,10 @@ const GatewayToolSchema = Type.Object({
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
// config.apply
// config.apply, config.patch
raw: Type.Optional(Type.String()),
baseHash: Type.Optional(Type.String()),
// config.apply, update.run
// config.apply, config.patch, update.run
sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Number()),
@@ -56,7 +57,7 @@ export function createGatewayTool(opts?: {
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.",
"Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing.",
parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -195,6 +196,42 @@ export function createGatewayTool(opts?: {
});
return jsonResult({ ok: true, result });
}
if (action === "config.patch") {
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.patch", gatewayOpts, {
raw,
baseHash,
sessionKey,
note,
restartDelayMs,
});
return jsonResult({ ok: true, result });
}
if (action === "update.run") {
const sessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim()

View File

@@ -27,6 +27,9 @@ export const ConfigPatchParamsSchema = Type.Object(
{
raw: NonEmptyString,
baseHash: Type.Optional(NonEmptyString),
sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
);

View File

@@ -260,12 +260,54 @@ export const configHandlers: GatewayRequestHandlers = {
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: formatDoctorNonInteractiveHint(),
stats: {
mode: "config.patch",
root: CONFIG_PATH_CLAWDBOT,
},
};
let sentinelPath: string | null = null;
try {
sentinelPath = await writeRestartSentinel(payload);
} catch {
sentinelPath = null;
}
const restart = scheduleGatewaySigusr1Restart({
delayMs: restartDelayMs,
reason: "config.patch",
});
respond(
true,
{
ok: true,
path: CONFIG_PATH_CLAWDBOT,
config: validated.config,
restart,
sentinel: {
path: sentinelPath,
payload,
},
},
undefined,
);

View File

@@ -493,6 +493,7 @@ export function renderApp(state: AppViewState) {
${state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
@@ -509,7 +510,10 @@ export function renderApp(state: AppViewState) {
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => (state.configRaw = next),
onRawChange: (next) => {
state.configRaw = next;
state.configFormDirty = true;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),

View File

@@ -154,6 +154,7 @@ export class ClawdbotApp extends LitElement {
@state() configLoading = false;
@state() configRaw = "{\n}\n";
@state() configRawOriginal = "";
@state() configValid: boolean | null = null;
@state() configIssues: unknown[] = [];
@state() configSaving = false;

View File

@@ -15,6 +15,7 @@ function createState(): ConfigState {
applySessionKey: "main",
configLoading: false,
configRaw: "",
configRawOriginal: "",
configValid: null,
configIssues: [],
configSaving: false,
@@ -26,6 +27,7 @@ function createState(): ConfigState {
configSchemaLoading: false,
configUiHints: {},
configForm: null,
configFormOriginal: null,
configFormDirty: false,
configFormMode: "form",
lastError: null,
@@ -63,6 +65,37 @@ describe("applyConfigSnapshot", () => {
expect(state.configForm).toEqual({ gateway: { mode: "local" } });
});
it("sets configRawOriginal when clean for change detection", () => {
const state = createState();
applyConfigSnapshot(state, {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: '{ "gateway": { "mode": "local" } }',
});
expect(state.configRawOriginal).toBe('{ "gateway": { "mode": "local" } }');
expect(state.configFormOriginal).toEqual({ gateway: { mode: "local" } });
});
it("preserves configRawOriginal when dirty", () => {
const state = createState();
state.configFormDirty = true;
state.configRawOriginal = '{ "original": true }';
state.configFormOriginal = { original: true };
applyConfigSnapshot(state, {
config: { gateway: { mode: "local" } },
valid: true,
issues: [],
raw: '{ "gateway": { "mode": "local" } }',
});
// Original values should be preserved when dirty
expect(state.configRawOriginal).toBe('{ "original": true }');
expect(state.configFormOriginal).toEqual({ original: true });
});
});
describe("updateConfigFormValue", () => {

View File

@@ -17,6 +17,7 @@ export type ConfigState = {
applySessionKey: string;
configLoading: boolean;
configRaw: string;
configRawOriginal: string;
configValid: boolean | null;
configIssues: unknown[];
configSaving: boolean;
@@ -98,6 +99,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
if (!state.configFormDirty) {
state.configForm = cloneConfigObject(snapshot.config ?? {});
state.configFormOriginal = cloneConfigObject(snapshot.config ?? {});
state.configRawOriginal = rawFromSnapshot;
}
}

View File

@@ -6,6 +6,7 @@ import { renderConfig } from "./config";
describe("config view", () => {
const baseProps = () => ({
raw: "{\n}\n",
originalRaw: "{\n}\n",
valid: true,
issues: [],
loading: false,

View File

@@ -10,6 +10,7 @@ import {
export type ConfigProps = {
raw: string;
originalRaw: string;
valid: boolean | null;
issues: unknown[];
loading: boolean;
@@ -187,29 +188,17 @@ export function renderConfig(props: ConfigProps) {
const formUnsafe = analysis.schema
? analysis.unsupportedPaths.length > 0
: false;
const canSaveForm =
Boolean(props.formValue) && !props.loading && !formUnsafe;
const canSave =
props.connected &&
!props.saving &&
(props.formMode === "raw" ? true : canSaveForm);
const canApply =
props.connected &&
!props.applying &&
!props.updating &&
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
// Get available sections from schema
const schemaProps = analysis.schema?.properties ?? {};
const availableSections = SECTIONS.filter(s => s.key in schemaProps);
// Add any sections in schema but not in our list
const knownKeys = new Set(SECTIONS.map(s => s.key));
const extraSections = Object.keys(schemaProps)
.filter(k => !knownKeys.has(k))
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections];
const activeSectionSchema =
@@ -236,12 +225,29 @@ export function renderConfig(props: ConfigProps) {
: isAllSubsection
? null
: props.activeSubsection ?? (subsections[0]?.key ?? null);
// Compute diff for showing changes
const diff = props.formMode === "form"
// Compute diff for showing changes (works for both form and raw modes)
const diff = props.formMode === "form"
? computeDiff(props.originalValue, props.formValue)
: [];
const hasChanges = diff.length > 0;
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled
const canSaveForm =
Boolean(props.formValue) && !props.loading && !formUnsafe;
const canSave =
props.connected &&
!props.saving &&
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
const canApply =
props.connected &&
!props.applying &&
!props.updating &&
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
return html`
<div class="config-layout">
@@ -319,7 +325,7 @@ export function renderConfig(props: ConfigProps) {
<div class="config-actions">
<div class="config-actions__left">
${hasChanges ? html`
<span class="config-changes-badge">${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}</span>
<span class="config-changes-badge">${props.formMode === "raw" ? "Unsaved changes" : `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span>
` : html`
<span class="config-status muted">No changes</span>
`}
@@ -352,8 +358,8 @@ export function renderConfig(props: ConfigProps) {
</div>
</div>
<!-- Diff panel -->
${hasChanges ? html`
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${hasChanges && props.formMode === "form" ? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>