Skip to content

Commit 6802d01

Browse files
authored
feat(kiloclaw): validate openclaw config file saves (#3542)
* feat(kiloclaw): validate openclaw config file saves * fix(kiloclaw): harden config validation save flow * fix(kiloclaw): handle config validation failures * docs(kiloclaw): clarify config validation changelog entry * fix(kiloclaw): serialize config file mutations
1 parent 67c690f commit 6802d01

28 files changed

Lines changed: 1419 additions & 65 deletions

.specs/kiloclaw-controller.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,19 @@ running. When forwarding, it MUST authenticate to gog with
730730
| GET | `/_kilo/files/read` | Read a safe file path |
731731
| POST | `/_kilo/files/import-openclaw-workspace` | Import OpenClaw workspace files |
732732
| POST | `/_kilo/files/write` | Write a safe file path |
733+
| POST | `/_kilo/files/write-openclaw-config` | Validate and write `openclaw.json`, with explicit invalid override support |
734+
735+
##### Validation-aware `openclaw.json` file writes
736+
737+
1. `POST /_kilo/files/write-openclaw-config` MUST provide the validation-aware save flow for `openclaw.json`; clients MUST NOT use optional fields on generic `/_kilo/files/write` to infer validation behavior.
738+
2. For a normal validation-aware save, the controller MUST evaluate the submitted candidate using the installed OpenClaw config-validation behavior before committing it.
739+
3. When the candidate is invalid or validation cannot complete, the controller MUST leave `openclaw.json` unchanged and MUST return a bounded structured warning result suitable for an authenticated client to display.
740+
4. The controller MAY accept an explicit invalid-write override after a warning. An override MUST remain subject to normal safe-path and ETag concurrency checks and MUST NOT be inferred from an ordinary save request.
741+
5. Validation-warning responses MAY return bounded diagnostics derived while validating the authenticated instance's configuration, including substituted values; those diagnostics MUST be returned only to authenticated config-management clients and MUST NOT be logged. Responses and logs MUST NOT expose staging paths or unrestricted subprocess output.
742+
6. Validation-aware writes MUST remain usable when the gateway process is unavailable after controller routes are registered.
743+
7. Controllers implementing this behavior MUST advertise `files.write-openclaw-config`; clients MUST NOT infer this behavior solely from controller CalVer.
744+
8. Controllers implementing this behavior MUST serialize any remaining generic `POST /_kilo/files/write` mutation of `openclaw.json` with validation-aware writes and other controller-owned config mutations so legacy clients cannot interleave config commits.
745+
9. This validation reports OpenClaw configuration validity. It MUST NOT be represented as proof that runtime SecretRefs or optional environment substitutions resolve successfully.
733746

734747
#### Doctor (bearer token)
735748

apps/web/src/app/(app)/claw/components/FileEditorPane.tsx

Lines changed: 121 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ import { Loader2 } from 'lucide-react';
66
import { toast } from 'sonner';
77
import { Button } from '@/components/ui/button';
88
import { Alert, AlertDescription } from '@/components/ui/alert';
9+
import {
10+
AlertDialog,
11+
AlertDialogAction,
12+
AlertDialogCancel,
13+
AlertDialogContent,
14+
AlertDialogDescription,
15+
AlertDialogFooter,
16+
AlertDialogHeader,
17+
AlertDialogTitle,
18+
} from '@/components/ui/alert-dialog';
19+
import type {
20+
FileWriteResponse,
21+
OpenclawFileWriteValidation,
22+
} from '@/lib/kiloclaw/kiloclaw-internal-client';
923

1024
const Editor = lazy<React.ComponentType<EditorProps>>(() => import('@monaco-editor/react'));
1125
const DiffEditor = lazy<React.ComponentType<DiffEditorProps>>(() =>
@@ -61,15 +75,21 @@ export interface FileEditorPaneProps {
6175
error: { message: string } | null;
6276
refetch: () => void;
6377
onSave: (
64-
args: { path: string; content: string; etag?: string },
78+
args: {
79+
path: string;
80+
content: string;
81+
etag?: string;
82+
openclawValidation?: OpenclawFileWriteValidation;
83+
},
6584
callbacks: {
66-
onSuccess: (result: { etag: string }) => void;
85+
onSuccess: (result: FileWriteResponse) => void;
6786
onError: (err: FileSaveError) => void;
6887
}
6988
) => void;
7089
isSaving: boolean;
7190
onDirtyChange?: (dirty: boolean) => void;
7291
validateBeforeSave?: (filePath: string, content: string) => boolean;
92+
enableOpenclawValidation?: boolean;
7393
}
7494

7595
export function FileEditorPane({
@@ -82,8 +102,13 @@ export function FileEditorPane({
82102
isSaving,
83103
onDirtyChange,
84104
validateBeforeSave,
105+
enableOpenclawValidation = false,
85106
}: FileEditorPaneProps) {
86107
const [showDiff, setShowDiff] = useState(false);
108+
const [pendingValidationWarning, setPendingValidationWarning] = useState<
109+
| (Extract<FileWriteResponse, { outcome: 'openclaw-validation-warning' }> & { content: string })
110+
| null
111+
>(null);
87112

88113
// savedContentRef holds the last successfully saved content, used as fallback
89114
// until the query refetches to avoid flashing stale content after save.
@@ -98,6 +123,7 @@ export function FileEditorPane({
98123
prevFilePathRef.current = filePath;
99124
setEditedContent(null);
100125
setShowDiff(false);
126+
setPendingValidationWarning(null);
101127
etagRef.current = undefined;
102128
savedContentRef.current = null;
103129
}
@@ -132,6 +158,40 @@ export function FileEditorPane({
132158
[serverContent]
133159
);
134160

161+
const submitSave = useCallback(
162+
(content: string, openclawValidation?: OpenclawFileWriteValidation) => {
163+
onSave(
164+
{ path: filePath, content, etag: etagRef.current, openclawValidation },
165+
{
166+
onSuccess: result => {
167+
if ('outcome' in result) {
168+
setPendingValidationWarning({ ...result, content });
169+
return;
170+
}
171+
etagRef.current = result.etag;
172+
savedContentRef.current = content;
173+
setEditedContent(null);
174+
setPendingValidationWarning(null);
175+
toast.success(`Saved ${filePath}`);
176+
},
177+
onError: err => {
178+
if (err.data?.code === 'CONFLICT' && err.data?.upstreamCode === 'file_etag_conflict') {
179+
setPendingValidationWarning(null);
180+
refetch();
181+
setShowDiff(true);
182+
toast.error(
183+
'File was modified externally — your edits are preserved, review the diff'
184+
);
185+
} else {
186+
toast.error(err.message);
187+
}
188+
},
189+
}
190+
);
191+
},
192+
[filePath, onSave, refetch]
193+
);
194+
135195
if (isLoading) {
136196
return <EditorLoading />;
137197
}
@@ -198,7 +258,7 @@ export function FileEditorPane({
198258
value={currentValue}
199259
onChange={handleEditorChange}
200260
theme="vs-dark"
201-
options={EDITOR_OPTIONS}
261+
options={{ ...EDITOR_OPTIONS, readOnly: isSaving }}
202262
keepCurrentModel
203263
/>
204264
)}
@@ -226,34 +286,11 @@ export function FileEditorPane({
226286
if (validateBeforeSave && !validateBeforeSave(filePath, currentValue)) {
227287
return;
228288
}
229-
onSave(
230-
{ path: filePath, content: currentValue, etag: etagRef.current },
231-
{
232-
onSuccess: result => {
233-
etagRef.current = result.etag;
234-
// Optimistically update serverContent so we don't flash stale content
235-
// while the invalidated readFile query refetches.
236-
savedContentRef.current = currentValue;
237-
setEditedContent(null);
238-
toast.success(`Saved ${filePath}`);
239-
},
240-
onError: err => {
241-
if (
242-
err.data?.code === 'CONFLICT' &&
243-
err.data?.upstreamCode === 'file_etag_conflict'
244-
) {
245-
// Preserve the user's edits and show diff so they can compare
246-
// their version against the server's updated content.
247-
refetch();
248-
setShowDiff(true);
249-
toast.error(
250-
'File was modified externally — your edits are preserved, review the diff'
251-
);
252-
} else {
253-
toast.error(err.message);
254-
}
255-
},
256-
}
289+
submitSave(
290+
currentValue,
291+
enableOpenclawValidation && filePath === 'openclaw.json'
292+
? 'warn-before-write'
293+
: undefined
257294
);
258295
}}
259296
>
@@ -268,6 +305,59 @@ export function FileEditorPane({
268305
</Button>
269306
</div>
270307
</div>
308+
309+
<AlertDialog
310+
open={pendingValidationWarning !== null}
311+
onOpenChange={open => {
312+
if (!open && !isSaving) setPendingValidationWarning(null);
313+
}}
314+
>
315+
<AlertDialogContent className="sm:max-w-xl">
316+
<AlertDialogHeader>
317+
<AlertDialogTitle>
318+
{pendingValidationWarning?.reason === 'invalid'
319+
? 'OpenClaw configuration is invalid'
320+
: 'Configuration validation could not run'}
321+
</AlertDialogTitle>
322+
<AlertDialogDescription>
323+
{pendingValidationWarning?.reason === 'invalid'
324+
? 'OpenClaw may reject this file or restore the previous configuration during reload or startup.'
325+
: 'Save without validation only if you understand that OpenClaw may reject or restore this file.'}
326+
</AlertDialogDescription>
327+
</AlertDialogHeader>
328+
{pendingValidationWarning && (
329+
<div className="bg-muted max-h-48 overflow-y-auto rounded-md border p-3 text-xs">
330+
<ul className="space-y-2">
331+
{pendingValidationWarning.issues.map((issue, index) => (
332+
<li key={`${issue.path}:${index}`} className="space-y-0.5">
333+
{issue.path && <div className="font-mono text-destructive">{issue.path}</div>}
334+
<div className="text-muted-foreground">{issue.message}</div>
335+
{issue.allowedValues && issue.allowedValues.length > 0 && (
336+
<div className="text-muted-foreground font-mono">
337+
Allowed values: {issue.allowedValues.join(', ')}
338+
</div>
339+
)}
340+
</li>
341+
))}
342+
</ul>
343+
</div>
344+
)}
345+
<AlertDialogFooter>
346+
<AlertDialogCancel disabled={isSaving}>Keep editing</AlertDialogCancel>
347+
<AlertDialogAction
348+
variant="destructive"
349+
disabled={isSaving || !pendingValidationWarning}
350+
onClick={() => {
351+
if (pendingValidationWarning) {
352+
submitSave(pendingValidationWarning.content, 'allow-invalid');
353+
}
354+
}}
355+
>
356+
{isSaving ? 'Saving...' : 'Save anyway'}
357+
</AlertDialogAction>
358+
</AlertDialogFooter>
359+
</AlertDialogContent>
360+
</AlertDialog>
271361
</div>
272362
);
273363
}

apps/web/src/app/(app)/claw/components/SettingsTab.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,8 @@ export function SettingsTab({
20032003
cleanVersion(controllerVersion?.version),
20042004
OPENCLAW_IMPORT_UI_MIN_CONTROLLER_VERSION
20052005
);
2006+
const supportsOpenclawSaveValidation =
2007+
controllerVersion?.capabilities?.includes('files.write-openclaw-config') === true;
20062008
// Fail OPEN: hide the interests editor only when the controller
20072009
// version is positively parsed as too old, OR the worker reports an
20082010
// explicit `version: null` (its positive old-controller signal for a
@@ -2605,6 +2607,7 @@ export function SettingsTab({
26052607
enabled={isRunning}
26062608
mutations={mutations}
26072609
onOpenChange={setEditConfigOpen}
2610+
enableOpenclawValidation={supportsOpenclawSaveValidation}
26082611
/>
26092612
</div>
26102613
)}

apps/web/src/app/(app)/claw/components/WorkspaceFileEditor.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import { useCallback } from 'react';
44
import { useClawFileTree, useClawReadFile } from '../hooks/useClawHooks';
55
import type { useKiloClawMutations } from '@/hooks/useKiloClaw';
6+
import type {
7+
FileWriteResponse,
8+
OpenclawFileWriteValidation,
9+
} from '@/lib/kiloclaw/kiloclaw-internal-client';
610
import { FileEditorShell } from './FileEditorShell';
711
import { FileEditorPane, type FileSaveError } from './FileEditorPane';
812
import { validateOpenclawJsonForSave } from './validateOpenclawJson';
@@ -14,19 +18,26 @@ function UserFileEditorPane({
1418
enabled,
1519
mutations,
1620
onDirtyChange,
21+
enableOpenclawValidation,
1722
}: {
1823
filePath: string;
1924
enabled: boolean;
2025
mutations: ClawMutations;
2126
onDirtyChange: (dirty: boolean) => void;
27+
enableOpenclawValidation: boolean;
2228
}) {
2329
const { data, isLoading, error, refetch } = useClawReadFile(filePath, enabled);
2430

2531
const handleSave = useCallback(
2632
(
27-
args: { path: string; content: string; etag?: string },
33+
args: {
34+
path: string;
35+
content: string;
36+
etag?: string;
37+
openclawValidation?: OpenclawFileWriteValidation;
38+
},
2839
callbacks: {
29-
onSuccess: (result: { etag: string }) => void;
40+
onSuccess: (result: FileWriteResponse) => void;
3041
onError: (err: FileSaveError) => void;
3142
}
3243
) => {
@@ -47,6 +58,7 @@ function UserFileEditorPane({
4758
isSaving={mutations.writeFile.isPending}
4859
onDirtyChange={onDirtyChange}
4960
validateBeforeSave={validateOpenclawJsonForSave}
61+
enableOpenclawValidation={enableOpenclawValidation}
5062
/>
5163
);
5264
}
@@ -55,10 +67,12 @@ export function WorkspaceFileEditor({
5567
enabled,
5668
mutations,
5769
onOpenChange,
70+
enableOpenclawValidation,
5871
}: {
5972
enabled: boolean;
6073
mutations: ClawMutations;
6174
onOpenChange: (open: boolean) => void;
75+
enableOpenclawValidation: boolean;
6276
}) {
6377
const { data: tree, isLoading, error, refetch } = useClawFileTree(enabled);
6478

@@ -76,6 +90,7 @@ export function WorkspaceFileEditor({
7690
enabled={enabled}
7791
mutations={mutations}
7892
onDirtyChange={onDirtyChange}
93+
enableOpenclawValidation={enableOpenclawValidation}
7994
/>
8095
)}
8196
/>

apps/web/src/app/(app)/claw/components/changelog-data.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ export type ChangelogEntry = {
1010

1111
// Newest entries first. Developers add new entries to the top of this array.
1212
export const CHANGELOG_ENTRIES: ChangelogEntry[] = [
13+
{
14+
date: '2026-05-27',
15+
description:
16+
'Saving openclaw.json from the file explorer in Settings now runs OpenClaw config validation first. If validation fails, your edits are preserved so you can review the warning before choosing Save anyway.',
17+
category: 'feature',
18+
deployHint: 'upgrade_required',
19+
},
1320
{
1421
date: '2026-05-20',
1522
description:

0 commit comments

Comments
 (0)