Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .specs/kiloclaw-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,19 @@ running. When forwarding, it MUST authenticate to gog with
| GET | `/_kilo/files/read` | Read a safe file path |
| POST | `/_kilo/files/import-openclaw-workspace` | Import OpenClaw workspace files |
| POST | `/_kilo/files/write` | Write a safe file path |
| POST | `/_kilo/files/write-openclaw-config` | Validate and write `openclaw.json`, with explicit invalid override support |

##### Validation-aware `openclaw.json` file writes

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.
2. For a normal validation-aware save, the controller MUST evaluate the submitted candidate using the installed OpenClaw config-validation behavior before committing it.
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.
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.
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.
6. Validation-aware writes MUST remain usable when the gateway process is unavailable after controller routes are registered.
7. Controllers implementing this behavior MUST advertise `files.write-openclaw-config`; clients MUST NOT infer this behavior solely from controller CalVer.
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.
9. This validation reports OpenClaw configuration validity. It MUST NOT be represented as proof that runtime SecretRefs or optional environment substitutions resolve successfully.

#### Doctor (bearer token)

Expand Down
152 changes: 121 additions & 31 deletions apps/web/src/app/(app)/claw/components/FileEditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type {
FileWriteResponse,
OpenclawFileWriteValidation,
} from '@/lib/kiloclaw/kiloclaw-internal-client';

const Editor = lazy<React.ComponentType<EditorProps>>(() => import('@monaco-editor/react'));
const DiffEditor = lazy<React.ComponentType<DiffEditorProps>>(() =>
Expand Down Expand Up @@ -61,15 +75,21 @@ export interface FileEditorPaneProps {
error: { message: string } | null;
refetch: () => void;
onSave: (
args: { path: string; content: string; etag?: string },
args: {
path: string;
content: string;
etag?: string;
openclawValidation?: OpenclawFileWriteValidation;
},
callbacks: {
onSuccess: (result: { etag: string }) => void;
onSuccess: (result: FileWriteResponse) => void;
onError: (err: FileSaveError) => void;
}
) => void;
isSaving: boolean;
onDirtyChange?: (dirty: boolean) => void;
validateBeforeSave?: (filePath: string, content: string) => boolean;
enableOpenclawValidation?: boolean;
}

export function FileEditorPane({
Expand All @@ -82,8 +102,13 @@ export function FileEditorPane({
isSaving,
onDirtyChange,
validateBeforeSave,
enableOpenclawValidation = false,
}: FileEditorPaneProps) {
const [showDiff, setShowDiff] = useState(false);
const [pendingValidationWarning, setPendingValidationWarning] = useState<
| (Extract<FileWriteResponse, { outcome: 'openclaw-validation-warning' }> & { content: string })
| null
>(null);

// savedContentRef holds the last successfully saved content, used as fallback
// until the query refetches to avoid flashing stale content after save.
Expand All @@ -98,6 +123,7 @@ export function FileEditorPane({
prevFilePathRef.current = filePath;
setEditedContent(null);
setShowDiff(false);
setPendingValidationWarning(null);
etagRef.current = undefined;
savedContentRef.current = null;
}
Expand Down Expand Up @@ -132,6 +158,40 @@ export function FileEditorPane({
[serverContent]
);

const submitSave = useCallback(
(content: string, openclawValidation?: OpenclawFileWriteValidation) => {
onSave(
{ path: filePath, content, etag: etagRef.current, openclawValidation },
{
onSuccess: result => {
if ('outcome' in result) {
setPendingValidationWarning({ ...result, content });
return;
}
etagRef.current = result.etag;
savedContentRef.current = content;
setEditedContent(null);
setPendingValidationWarning(null);
toast.success(`Saved ${filePath}`);
},
onError: err => {
if (err.data?.code === 'CONFLICT' && err.data?.upstreamCode === 'file_etag_conflict') {
setPendingValidationWarning(null);
refetch();
setShowDiff(true);
toast.error(
'File was modified externally — your edits are preserved, review the diff'
);
} else {
toast.error(err.message);
}
},
}
);
},
[filePath, onSave, refetch]
);

if (isLoading) {
return <EditorLoading />;
}
Expand Down Expand Up @@ -198,7 +258,7 @@ export function FileEditorPane({
value={currentValue}
onChange={handleEditorChange}
theme="vs-dark"
options={EDITOR_OPTIONS}
options={{ ...EDITOR_OPTIONS, readOnly: isSaving }}
keepCurrentModel
/>
)}
Expand Down Expand Up @@ -226,34 +286,11 @@ export function FileEditorPane({
if (validateBeforeSave && !validateBeforeSave(filePath, currentValue)) {
return;
}
onSave(
{ path: filePath, content: currentValue, etag: etagRef.current },
{
onSuccess: result => {
etagRef.current = result.etag;
// Optimistically update serverContent so we don't flash stale content
// while the invalidated readFile query refetches.
savedContentRef.current = currentValue;
setEditedContent(null);
toast.success(`Saved ${filePath}`);
},
onError: err => {
if (
err.data?.code === 'CONFLICT' &&
err.data?.upstreamCode === 'file_etag_conflict'
) {
// Preserve the user's edits and show diff so they can compare
// their version against the server's updated content.
refetch();
setShowDiff(true);
toast.error(
'File was modified externally — your edits are preserved, review the diff'
);
} else {
toast.error(err.message);
}
},
}
submitSave(
currentValue,
enableOpenclawValidation && filePath === 'openclaw.json'
? 'warn-before-write'
: undefined
);
}}
>
Expand All @@ -268,6 +305,59 @@ export function FileEditorPane({
</Button>
</div>
</div>

<AlertDialog
open={pendingValidationWarning !== null}
onOpenChange={open => {
if (!open && !isSaving) setPendingValidationWarning(null);
}}
>
<AlertDialogContent className="sm:max-w-xl">
<AlertDialogHeader>
<AlertDialogTitle>
{pendingValidationWarning?.reason === 'invalid'
? 'OpenClaw configuration is invalid'
: 'Configuration validation could not run'}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingValidationWarning?.reason === 'invalid'
? 'OpenClaw may reject this file or restore the previous configuration during reload or startup.'
: 'Save without validation only if you understand that OpenClaw may reject or restore this file.'}
</AlertDialogDescription>
</AlertDialogHeader>
{pendingValidationWarning && (
<div className="bg-muted max-h-48 overflow-y-auto rounded-md border p-3 text-xs">
<ul className="space-y-2">
{pendingValidationWarning.issues.map((issue, index) => (
<li key={`${issue.path}:${index}`} className="space-y-0.5">
{issue.path && <div className="font-mono text-destructive">{issue.path}</div>}
<div className="text-muted-foreground">{issue.message}</div>
{issue.allowedValues && issue.allowedValues.length > 0 && (
<div className="text-muted-foreground font-mono">
Allowed values: {issue.allowedValues.join(', ')}
</div>
)}
</li>
))}
</ul>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isSaving}>Keep editing</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isSaving || !pendingValidationWarning}
onClick={() => {
if (pendingValidationWarning) {
submitSave(pendingValidationWarning.content, 'allow-invalid');
}
}}
>
{isSaving ? 'Saving...' : 'Save anyway'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/app/(app)/claw/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,8 @@ export function SettingsTab({
cleanVersion(controllerVersion?.version),
OPENCLAW_IMPORT_UI_MIN_CONTROLLER_VERSION
);
const supportsOpenclawSaveValidation =
controllerVersion?.capabilities?.includes('files.write-openclaw-config') === true;
// Fail OPEN: hide the interests editor only when the controller
// version is positively parsed as too old, OR the worker reports an
// explicit `version: null` (its positive old-controller signal for a
Expand Down Expand Up @@ -2605,6 +2607,7 @@ export function SettingsTab({
enabled={isRunning}
mutations={mutations}
onOpenChange={setEditConfigOpen}
enableOpenclawValidation={supportsOpenclawSaveValidation}
/>
</div>
)}
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/app/(app)/claw/components/WorkspaceFileEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import { useCallback } from 'react';
import { useClawFileTree, useClawReadFile } from '../hooks/useClawHooks';
import type { useKiloClawMutations } from '@/hooks/useKiloClaw';
import type {
FileWriteResponse,
OpenclawFileWriteValidation,
} from '@/lib/kiloclaw/kiloclaw-internal-client';
import { FileEditorShell } from './FileEditorShell';
import { FileEditorPane, type FileSaveError } from './FileEditorPane';
import { validateOpenclawJsonForSave } from './validateOpenclawJson';
Expand All @@ -14,19 +18,26 @@ function UserFileEditorPane({
enabled,
mutations,
onDirtyChange,
enableOpenclawValidation,
}: {
filePath: string;
enabled: boolean;
mutations: ClawMutations;
onDirtyChange: (dirty: boolean) => void;
enableOpenclawValidation: boolean;
}) {
const { data, isLoading, error, refetch } = useClawReadFile(filePath, enabled);

const handleSave = useCallback(
(
args: { path: string; content: string; etag?: string },
args: {
path: string;
content: string;
etag?: string;
openclawValidation?: OpenclawFileWriteValidation;
},
callbacks: {
onSuccess: (result: { etag: string }) => void;
onSuccess: (result: FileWriteResponse) => void;
onError: (err: FileSaveError) => void;
}
) => {
Expand All @@ -47,6 +58,7 @@ function UserFileEditorPane({
isSaving={mutations.writeFile.isPending}
onDirtyChange={onDirtyChange}
validateBeforeSave={validateOpenclawJsonForSave}
enableOpenclawValidation={enableOpenclawValidation}
/>
);
}
Expand All @@ -55,10 +67,12 @@ export function WorkspaceFileEditor({
enabled,
mutations,
onOpenChange,
enableOpenclawValidation,
}: {
enabled: boolean;
mutations: ClawMutations;
onOpenChange: (open: boolean) => void;
enableOpenclawValidation: boolean;
}) {
const { data: tree, isLoading, error, refetch } = useClawFileTree(enabled);

Expand All @@ -76,6 +90,7 @@ export function WorkspaceFileEditor({
enabled={enabled}
mutations={mutations}
onDirtyChange={onDirtyChange}
enableOpenclawValidation={enableOpenclawValidation}
/>
)}
/>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/(app)/claw/components/changelog-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export type ChangelogEntry = {

// Newest entries first. Developers add new entries to the top of this array.
export const CHANGELOG_ENTRIES: ChangelogEntry[] = [
{
date: '2026-05-27',
description:
'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.',
category: 'feature',
deployHint: 'upgrade_required',
},
{
date: '2026-05-20',
description:
Expand Down
Loading
Loading