diff --git a/kiloclaw/DEVELOPMENT_LOCAL.md b/kiloclaw/DEVELOPMENT_LOCAL.md index 704225103c..9d3f6f20aa 100644 --- a/kiloclaw/DEVELOPMENT_LOCAL.md +++ b/kiloclaw/DEVELOPMENT_LOCAL.md @@ -211,18 +211,19 @@ Both are included in `vercel env pull` (see root `DEVELOPMENT.md`): Provisioning requires a Docker image in the Fly registry. For initial setup, existing images from a team member are usually sufficient. Run `push-dev.sh` -only when changing the Docker image or OpenClaw startup behavior. +when changing the Docker image, OpenClaw startup behavior, or the Node +controller (e.g., adding new `/_kilo/` routes). ### Docker authentication ```bash -# One-time setup +# Run before each push — the token expires after 5 minutes fly auth docker ``` -The auth token from `fly auth docker` expires after 5 minutes. If the push -takes longer (e.g., due to low upload bandwidth), Fly returns an error saying -it "doesn't recognize the app." Workarounds: +If the push takes longer than 5 minutes (e.g., due to low upload bandwidth), +the token expires mid-push and Fly returns an error saying it "doesn't +recognize the app." Workarounds: - Push from a machine with decent upload speed - Use an org token directly instead of `fly auth docker` @@ -243,9 +244,32 @@ This will: This must match `FLY_REGISTRY_APP` or new instances won't find the image. 3. Auto-update `FLY_IMAGE_TAG`, `FLY_IMAGE_DIGEST`, and `OPENCLAW_VERSION` in `.dev.vars` +Each push creates a unique tag (`dev-`) and only updates your local +`.dev.vars`. Other developers' machines are unaffected — they keep running +whatever `FLY_IMAGE_TAG` is in their own `.dev.vars`. + The image is large, so pushes are slow. After pushing, restart the worker -(`pnpm run dev`) to pick up the new values, then destroy and re-provision your -instance from the dashboard. +(`pnpm run dev`) to pick up the new values, then restart your instance from the +dashboard. A restart is sufficient to pick up the new image — you only need to +destroy and re-provision if the volume or Fly app config changed. + +### When do I need to push a new image? + +The Docker image bundles the **Node controller** (`controller/src/`) and +**OpenClaw**. The KiloClaw **worker** (`src/`) runs on Cloudflare and does NOT +require an image push — `pnpm run dev` picks up worker changes immediately. + +Push a new image when you change: + +- Controller routes or logic (`controller/src/`) +- The Dockerfile or startup scripts +- OpenClaw version (pinned in the Dockerfile) + +**Symptom of a stale controller image:** the worker calls a new `/_kilo/` route +that exists in your local controller code but not in the deployed image. The +request falls through to the proxy, which returns a bare `401 Unauthorized` +instead of the expected `controller_route_unavailable` code. This surfaces as a +`GatewayControllerError: Unauthorized` in the worker logs. ## Provisioning and Using an Instance diff --git a/kiloclaw/controller/bun.lock b/kiloclaw/controller/bun.lock index c547d3ba66..473df8cf16 100644 --- a/kiloclaw/controller/bun.lock +++ b/kiloclaw/controller/bun.lock @@ -6,6 +6,7 @@ "name": "kiloclaw-controller", "dependencies": { "hono": "4.12.2", + "zod": "4.3.6", }, "devDependencies": { "@types/node": "22.0.0", @@ -18,5 +19,7 @@ "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], "undici-types": ["undici-types@6.11.1", "", {}, "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], } } diff --git a/kiloclaw/controller/package.json b/kiloclaw/controller/package.json index 9135fc00f3..5769111cf2 100644 --- a/kiloclaw/controller/package.json +++ b/kiloclaw/controller/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "dependencies": { - "hono": "4.12.2" + "hono": "4.12.2", + "zod": "4.3.6" }, "devDependencies": { "@types/node": "22.0.0" diff --git a/kiloclaw/controller/src/atomic-write.test.ts b/kiloclaw/controller/src/atomic-write.test.ts new file mode 100644 index 0000000000..bd2111afbe --- /dev/null +++ b/kiloclaw/controller/src/atomic-write.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import { atomicWrite, type AtomicWriteDeps } from './atomic-write.js'; + +function makeDeps(overrides: Partial = {}): AtomicWriteDeps { + return { + writeFileSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + ...overrides, + }; +} + +describe('atomicWrite', () => { + it('writes to a temp file then renames into place', () => { + const deps = makeDeps(); + atomicWrite('/config/openclaw.json', '{"ok":true}', deps); + + expect(deps.writeFileSync).toHaveBeenCalledOnce(); + expect(deps.renameSync).toHaveBeenCalledOnce(); + + // The temp file should be in the same directory with a .kilotmp suffix + const tmpPath = (deps.writeFileSync as ReturnType).mock.calls[0][0] as string; + expect(tmpPath).toMatch(/^\/config\/\.openclaw\.json\.kilotmp\.[0-9a-f]+$/); + expect((deps.writeFileSync as ReturnType).mock.calls[0][1]).toBe('{"ok":true}'); + + // Rename should move the temp file to the final path + expect(deps.renameSync).toHaveBeenCalledWith(tmpPath, '/config/openclaw.json'); + + // No cleanup needed on success + expect(deps.unlinkSync).not.toHaveBeenCalled(); + }); + + it('does not call rename when write fails, and cleans up temp file', () => { + const writeError = new Error('disk full'); + const deps = makeDeps({ + writeFileSync: vi.fn().mockImplementation(() => { + throw writeError; + }), + }); + + expect(() => atomicWrite('/config/openclaw.json', 'data', deps)).toThrow(writeError); + + expect(deps.renameSync).not.toHaveBeenCalled(); + expect(deps.unlinkSync).toHaveBeenCalledOnce(); + }); + + it('unlinks temp file and rethrows when rename fails', () => { + const renameError = new Error('rename failed'); + const deps = makeDeps({ + renameSync: vi.fn().mockImplementation(() => { + throw renameError; + }), + }); + + expect(() => atomicWrite('/config/openclaw.json', 'data', deps)).toThrow(renameError); + + // Write succeeded, so temp file was created — should be cleaned up + const tmpPath = (deps.writeFileSync as ReturnType).mock.calls[0][0] as string; + expect(deps.unlinkSync).toHaveBeenCalledWith(tmpPath); + }); + + it('rethrows the original error when cleanup also fails', () => { + const renameError = new Error('rename failed'); + const unlinkError = new Error('unlink failed'); + const deps = makeDeps({ + renameSync: vi.fn().mockImplementation(() => { + throw renameError; + }), + unlinkSync: vi.fn().mockImplementation(() => { + throw unlinkError; + }), + }); + + // Should throw the original rename error, not the unlink error + expect(() => atomicWrite('/config/openclaw.json', 'data', deps)).toThrow(renameError); + }); +}); diff --git a/kiloclaw/controller/src/atomic-write.ts b/kiloclaw/controller/src/atomic-write.ts new file mode 100644 index 0000000000..8e0f61630e --- /dev/null +++ b/kiloclaw/controller/src/atomic-write.ts @@ -0,0 +1,47 @@ +/** + * Atomic file write: writes to a temp file then renames into place. + * Ensures a crash mid-write cannot leave a corrupted target file. + * Cleans up the temp file on failure. + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export type AtomicWriteDeps = { + writeFileSync: (path: string, data: string) => void; + renameSync: (oldPath: string, newPath: string) => void; + unlinkSync: (path: string) => void; +}; + +const defaultDeps: AtomicWriteDeps = { + writeFileSync: (p, data) => fs.writeFileSync(p, data), + renameSync: (oldPath, newPath) => fs.renameSync(oldPath, newPath), + unlinkSync: p => fs.unlinkSync(p), +}; + +/** + * Atomically write `data` to `filePath` by writing to a temp file first, + * then renaming into place. The temp file is cleaned up on failure. + */ +export function atomicWrite( + filePath: string, + data: string, + deps: AtomicWriteDeps = defaultDeps +): void { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + const tmpPath = path.join(dir, `.${base}.kilotmp.${crypto.randomBytes(6).toString('hex')}`); + + try { + deps.writeFileSync(tmpPath, data); + deps.renameSync(tmpPath, filePath); + } catch (error) { + // Clean up the temp file so we don't leak partial writes + try { + deps.unlinkSync(tmpPath); + } catch { + // Best-effort cleanup — the dotfile prefix keeps it hidden at least + } + throw error; + } +} diff --git a/kiloclaw/controller/src/config-writer.test.ts b/kiloclaw/controller/src/config-writer.test.ts index c938634296..66b2e412bd 100644 --- a/kiloclaw/controller/src/config-writer.test.ts +++ b/kiloclaw/controller/src/config-writer.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; -import { generateBaseConfig, writeBaseConfig, MAX_CONFIG_BACKUPS } from './config-writer'; +import { + backupConfigFile, + generateBaseConfig, + writeBaseConfig, + MAX_CONFIG_BACKUPS, +} from './config-writer'; /** Minimal config that `openclaw onboard` would produce. */ const ONBOARD_CONFIG = JSON.stringify({ @@ -33,6 +38,7 @@ function fakeDeps(existingConfig?: string) { }), copyFileSync: vi.fn((src: string, dest: string) => { copied.push({ src, dest }); + dirEntries = [...dirEntries, dest.split('/').pop() ?? dest]; }), readdirSync: vi.fn(() => dirEntries), unlinkSync: vi.fn((filePath: string) => { @@ -320,6 +326,80 @@ describe('generateBaseConfig', () => { expect(config.gateway.auth).toBeUndefined(); }); + + it('does not set allowInsecureAuth when AUTO_APPROVE_DEVICES is not true', () => { + const { deps } = fakeDeps(); + const env = { ...minimalEnv() }; + delete env.AUTO_APPROVE_DEVICES; + const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); + + expect(config.gateway.controlUi?.allowInsecureAuth).toBeUndefined(); + }); + + it('does not set allowInsecureAuth when AUTO_APPROVE_DEVICES is false', () => { + const { deps } = fakeDeps(); + const env = { ...minimalEnv(), AUTO_APPROVE_DEVICES: 'false' }; + const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); + + expect(config.gateway.controlUi?.allowInsecureAuth).toBeUndefined(); + }); + + it('configures Telegram allowFrom from explicit comma-separated list', () => { + const { deps } = fakeDeps(); + const env = { + ...minimalEnv(), + TELEGRAM_BOT_TOKEN: 'tg-token', + TELEGRAM_DM_ALLOW_FROM: 'user1,user2', + }; + const config = generateBaseConfig(env, '/tmp/openclaw.json', deps); + + expect(config.channels.telegram.allowFrom).toEqual(['user1', 'user2']); + expect(config.channels.telegram.dmPolicy).toBe('pairing'); + }); +}); + +describe('backupConfigFile', () => { + it('backs up existing config with timestamp', () => { + const existing = JSON.stringify({ old: true }); + const { deps, copied } = fakeDeps(existing); + + backupConfigFile('/tmp/openclaw.json', deps); + + expect(copied).toHaveLength(1); + expect(copied[0].src).toBe('/tmp/openclaw.json'); + expect(copied[0].dest).toMatch(/\/tmp\/openclaw\.json\.bak\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-/); + }); + + it('prunes old backups beyond MAX_CONFIG_BACKUPS', () => { + const existing = JSON.stringify({ old: true }); + const harness = fakeDeps(existing); + harness.setDirEntries([ + 'openclaw.json.bak.2026-02-20T10-00-00.000Z', + 'openclaw.json.bak.2026-02-21T10-00-00.000Z', + 'openclaw.json.bak.2026-02-22T10-00-00.000Z', + 'openclaw.json.bak.2026-02-23T10-00-00.000Z', + 'openclaw.json.bak.2026-02-24T10-00-00.000Z', + 'openclaw.json.bak.2026-02-25T10-00-00.000Z', + 'openclaw.json.bak.2026-02-26T10-00-00.000Z', + ]); + + backupConfigFile('/tmp/openclaw.json', harness.deps); + + expect(harness.unlinked).toHaveLength(8 - MAX_CONFIG_BACKUPS); + expect(harness.unlinked[0]).toBe('/tmp/openclaw.json.bak.2026-02-20T10-00-00.000Z'); + expect(harness.unlinked[1]).toBe('/tmp/openclaw.json.bak.2026-02-21T10-00-00.000Z'); + }); + + it('continues if backup pruning fails', () => { + const existing = JSON.stringify({ old: true }); + const harness = fakeDeps(existing); + harness.deps.readdirSync.mockImplementation(() => { + throw new Error('permission denied'); + }); + + expect(() => backupConfigFile('/tmp/openclaw.json', harness.deps)).not.toThrow(); + expect(harness.copied).toHaveLength(1); + }); }); describe('writeBaseConfig', () => { @@ -413,7 +493,7 @@ describe('writeBaseConfig', () => { writeBaseConfig(minimalEnv(), '/tmp/openclaw.json', harness.deps); - expect(harness.unlinked).toHaveLength(7 - MAX_CONFIG_BACKUPS); + expect(harness.unlinked).toHaveLength(8 - MAX_CONFIG_BACKUPS); expect(harness.unlinked[0]).toBe('/tmp/openclaw.json.bak.2026-02-20T10-00-00.000Z'); expect(harness.unlinked[1]).toBe('/tmp/openclaw.json.bak.2026-02-21T10-00-00.000Z'); }); diff --git a/kiloclaw/controller/src/config-writer.ts b/kiloclaw/controller/src/config-writer.ts index cb9183ec45..65fc89fff2 100644 --- a/kiloclaw/controller/src/config-writer.ts +++ b/kiloclaw/controller/src/config-writer.ts @@ -14,6 +14,31 @@ const DEFAULT_CONFIG_PATH = '/root/.openclaw/openclaw.json'; export const MAX_CONFIG_BACKUPS = 5; +// NOTE: writeBaseConfig does NOT use the shared atomicWrite utility because +// the temp file is created earlier by `openclaw onboard` and shared across +// multiple steps (onboard writes to it, generateBaseConfig reads from it, +// then we write the patched content and rename into place). atomicWrite +// manages its own temp file internally, so it cannot participate in this +// lifecycle. + +function pruneOldConfigBackups(dir: string, base: string, deps: ConfigWriterDeps): void { + try { + const backupPrefix = `${base}.bak.`; + const backups = deps + .readdirSync(dir) + .filter(f => f.startsWith(backupPrefix)) + .sort(); + const toRemove = backups.slice(0, -MAX_CONFIG_BACKUPS); + for (const old of toRemove) { + deps.unlinkSync(path.join(dir, old)); + console.log(`Pruned old config backup: ${old}`); + } + } catch (error) { + // Non-fatal — backup pruning failure shouldn't block config writes + console.warn('Failed to prune old config backups:', error); + } +} + /** Flags passed to `openclaw onboard`, matching start-openclaw.sh. */ const ONBOARD_FLAGS = [ 'onboard', @@ -237,6 +262,26 @@ export function generateBaseConfig( return config; } +/** + * Back up the existing config file and prune old backups. + */ +export function backupConfigFile( + configPath = DEFAULT_CONFIG_PATH, + deps: ConfigWriterDeps = defaultDeps +): void { + const dir = path.dirname(configPath); + const base = path.basename(configPath); + + if (deps.existsSync(configPath)) { + const timestamp = new Date().toISOString().replace(/:/g, '-'); + const backupPath = path.join(dir, `${base}.bak.${timestamp}`); + deps.copyFileSync(configPath, backupPath); + console.log(`Backed up existing config to ${backupPath}`); + } + + pruneOldConfigBackups(dir, base, deps); +} + /** * Generate a fresh config and write it to disk. * @@ -257,34 +302,11 @@ export function writeBaseConfig( configPath = DEFAULT_CONFIG_PATH, deps: ConfigWriterDeps = defaultDeps ): ConfigObject { + backupConfigFile(configPath, deps); + const dir = path.dirname(configPath); const base = path.basename(configPath); - // 1. Back up existing config with timestamp - if (deps.existsSync(configPath)) { - const timestamp = new Date().toISOString().replace(/:/g, '-'); - const backupPath = path.join(dir, `${base}.bak.${timestamp}`); - deps.copyFileSync(configPath, backupPath); - console.log(`Backed up existing config to ${backupPath}`); - } - - // 2. Prune old backups, keep most recent MAX_CONFIG_BACKUPS - try { - const backupPrefix = `${base}.bak.`; - const backups = deps - .readdirSync(dir) - .filter(f => f.startsWith(backupPrefix)) - .sort(); - const toRemove = backups.slice(0, -MAX_CONFIG_BACKUPS); - for (const old of toRemove) { - deps.unlinkSync(path.join(dir, old)); - console.log(`Pruned old config backup: ${old}`); - } - } catch (error) { - // Non-fatal — backup pruning failure shouldn't block config restore - console.warn('Failed to prune old config backups:', error); - } - // 3. Run `openclaw onboard` targeting a temp file so the existing (possibly // broken) config is untouched until we're ready to atomically swap in. const tmpPath = path.join(dir, `.${base}.kilotmp.${crypto.randomBytes(6).toString('hex')}`); diff --git a/kiloclaw/controller/src/proxy.test.ts b/kiloclaw/controller/src/proxy.test.ts index 8576beb652..c94fb2f892 100644 --- a/kiloclaw/controller/src/proxy.test.ts +++ b/kiloclaw/controller/src/proxy.test.ts @@ -26,9 +26,27 @@ describe('HTTP proxy', () => { const noToken = await app.request('/x'); expect(noToken.status).toBe(401); + expect(await noToken.json()).toEqual({ error: 'Unauthorized' }); + + const noTokenKilo = await app.request('/_kilo/missing'); + expect(noTokenKilo.status).toBe(401); + expect(await noTokenKilo.json()).toEqual({ + code: 'controller_route_unavailable', + error: 'Unauthorized', + }); const wrongToken = await app.request('/x', { headers: { 'x-kiloclaw-proxy-token': 'bad' } }); expect(wrongToken.status).toBe(401); + expect(await wrongToken.json()).toEqual({ error: 'Unauthorized' }); + + const wrongTokenKilo = await app.request('/_kilo/missing', { + headers: { 'x-kiloclaw-proxy-token': 'bad' }, + }); + expect(wrongTokenKilo.status).toBe(401); + expect(await wrongTokenKilo.json()).toEqual({ + code: 'controller_route_unavailable', + error: 'Unauthorized', + }); }); it('proxies with valid token and strips x-kiloclaw-proxy-token', async () => { diff --git a/kiloclaw/controller/src/proxy.ts b/kiloclaw/controller/src/proxy.ts index b84b2e181c..b9acb6897e 100644 --- a/kiloclaw/controller/src/proxy.ts +++ b/kiloclaw/controller/src/proxy.ts @@ -45,7 +45,13 @@ export function createHttpProxy(options: ProxyOptions) { return async (c: Context): Promise => { const token = c.req.header('x-kiloclaw-proxy-token'); if (!hasValidProxyToken(token, options.requireProxyToken, options.expectedToken)) { - return c.json({ error: 'Unauthorized' }, 401); + const isUnknownControllerRoute = c.req.path.startsWith('/_kilo/'); + return c.json( + isUnknownControllerRoute + ? { code: 'controller_route_unavailable', error: 'Unauthorized' } + : { error: 'Unauthorized' }, + 401 + ); } if (options.supervisor && options.supervisor.getState() !== 'running') { diff --git a/kiloclaw/controller/src/routes/config.test.ts b/kiloclaw/controller/src/routes/config.test.ts index f5499a0ac8..8fcc58cf14 100644 --- a/kiloclaw/controller/src/routes/config.test.ts +++ b/kiloclaw/controller/src/routes/config.test.ts @@ -4,24 +4,30 @@ import { registerConfigRoutes } from './config'; import type { Supervisor } from '../supervisor'; vi.mock('../config-writer', () => ({ + backupConfigFile: vi.fn(), writeBaseConfig: vi.fn(), })); -// Mock fs at the module level (for config/patch tests) +vi.mock('../atomic-write', () => ({ + atomicWrite: vi.fn(), +})); + +// Mock fs at the module level (for config/patch tests — readFileSync is still used directly) vi.mock('node:fs', () => { return { default: { readFileSync: vi.fn(), - writeFileSync: vi.fn(), }, }; }); -import { writeBaseConfig } from '../config-writer'; +import { backupConfigFile, writeBaseConfig } from '../config-writer'; +import { atomicWrite } from '../atomic-write'; import fs from 'node:fs'; const readMock = vi.mocked(fs.readFileSync); -const writeMock = vi.mocked(fs.writeFileSync); +const atomicWriteMock = vi.mocked(atomicWrite); +const backupMock = vi.mocked(backupConfigFile); function createMockSupervisor(): Supervisor { const state = 'running' as const; @@ -48,7 +54,7 @@ function authHeaders(token = 'test-token'): HeadersInit { describe('/_kilo/config/restore routes', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it('rejects requests without auth', async () => { @@ -120,6 +126,23 @@ describe('/_kilo/config/restore routes', () => { expect(body.error).toContain('disk full'); }); + it('restores config but does not signal when gateway is not running', async () => { + const app = new Hono(); + const supervisor = createMockSupervisor(); + vi.mocked(supervisor.getState).mockReturnValue('stopped'); + registerConfigRoutes(app, supervisor, 'test-token'); + + const resp = await app.request('/_kilo/config/restore/base', { + method: 'POST', + headers: authHeaders(), + }); + expect(resp.status).toBe(200); + expect(await resp.json()).toEqual({ ok: true, signaled: false }); + + expect(writeBaseConfig).toHaveBeenCalledWith(process.env); + expect(supervisor.signal).not.toHaveBeenCalled(); + }); + it('does not leak through to catch-all proxy', async () => { const app = new Hono(); const supervisor = createMockSupervisor(); @@ -134,7 +157,7 @@ describe('/_kilo/config/restore routes', () => { describe('/_kilo/config/patch routes', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); }); it('enforces bearer auth', async () => { @@ -177,8 +200,10 @@ describe('/_kilo/config/patch routes', () => { expect(resp.status).toBe(200); expect(await resp.json()).toEqual({ ok: true }); - expect(writeMock).toHaveBeenCalledOnce(); - const written = JSON.parse(writeMock.mock.calls[0][1] as string); + expect(atomicWriteMock).toHaveBeenCalledOnce(); + // First arg should be the config path, second the serialized JSON + expect(atomicWriteMock.mock.calls[0][0]).toBe('/root/.openclaw/openclaw.json'); + const written = JSON.parse(atomicWriteMock.mock.calls[0][1] as string); expect(written.agents.defaults.model.primary).toBe('kilocode/anthropic/claude-sonnet-4.5'); // Existing keys preserved expect(written.gateway.port).toBe(3001); @@ -228,8 +253,8 @@ describe('/_kilo/config/patch routes', () => { }); expect(resp.status).toBe(200); - expect(writeMock).toHaveBeenCalledOnce(); - const written = JSON.parse(writeMock.mock.calls[0][1] as string); + expect(atomicWriteMock).toHaveBeenCalledOnce(); + const written = JSON.parse(atomicWriteMock.mock.calls[0][1] as string); // Banned keys are silently dropped at every depth expect(Object.hasOwn(written, '__proto__')).toBe(false); expect(Object.hasOwn(written, 'constructor')).toBe(false); @@ -256,3 +281,368 @@ describe('/_kilo/config/patch routes', () => { expect(resp.status).toBe(500); }); }); + +type TestCase = { + route: string; + method?: string; + headers?: HeadersInit; + body?: string; + read?: () => string; + write?: () => void; + expect: { + status: number; + body?: unknown; + bodyContains?: Record; + mocks?: { + backup?: (mock: typeof backupMock) => void; + write?: (mock: typeof atomicWriteMock) => void; + }; + }; +}; + +async function test(tc: TestCase) { + const app = new Hono(); + registerConfigRoutes(app, createMockSupervisor(), 'test-token'); + + if (tc.read) { + readMock.mockImplementation(tc.read); + } + + if (tc.write) { + atomicWriteMock.mockImplementation(tc.write); + } + + const resp = await app.request(tc.route, { + method: tc.method ?? 'GET', + headers: tc.headers, + body: tc.body, + }); + + expect(resp.status).toBe(tc.expect.status); + + const json = + tc.expect.body !== undefined || tc.expect.bodyContains !== undefined + ? await resp.json() + : undefined; + + if (tc.expect.body !== undefined) { + expect(json).toEqual(tc.expect.body); + } + + if (tc.expect.bodyContains !== undefined) { + expect(json).toMatchObject(tc.expect.bodyContains); + } + + if (tc.expect.mocks?.write) { + tc.expect.mocks.write(atomicWriteMock); + } + + if (tc.expect.mocks?.backup) { + tc.expect.mocks.backup(backupMock); + } +} + +describe('/_kilo/config/read routes', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('rejects requests without auth', async () => { + await test({ + route: '/_kilo/config/read', + expect: { + status: 401, + }, + }); + }); + + it('rejects requests with wrong token', async () => { + await test({ + route: '/_kilo/config/read', + headers: { + Authorization: 'Bearer wrong-token', + }, + expect: { + status: 401, + }, + }); + }); + + it('returns the parsed config with etag', async () => { + const config = { + gateway: { port: 3001 }, + agents: { defaults: { model: { primary: 'test' } } }, + }; + const raw = JSON.stringify(config); + + await test({ + route: '/_kilo/config/read', + headers: { Authorization: 'Bearer test-token' }, + read: () => raw, + expect: { + status: 200, + // Hardcoded real hash of above config, to avoid exposing or + // duplicating the private hash calculation function + body: { config, etag: 'ba2c2548ac3dbe82044f0276f9e9e03b' }, + }, + }); + }); + + it('returns 500 when config file contains non-object JSON', async () => { + await test({ + route: '/_kilo/config/read', + headers: { Authorization: 'Bearer test-token' }, + read: () => '[1, 2, 3]', + expect: { + status: 500, + bodyContains: { + code: 'config_read_failed', + error: 'Config file does not contain a JSON object', + }, + }, + }); + }); + + it('returns 500 when config file is missing', async () => { + await test({ + route: '/_kilo/config/read', + headers: { Authorization: 'Bearer test-token' }, + read: () => { + throw new Error('ENOENT: no such file'); + }, + expect: { + status: 500, + bodyContains: { + error: expect.stringContaining('Failed to read config'), + }, + }, + }); + }); +}); + +describe('/_kilo/config/replace routes', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('rejects requests without auth', async () => { + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + config: { gateway: {} }, + }), + expect: { + status: 401, + }, + }); + }); + + it('rejects requests with wrong token', async () => { + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders('bad-token'), + body: JSON.stringify({ + config: { gateway: {} }, + }), + expect: { + status: 401, + }, + }); + }); + + it('replaces config file entirely', async () => { + const newConfig = { agents: { custom: true }, gateway: { port: 9999 } }; + + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ config: newConfig }), + expect: { + status: 200, + body: { ok: true }, + mocks: { + backup: mock => { + expect(mock).toHaveBeenCalledOnce(); + expect(mock).toHaveBeenCalledWith('/root/.openclaw/openclaw.json'); + }, + write: mock => { + expect(mock).toHaveBeenCalledOnce(); + const written = JSON.parse(mock.mock.calls[0][1] as string); + expect(written).toEqual(newConfig); + }, + }, + }, + }); + }); + + it('replaces config when etag matches', async () => { + const existing = JSON.stringify({ old: true }, null, 2); + const newConfig = { agents: { custom: true } }; + // md5('{\n "old": true\n}') + const etag = 'd9e2d0820f656cdfc4e3a872523a92a8'; + + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + read: () => existing, + body: JSON.stringify({ config: newConfig, etag }), + expect: { + status: 200, + body: { ok: true }, + mocks: { + backup: mock => { + expect(mock).toHaveBeenCalledOnce(); + expect(mock).toHaveBeenCalledWith('/root/.openclaw/openclaw.json'); + }, + write: mock => { + expect(mock).toHaveBeenCalledOnce(); + const written = JSON.parse(mock.mock.calls[0][1] as string); + expect(written).toEqual(newConfig); + }, + }, + }, + }); + }); + + it('rejects replace when etag does not match', async () => { + const existing = JSON.stringify({ old: true }, null, 2); + + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + read: () => existing, + body: JSON.stringify({ config: { new: true }, etag: 'stale-etag' }), + expect: { + status: 409, + bodyContains: { error: expect.stringContaining('Config was modified') }, + mocks: { + backup: mock => { + expect(mock).not.toHaveBeenCalled(); + }, + write: mock => { + expect(mock).not.toHaveBeenCalled(); + }, + }, + }, + }); + }); + + it('skips etag check when etag is not provided', async () => { + const newConfig = { gateway: { port: 1234 } }; + + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ config: newConfig }), + expect: { + status: 200, + body: { ok: true }, + mocks: { + backup: mock => { + expect(mock).toHaveBeenCalledOnce(); + expect(mock).toHaveBeenCalledWith('/root/.openclaw/openclaw.json'); + }, + write: mock => { + expect(mock).toHaveBeenCalledOnce(); + }, + }, + }, + }); + }); + + it('rejects non-object body', async () => { + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + body: JSON.stringify([1, 2, 3]), + expect: { + status: 400, + }, + }); + }); + + it('rejects body without config field', async () => { + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ gateway: {} }), + expect: { + status: 400, + }, + }); + }); + + it('rejects invalid JSON', async () => { + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + body: 'not json', + expect: { + status: 400, + }, + }); + }); + + it('returns 500 when write fails', async () => { + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ + config: { gateway: {} }, + }), + write: () => { + throw new Error(''); + }, + expect: { + status: 500, + bodyContains: { error: expect.stringContaining('Failed to replace config') }, + mocks: { + backup: mock => { + expect(mock).toHaveBeenCalledOnce(); + expect(mock).toHaveBeenCalledWith('/root/.openclaw/openclaw.json'); + }, + }, + }, + }); + }); + + it('returns 500 when backup fails', async () => { + backupMock.mockImplementation(() => { + throw new Error('backup failed'); + }); + + await test({ + route: '/_kilo/config/replace', + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ + config: { gateway: {} }, + }), + expect: { + status: 500, + bodyContains: { error: expect.stringContaining('Failed to replace config') }, + mocks: { + backup: mock => { + expect(mock).toHaveBeenCalledOnce(); + }, + write: mock => { + expect(mock).not.toHaveBeenCalled(); + }, + }, + }, + }); + }); +}); diff --git a/kiloclaw/controller/src/routes/config.ts b/kiloclaw/controller/src/routes/config.ts index eab5079547..344cc49c76 100644 --- a/kiloclaw/controller/src/routes/config.ts +++ b/kiloclaw/controller/src/routes/config.ts @@ -1,10 +1,26 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import type { Hono } from 'hono'; +import { z } from 'zod'; +import { atomicWrite } from '../atomic-write'; import { timingSafeTokenEqual } from '../auth'; import type { Supervisor } from '../supervisor'; -import { writeBaseConfig } from '../config-writer'; +import { backupConfigFile, writeBaseConfig } from '../config-writer'; import { getBearerToken } from './gateway'; +const ReplaceConfigBodySchema = z.object({ + config: z.record(z.string(), z.unknown()), + etag: z.string().optional(), +}); + +function computeEtag(raw: string): string { + return crypto.createHash('md5').update(raw).digest('hex'); +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + const CONFIG_PATH = '/root/.openclaw/openclaw.json'; const VALID_VERSIONS = ['base'] as const; @@ -51,6 +67,29 @@ export function registerConfigRoutes( await next(); }); + // Read the current openclaw.json config from disk. + app.get('/_kilo/config/read', c => { + try { + const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); + const config = JSON.parse(raw); + if (!isJsonObject(config)) { + return c.json( + { code: 'config_read_failed', error: 'Config file does not contain a JSON object' }, + 500 + ); + } + const etag = computeEtag(raw); + return c.json({ config, etag }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[controller] /_kilo/config/read failed:', message); + return c.json( + { code: 'config_read_failed', error: `Failed to read config: ${message}` }, + 500 + ); + } + }); + // Restore config from env vars and restart the gateway. app.post('/_kilo/config/restore/:version', c => { const version = c.req.param('version'); @@ -79,6 +118,58 @@ export function registerConfigRoutes( } }); + // Replace openclaw.json with a JSON blob. + // + // Optionally accepts an etag. When provided, the write is rejected with a + // 409 if the on-disk config has changed. This, and the underlying file op, + // is just best effort concurrency; it's not designed against high + // contention or tight race conditions + app.post('/_kilo/config/replace', async c => { + let raw: unknown; + try { + raw = await c.req.json(); + } catch { + return c.json({ code: 'invalid_json_body', error: 'Invalid JSON body' }, 400); + } + + const parsed = ReplaceConfigBodySchema.safeParse(raw); + if (!parsed.success) { + return c.json({ code: 'invalid_request_body', error: 'Invalid request body' }, 400); + } + + const { config, etag } = parsed.data; + + // Best effort optimistic concurrency: the read/check/write is not atomic, + // but sufficient to catch the common case of stale browser tabs. + try { + if (etag !== undefined) { + const current = fs.readFileSync(CONFIG_PATH, 'utf8'); + if (etag !== computeEtag(current)) { + return c.json( + { + code: 'config_etag_conflict', + error: 'Config was modified since last read — please reload and retry', + }, + 409 + ); + } + } + + backupConfigFile(CONFIG_PATH); + atomicWrite(CONFIG_PATH, JSON.stringify(config, null, 2)); + + console.log('[controller] Config replaced'); + return c.json({ ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[controller] Failed to replace config:', message); + return c.json( + { code: 'config_replace_failed', error: `Failed to replace config: ${message}` }, + 500 + ); + } + }); + // Deep-merge a JSON patch into openclaw.json. // OpenClaw's gateway watches this file and reloads on change. // @@ -100,7 +191,8 @@ export function registerConfigRoutes( const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); const config = JSON.parse(raw); deepMerge(config, patch as Record); - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); + const serialized = JSON.stringify(config, null, 2); + atomicWrite(CONFIG_PATH, serialized); console.log('[controller] Config patched:', JSON.stringify(patch)); return c.json({ ok: true }); } catch (err) { diff --git a/kiloclaw/scripts/push-dev.sh b/kiloclaw/scripts/push-dev.sh index 703a5eb7e4..3b6451ca56 100755 --- a/kiloclaw/scripts/push-dev.sh +++ b/kiloclaw/scripts/push-dev.sh @@ -6,10 +6,12 @@ # Usage: ./scripts/push-dev.sh [app-name] # app-name defaults to FLY_APP_NAME from .dev.vars, or "kiloclaw-dev" # -# Prerequisites: fly auth docker (for registry auth) - set -e +# Fly registry tokens expire after 5 minutes, so re-auth on every push. +echo "Authenticating with Fly registry..." +fly auth docker + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" KILOCLAW_DIR="$(dirname "$SCRIPT_DIR")" diff --git a/kiloclaw/src/durable-objects/gateway-controller-types.ts b/kiloclaw/src/durable-objects/gateway-controller-types.ts index 890f9ac7f0..cf6a4e4c09 100644 --- a/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -47,10 +47,18 @@ export const ControllerVersionResponseSchema = z.object({ export class GatewayControllerError extends Error { readonly status: number; + readonly code: string | null; - constructor(status: number, message: string) { + constructor(status: number, message: string, code?: string) { super(message); this.name = 'GatewayControllerError'; this.status = status; + this.code = code ?? null; } } + +// Treat the Openclaw config on disk as an opaque blob +export const OpenclawConfigResponseSchema = z.object({ + config: z.record(z.string(), z.unknown()), + etag: z.string(), +}); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts index 39633216fe..7ec5ee5f1f 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts @@ -7,6 +7,7 @@ import { GatewayCommandResponseSchema, ConfigRestoreResponseSchema, ControllerVersionResponseSchema, + OpenclawConfigResponseSchema, GatewayControllerError, } from '../gateway-controller-types'; import { HEALTH_PROBE_TIMEOUT_SECONDS, HEALTH_PROBE_INTERVAL_MS } from '../../config'; @@ -24,7 +25,7 @@ function requireGatewayControllerContext( sandboxId: string; } { if (!state.sandboxId) { - throw new GatewayControllerError(404, 'Instance not provisioned'); + throw new GatewayControllerError(409, 'Instance not provisioned'); } if (!state.flyMachineId) { throw new GatewayControllerError(409, 'Instance has no machine ID'); @@ -94,6 +95,13 @@ export async function callGatewayController( } if (!response.ok) { + const errorCode = + typeof body === 'object' && + body !== null && + 'code' in body && + typeof (body as { code?: unknown }).code === 'string' + ? (body as { code: string }).code + : undefined; const errorMessage = typeof body === 'object' && body !== null && @@ -101,7 +109,7 @@ export async function callGatewayController( typeof (body as { error?: unknown }).error === 'string' ? (body as { error: string }).error : `Gateway controller request failed (${response.status})`; - throw new GatewayControllerError(response.status, errorMessage); + throw new GatewayControllerError(response.status, errorMessage, errorCode); } const parsed = responseSchema.safeParse(body ?? {}); @@ -198,6 +206,16 @@ export function restoreConfig( ); } +function isErrorUnknownRoute(error: unknown): boolean { + // If a controller predates a new route, the request will either: + // - fall through to the catch-all proxy (401 REQUIRE_PROXY_TOKEN) + // - forward to the gateway which returns 404 for the unknown path. + return ( + error instanceof GatewayControllerError && + (error.status === 404 || error.code === 'controller_route_unavailable') + ); +} + export async function getControllerVersion( state: InstanceMutableState, env: KiloClawEnv @@ -215,7 +233,52 @@ export async function getControllerVersion( ControllerVersionResponseSchema ); } catch (error) { - if (error instanceof GatewayControllerError && (error.status === 404 || error.status === 401)) { + if (isErrorUnknownRoute(error)) { + return null; + } + throw error; + } +} + +/** Returns null if the controller is too old to have the /_kilo/config/read endpoint. */ +export async function getOpenclawConfig( + state: InstanceMutableState, + env: KiloClawEnv +): Promise<{ config: Record; etag?: string } | null> { + try { + return await callGatewayController( + state, + env, + '/_kilo/config/read', + 'GET', + OpenclawConfigResponseSchema + ); + } catch (error) { + if (isErrorUnknownRoute(error)) { + return null; + } + throw error; + } +} + +/** Returns null if the controller is too old to have the /_kilo/config/replace endpoint. */ +export async function replaceConfigOnMachine( + state: InstanceMutableState, + env: KiloClawEnv, + config: Record, + etag?: string +): Promise<{ ok: boolean } | null> { + try { + return await callGatewayController( + state, + env, + '/_kilo/config/replace', + 'POST', + GatewayCommandResponseSchema, + { config, etag } + ); + } catch (error) { + if (isErrorUnknownRoute(error)) { return null; } throw error; diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index b44e0233ef..b768024364 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -962,6 +962,21 @@ export class KiloClawInstance extends DurableObject { return gateway.patchConfigOnMachine(this.s, this.env, patch); } + /** Returns null if the controller is too old to have the /_kilo/config/read endpoint. */ + async getOpenclawConfig(): Promise<{ config: Record; etag?: string } | null> { + await this.loadState(); + return gateway.getOpenclawConfig(this.s, this.env); + } + + /** Returns null if the controller is too old to have the /_kilo/config/replace endpoint. */ + async replaceConfigOnMachine( + config: Record, + etag?: string + ): Promise<{ ok: boolean } | null> { + await this.loadState(); + return gateway.replaceConfigOnMachine(this.s, this.env, config, etag); + } + // ── Restart machine (user-facing) ────────────────────────────────── async restartMachine(options?: { diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index 70c64104b7..67cf54e7bd 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -64,8 +64,8 @@ function statusCodeFromError(err: unknown): number { return 500; } -function jsonError(message: string, status: number): Response { - return new Response(JSON.stringify({ error: message }), { +function jsonError(message: string, status: number, code?: string): Response { + return new Response(JSON.stringify({ error: message, ...(code ? { code } : {}) }), { status, headers: { 'content-type': 'application/json' }, }); @@ -77,27 +77,66 @@ function jsonError(message: string, status: number): Response { * The raw error is always logged via console.error for Sentry/debugging. */ const SAFE_ERROR_PREFIXES = [ - 'Instance is not ', // e.g. "Instance is not running", "Instance is not provisioned" + 'Instance is not ', // e.g. "Instance is not running" + 'Instance not ', // e.g. "Instance not provisioned" (DO uses both forms) 'User already has an ', // duplicate provision 'Gateway controller ', // already sanitized at DO level + 'Config was modified ', // etag mismatch on config replace 'Invalid secret patch: ', // catalog validation (allFieldsRequired, etc.) + 'Config was modified ', // etag mismatch on config replace ]; function sanitizeError(err: unknown, operation: string): { message: string; status: number } { const raw = err instanceof Error ? err.message : 'Unknown error'; const status = statusCodeFromError(err); + const normalized = raw.replace(/^(?:[A-Za-z]+Error:\s*)+/, ''); // Log the full error for Sentry/debugging — this never reaches the caller console.error(`[platform] ${operation} failed:`, raw); // Allow known-safe messages through - if (SAFE_ERROR_PREFIXES.some(prefix => raw.startsWith(prefix))) { - return { message: raw, status }; + if (SAFE_ERROR_PREFIXES.some(prefix => normalized.startsWith(prefix))) { + return { message: normalized, status }; } return { message: `${operation} failed`, status }; } +const OPENCLAW_CONFIG_ERROR_CODES = new Set([ + 'controller_route_unavailable', + 'config_etag_conflict', + 'invalid_json_body', + 'invalid_request_body', +]); + +function sanitizeOpenclawConfigError( + err: unknown, + operation: string +): { message: string; status: number; code?: string } { + const raw = err instanceof Error ? err.message : 'Unknown error'; + const status = statusCodeFromError(err); + const normalized = raw.replace(/^(?:[A-Za-z]+Error:\s*)+/, ''); + const code = + typeof err === 'object' && + err !== null && + 'code' in err && + typeof (err as { code?: unknown }).code === 'string' + ? (err as { code: string }).code + : undefined; + + console.error(`[platform] ${operation} failed:`, raw); + + if (code && OPENCLAW_CONFIG_ERROR_CODES.has(code)) { + return { message: normalized, status, code }; + } + + if (SAFE_ERROR_PREFIXES.some(prefix => normalized.startsWith(prefix))) { + return { message: normalized, status, ...(code ? { code } : {}) }; + } + + return { message: `${operation} failed`, status, ...(code ? { code } : {}) }; +} + /** * Safely parse JSON body through a zod schema. * Returns 400 with a consistent error shape on malformed JSON or validation failure. @@ -493,6 +532,60 @@ platform.post('/config/restore', async c => { } }); +// GET /api/platform/openclaw-config?userId=... +// Returns the live openclaw.json from the running machine. +platform.get('/openclaw-config', async c => { + const userId = c.req.query('userId'); + if (!userId) { + return c.json({ error: 'userId query parameter is required' }, 400); + } + + try { + const config = await withDORetry( + instanceStubFactory(c.env, userId), + stub => stub.getOpenclawConfig(), + 'getOpenclawConfig' + ); + if (!config) { + return jsonError('Failed to get OpenClaw config', 404, 'controller_route_unavailable'); + } + return c.json(config, 200); + } catch (err) { + const { message, status, code } = sanitizeOpenclawConfigError(err, 'openclaw-config read'); + return jsonError(message, status, code); + } +}); + +// POST /api/platform/openclaw-config +// Replace the entire openclaw.json on the running machine. +const ReplaceOpenclawConfigSchema = z.object({ + userId: z.string().min(1), + config: z.record(z.string(), z.unknown()), + etag: z.string().optional(), +}); + +platform.post('/openclaw-config', async c => { + const result = await parseBody(c, ReplaceOpenclawConfigSchema); + if ('error' in result) return result.error; + + const { userId, config, etag } = result.data; + + try { + const response = await withDORetry( + instanceStubFactory(c.env, userId), + stub => stub.replaceConfigOnMachine(config, etag), + 'replaceConfigOnMachine' + ); + if (!response) { + return jsonError('Failed to update OpenClaw config', 404, 'controller_route_unavailable'); + } + return c.json(response, 200); + } catch (err) { + const { message, status, code } = sanitizeOpenclawConfigError(err, 'openclaw-config replace'); + return jsonError(message, status, code); + } +}); + // POST /api/platform/doctor platform.post('/doctor', async c => { const result = await parseBody(c, UserIdRequestSchema); diff --git a/src/app/(app)/claw/components/OpenclawConfigEditor.tsx b/src/app/(app)/claw/components/OpenclawConfigEditor.tsx new file mode 100644 index 0000000000..701e73004f --- /dev/null +++ b/src/app/(app)/claw/components/OpenclawConfigEditor.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { Suspense, lazy, useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; +import { useKiloClawOpenclawConfig } from '@/hooks/useKiloClaw'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +type ClawMutations = ReturnType; + +const Editor = lazy(() => import('@monaco-editor/react')); +const DiffEditor = lazy(() => + import('@monaco-editor/react').then(mod => ({ default: mod.DiffEditor })) +); + +function EditorLoading() { + return ( +
+
+ + Loading editor... +
+
+ ); +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const EDITOR_OPTIONS = { + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + folding: true, + wordWrap: 'on' as const, + automaticLayout: true, + tabSize: 2, + padding: { top: 8, bottom: 8 }, + scrollbar: { + vertical: 'auto' as const, + horizontal: 'hidden' as const, + verticalScrollbarSize: 8, + }, +}; + +export function OpenclawConfigEditor({ + enabled, + mutations, + onOpenChange, +}: { + enabled: boolean; + mutations: ClawMutations; + onOpenChange: (open: boolean) => void; +}) { + const { data, isLoading, error, refetch } = useKiloClawOpenclawConfig(enabled); + + const baseConfig = useMemo( + () => (data ? JSON.stringify(data.openclawConfig, null, 2) : ''), + [data] + ); + + const [isMounted, setIsMounted] = useState(false); + const [editedConfig, setEditedConfig] = useState(null); + const initialEtagRef = useRef(undefined); + useEffect(() => { + if (data?.etag && initialEtagRef.current === undefined) { + initialEtagRef.current = data.etag; + } + }, [data?.etag]); + const baseConfigChanged = + data?.etag !== undefined && + initialEtagRef.current !== undefined && + data.etag !== initialEtagRef.current; + const currentEditValue = editedConfig ?? baseConfig; + const hasChanges = editedConfig !== null && editedConfig !== baseConfig; + + useEffect(() => { + setIsMounted(true); + }, []); + + const handleEditorChange = useCallback( + (value: string | undefined) => { + const next = value ?? ''; + if (next === baseConfig) { + setEditedConfig(null); + } else { + setEditedConfig(next); + } + }, + [baseConfig] + ); + + if (isLoading) { + return ( +
+ + Loading config... +
+ ); + } + + if (error) { + return ( + + + {error instanceof Error ? error.message : 'Failed to load config'} + + + ); + } + + if (!data) return null; + + if (!isMounted) { + return ; + } + + const handleReload = () => { + initialEtagRef.current = data.etag; + setEditedConfig(null); + }; + + return ( +
+ {baseConfigChanged && hasChanges && ( + + + + The config was updated externally. Your edits are based on an older version. + + + + + )} +
+
+

Editor

+
+ }> + + +
+
+ +
+

Diff

+ {hasChanges ? ( +
+ }> + + +
+ ) : ( +
+

No changes

+
+ )} +
+
+ +
+ + +
+
+ ); +} diff --git a/src/app/(app)/claw/components/SettingsTab.tsx b/src/app/(app)/claw/components/SettingsTab.tsx index 4901c7448a..310c8768f0 100644 --- a/src/app/(app)/claw/components/SettingsTab.tsx +++ b/src/app/(app)/claw/components/SettingsTab.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, Check, Copy, + FileCode, Hash, Package, RotateCcw, @@ -11,7 +12,7 @@ import { Square, X, } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { usePostHog } from 'posthog-js/react'; import { toast } from 'sonner'; @@ -40,6 +41,7 @@ import { getEntriesByCategory } from '@kilocode/kiloclaw-secret-catalog'; import { SecretEntrySection } from './SecretEntrySection'; import { ConfirmActionDialog } from './ConfirmActionDialog'; import { VersionPinCard } from './VersionPinCard'; +import { OpenclawConfigEditor } from './OpenclawConfigEditor'; type ClawMutations = ReturnType; @@ -176,6 +178,7 @@ export function SettingsTab({ ? 'Failed to load the running OpenClaw version. Retry before changing the default model.' : undefined; const isLoadingModelSelection = isLoadingModels || (isRunning && isLoadingControllerVersion); + const [editConfigOpen, setEditConfigOpen] = useState(false); const modelOptions = useMemo( () => @@ -238,6 +241,10 @@ export function SettingsTab({ ); } + useEffect(() => { + if (!isRunning) setEditConfigOpen(false); + }, [isRunning]); + // Determine if running version differs from tracked version // Old image: the DO returns null when the controller lacks /_kilo/version, // and the platform route converts that to { version: null, commit: null }. @@ -497,6 +504,16 @@ export function SettingsTab({ )} + +