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 () => { it("passes update.run through gateway call", async () => {
const { callGatewayTool } = await import("./tools/gateway.js"); const { callGatewayTool } = await import("./tools/gateway.js");
const tool = createClawdbotTools({ const tool = createClawdbotTools({

View File

@@ -20,6 +20,7 @@ const GATEWAY_ACTIONS = [
"config.get", "config.get",
"config.schema", "config.schema",
"config.apply", "config.apply",
"config.patch",
"update.run", "update.run",
] as const; ] as const;
@@ -35,10 +36,10 @@ const GatewayToolSchema = Type.Object({
gatewayUrl: Type.Optional(Type.String()), gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
// config.apply // config.apply, config.patch
raw: Type.Optional(Type.String()), raw: Type.Optional(Type.String()),
baseHash: Type.Optional(Type.String()), baseHash: Type.Optional(Type.String()),
// config.apply, update.run // config.apply, config.patch, update.run
sessionKey: Type.Optional(Type.String()), sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()), note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Number()), restartDelayMs: Type.Optional(Type.Number()),
@@ -56,7 +57,7 @@ export function createGatewayTool(opts?: {
label: "Gateway", label: "Gateway",
name: "gateway", name: "gateway",
description: 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, parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
@@ -195,6 +196,42 @@ export function createGatewayTool(opts?: {
}); });
return jsonResult({ ok: true, result }); 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") { if (action === "update.run") {
const sessionKey = const sessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim() typeof params.sessionKey === "string" && params.sessionKey.trim()

View File

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

View File

@@ -260,12 +260,54 @@ export const configHandlers: GatewayRequestHandlers = {
return; return;
} }
await writeConfigFile(validated.config); 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( respond(
true, true,
{ {
ok: true, ok: true,
path: CONFIG_PATH_CLAWDBOT, path: CONFIG_PATH_CLAWDBOT,
config: validated.config, config: validated.config,
restart,
sentinel: {
path: sentinelPath,
payload,
},
}, },
undefined, undefined,
); );

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ function createState(): ConfigState {
applySessionKey: "main", applySessionKey: "main",
configLoading: false, configLoading: false,
configRaw: "", configRaw: "",
configRawOriginal: "",
configValid: null, configValid: null,
configIssues: [], configIssues: [],
configSaving: false, configSaving: false,
@@ -26,6 +27,7 @@ function createState(): ConfigState {
configSchemaLoading: false, configSchemaLoading: false,
configUiHints: {}, configUiHints: {},
configForm: null, configForm: null,
configFormOriginal: null,
configFormDirty: false, configFormDirty: false,
configFormMode: "form", configFormMode: "form",
lastError: null, lastError: null,
@@ -63,6 +65,37 @@ describe("applyConfigSnapshot", () => {
expect(state.configForm).toEqual({ gateway: { mode: "local" } }); 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", () => { describe("updateConfigFormValue", () => {

View File

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

View File

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

View File

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