Skip to content

Commit 5d9f36e

Browse files
authored
feat: add raw openclaw config editor (#828)
2 parents 38f4287 + 8a38b32 commit 5d9f36e

25 files changed

Lines changed: 2166 additions & 61 deletions

kiloclaw/DEVELOPMENT_LOCAL.md

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,18 +211,19 @@ Both are included in `vercel env pull` (see root `DEVELOPMENT.md`):
211211

212212
Provisioning requires a Docker image in the Fly registry. For initial setup,
213213
existing images from a team member are usually sufficient. Run `push-dev.sh`
214-
only when changing the Docker image or OpenClaw startup behavior.
214+
when changing the Docker image, OpenClaw startup behavior, or the Node
215+
controller (e.g., adding new `/_kilo/` routes).
215216

216217
### Docker authentication
217218

218219
```bash
219-
# One-time setup
220+
# Run before each push — the token expires after 5 minutes
220221
fly auth docker
221222
```
222223

223-
The auth token from `fly auth docker` expires after 5 minutes. If the push
224-
takes longer (e.g., due to low upload bandwidth), Fly returns an error saying
225-
it "doesn't recognize the app." Workarounds:
224+
If the push takes longer than 5 minutes (e.g., due to low upload bandwidth),
225+
the token expires mid-push and Fly returns an error saying it "doesn't
226+
recognize the app." Workarounds:
226227

227228
- Push from a machine with decent upload speed
228229
- Use an org token directly instead of `fly auth docker`
@@ -243,9 +244,32 @@ This will:
243244
This must match `FLY_REGISTRY_APP` or new instances won't find the image.
244245
3. Auto-update `FLY_IMAGE_TAG`, `FLY_IMAGE_DIGEST`, and `OPENCLAW_VERSION` in `.dev.vars`
245246

247+
Each push creates a unique tag (`dev-<timestamp>`) and only updates your local
248+
`.dev.vars`. Other developers' machines are unaffected — they keep running
249+
whatever `FLY_IMAGE_TAG` is in their own `.dev.vars`.
250+
246251
The image is large, so pushes are slow. After pushing, restart the worker
247-
(`pnpm run dev`) to pick up the new values, then destroy and re-provision your
248-
instance from the dashboard.
252+
(`pnpm run dev`) to pick up the new values, then restart your instance from the
253+
dashboard. A restart is sufficient to pick up the new image — you only need to
254+
destroy and re-provision if the volume or Fly app config changed.
255+
256+
### When do I need to push a new image?
257+
258+
The Docker image bundles the **Node controller** (`controller/src/`) and
259+
**OpenClaw**. The KiloClaw **worker** (`src/`) runs on Cloudflare and does NOT
260+
require an image push — `pnpm run dev` picks up worker changes immediately.
261+
262+
Push a new image when you change:
263+
264+
- Controller routes or logic (`controller/src/`)
265+
- The Dockerfile or startup scripts
266+
- OpenClaw version (pinned in the Dockerfile)
267+
268+
**Symptom of a stale controller image:** the worker calls a new `/_kilo/` route
269+
that exists in your local controller code but not in the deployed image. The
270+
request falls through to the proxy, which returns a bare `401 Unauthorized`
271+
instead of the expected `controller_route_unavailable` code. This surfaces as a
272+
`GatewayControllerError: Unauthorized` in the worker logs.
249273

250274
## Provisioning and Using an Instance
251275

kiloclaw/controller/bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

kiloclaw/controller/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"private": true,
44
"type": "module",
55
"dependencies": {
6-
"hono": "4.12.2"
6+
"hono": "4.12.2",
7+
"zod": "4.3.6"
78
},
89
"devDependencies": {
910
"@types/node": "22.0.0"
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { atomicWrite, type AtomicWriteDeps } from './atomic-write.js';
3+
4+
function makeDeps(overrides: Partial<AtomicWriteDeps> = {}): AtomicWriteDeps {
5+
return {
6+
writeFileSync: vi.fn(),
7+
renameSync: vi.fn(),
8+
unlinkSync: vi.fn(),
9+
...overrides,
10+
};
11+
}
12+
13+
describe('atomicWrite', () => {
14+
it('writes to a temp file then renames into place', () => {
15+
const deps = makeDeps();
16+
atomicWrite('/config/openclaw.json', '{"ok":true}', deps);
17+
18+
expect(deps.writeFileSync).toHaveBeenCalledOnce();
19+
expect(deps.renameSync).toHaveBeenCalledOnce();
20+
21+
// The temp file should be in the same directory with a .kilotmp suffix
22+
const tmpPath = (deps.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
23+
expect(tmpPath).toMatch(/^\/config\/\.openclaw\.json\.kilotmp\.[0-9a-f]+$/);
24+
expect((deps.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1]).toBe('{"ok":true}');
25+
26+
// Rename should move the temp file to the final path
27+
expect(deps.renameSync).toHaveBeenCalledWith(tmpPath, '/config/openclaw.json');
28+
29+
// No cleanup needed on success
30+
expect(deps.unlinkSync).not.toHaveBeenCalled();
31+
});
32+
33+
it('does not call rename when write fails, and cleans up temp file', () => {
34+
const writeError = new Error('disk full');
35+
const deps = makeDeps({
36+
writeFileSync: vi.fn().mockImplementation(() => {
37+
throw writeError;
38+
}),
39+
});
40+
41+
expect(() => atomicWrite('/config/openclaw.json', 'data', deps)).toThrow(writeError);
42+
43+
expect(deps.renameSync).not.toHaveBeenCalled();
44+
expect(deps.unlinkSync).toHaveBeenCalledOnce();
45+
});
46+
47+
it('unlinks temp file and rethrows when rename fails', () => {
48+
const renameError = new Error('rename failed');
49+
const deps = makeDeps({
50+
renameSync: vi.fn().mockImplementation(() => {
51+
throw renameError;
52+
}),
53+
});
54+
55+
expect(() => atomicWrite('/config/openclaw.json', 'data', deps)).toThrow(renameError);
56+
57+
// Write succeeded, so temp file was created — should be cleaned up
58+
const tmpPath = (deps.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
59+
expect(deps.unlinkSync).toHaveBeenCalledWith(tmpPath);
60+
});
61+
62+
it('rethrows the original error when cleanup also fails', () => {
63+
const renameError = new Error('rename failed');
64+
const unlinkError = new Error('unlink failed');
65+
const deps = makeDeps({
66+
renameSync: vi.fn().mockImplementation(() => {
67+
throw renameError;
68+
}),
69+
unlinkSync: vi.fn().mockImplementation(() => {
70+
throw unlinkError;
71+
}),
72+
});
73+
74+
// Should throw the original rename error, not the unlink error
75+
expect(() => atomicWrite('/config/openclaw.json', 'data', deps)).toThrow(renameError);
76+
});
77+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Atomic file write: writes to a temp file then renames into place.
3+
* Ensures a crash mid-write cannot leave a corrupted target file.
4+
* Cleans up the temp file on failure.
5+
*/
6+
import crypto from 'node:crypto';
7+
import fs from 'node:fs';
8+
import path from 'node:path';
9+
10+
export type AtomicWriteDeps = {
11+
writeFileSync: (path: string, data: string) => void;
12+
renameSync: (oldPath: string, newPath: string) => void;
13+
unlinkSync: (path: string) => void;
14+
};
15+
16+
const defaultDeps: AtomicWriteDeps = {
17+
writeFileSync: (p, data) => fs.writeFileSync(p, data),
18+
renameSync: (oldPath, newPath) => fs.renameSync(oldPath, newPath),
19+
unlinkSync: p => fs.unlinkSync(p),
20+
};
21+
22+
/**
23+
* Atomically write `data` to `filePath` by writing to a temp file first,
24+
* then renaming into place. The temp file is cleaned up on failure.
25+
*/
26+
export function atomicWrite(
27+
filePath: string,
28+
data: string,
29+
deps: AtomicWriteDeps = defaultDeps
30+
): void {
31+
const dir = path.dirname(filePath);
32+
const base = path.basename(filePath);
33+
const tmpPath = path.join(dir, `.${base}.kilotmp.${crypto.randomBytes(6).toString('hex')}`);
34+
35+
try {
36+
deps.writeFileSync(tmpPath, data);
37+
deps.renameSync(tmpPath, filePath);
38+
} catch (error) {
39+
// Clean up the temp file so we don't leak partial writes
40+
try {
41+
deps.unlinkSync(tmpPath);
42+
} catch {
43+
// Best-effort cleanup — the dotfile prefix keeps it hidden at least
44+
}
45+
throw error;
46+
}
47+
}

kiloclaw/controller/src/config-writer.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect, vi } from 'vitest';
2-
import { generateBaseConfig, writeBaseConfig, MAX_CONFIG_BACKUPS } from './config-writer';
2+
import {
3+
backupConfigFile,
4+
generateBaseConfig,
5+
writeBaseConfig,
6+
MAX_CONFIG_BACKUPS,
7+
} from './config-writer';
38

49
/** Minimal config that `openclaw onboard` would produce. */
510
const ONBOARD_CONFIG = JSON.stringify({
@@ -33,6 +38,7 @@ function fakeDeps(existingConfig?: string) {
3338
}),
3439
copyFileSync: vi.fn((src: string, dest: string) => {
3540
copied.push({ src, dest });
41+
dirEntries = [...dirEntries, dest.split('/').pop() ?? dest];
3642
}),
3743
readdirSync: vi.fn(() => dirEntries),
3844
unlinkSync: vi.fn((filePath: string) => {
@@ -320,6 +326,80 @@ describe('generateBaseConfig', () => {
320326

321327
expect(config.gateway.auth).toBeUndefined();
322328
});
329+
330+
it('does not set allowInsecureAuth when AUTO_APPROVE_DEVICES is not true', () => {
331+
const { deps } = fakeDeps();
332+
const env = { ...minimalEnv() };
333+
delete env.AUTO_APPROVE_DEVICES;
334+
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
335+
336+
expect(config.gateway.controlUi?.allowInsecureAuth).toBeUndefined();
337+
});
338+
339+
it('does not set allowInsecureAuth when AUTO_APPROVE_DEVICES is false', () => {
340+
const { deps } = fakeDeps();
341+
const env = { ...minimalEnv(), AUTO_APPROVE_DEVICES: 'false' };
342+
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
343+
344+
expect(config.gateway.controlUi?.allowInsecureAuth).toBeUndefined();
345+
});
346+
347+
it('configures Telegram allowFrom from explicit comma-separated list', () => {
348+
const { deps } = fakeDeps();
349+
const env = {
350+
...minimalEnv(),
351+
TELEGRAM_BOT_TOKEN: 'tg-token',
352+
TELEGRAM_DM_ALLOW_FROM: 'user1,user2',
353+
};
354+
const config = generateBaseConfig(env, '/tmp/openclaw.json', deps);
355+
356+
expect(config.channels.telegram.allowFrom).toEqual(['user1', 'user2']);
357+
expect(config.channels.telegram.dmPolicy).toBe('pairing');
358+
});
359+
});
360+
361+
describe('backupConfigFile', () => {
362+
it('backs up existing config with timestamp', () => {
363+
const existing = JSON.stringify({ old: true });
364+
const { deps, copied } = fakeDeps(existing);
365+
366+
backupConfigFile('/tmp/openclaw.json', deps);
367+
368+
expect(copied).toHaveLength(1);
369+
expect(copied[0].src).toBe('/tmp/openclaw.json');
370+
expect(copied[0].dest).toMatch(/\/tmp\/openclaw\.json\.bak\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-/);
371+
});
372+
373+
it('prunes old backups beyond MAX_CONFIG_BACKUPS', () => {
374+
const existing = JSON.stringify({ old: true });
375+
const harness = fakeDeps(existing);
376+
harness.setDirEntries([
377+
'openclaw.json.bak.2026-02-20T10-00-00.000Z',
378+
'openclaw.json.bak.2026-02-21T10-00-00.000Z',
379+
'openclaw.json.bak.2026-02-22T10-00-00.000Z',
380+
'openclaw.json.bak.2026-02-23T10-00-00.000Z',
381+
'openclaw.json.bak.2026-02-24T10-00-00.000Z',
382+
'openclaw.json.bak.2026-02-25T10-00-00.000Z',
383+
'openclaw.json.bak.2026-02-26T10-00-00.000Z',
384+
]);
385+
386+
backupConfigFile('/tmp/openclaw.json', harness.deps);
387+
388+
expect(harness.unlinked).toHaveLength(8 - MAX_CONFIG_BACKUPS);
389+
expect(harness.unlinked[0]).toBe('/tmp/openclaw.json.bak.2026-02-20T10-00-00.000Z');
390+
expect(harness.unlinked[1]).toBe('/tmp/openclaw.json.bak.2026-02-21T10-00-00.000Z');
391+
});
392+
393+
it('continues if backup pruning fails', () => {
394+
const existing = JSON.stringify({ old: true });
395+
const harness = fakeDeps(existing);
396+
harness.deps.readdirSync.mockImplementation(() => {
397+
throw new Error('permission denied');
398+
});
399+
400+
expect(() => backupConfigFile('/tmp/openclaw.json', harness.deps)).not.toThrow();
401+
expect(harness.copied).toHaveLength(1);
402+
});
323403
});
324404

325405
describe('writeBaseConfig', () => {
@@ -413,7 +493,7 @@ describe('writeBaseConfig', () => {
413493

414494
writeBaseConfig(minimalEnv(), '/tmp/openclaw.json', harness.deps);
415495

416-
expect(harness.unlinked).toHaveLength(7 - MAX_CONFIG_BACKUPS);
496+
expect(harness.unlinked).toHaveLength(8 - MAX_CONFIG_BACKUPS);
417497
expect(harness.unlinked[0]).toBe('/tmp/openclaw.json.bak.2026-02-20T10-00-00.000Z');
418498
expect(harness.unlinked[1]).toBe('/tmp/openclaw.json.bak.2026-02-21T10-00-00.000Z');
419499
});

0 commit comments

Comments
 (0)