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
38 changes: 31 additions & 7 deletions kiloclaw/DEVELOPMENT_LOCAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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-<timestamp>`) 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

Expand Down
3 changes: 3 additions & 0 deletions kiloclaw/controller/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion kiloclaw/controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
77 changes: 77 additions & 0 deletions kiloclaw/controller/src/atomic-write.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import { atomicWrite, type AtomicWriteDeps } from './atomic-write.js';

function makeDeps(overrides: Partial<AtomicWriteDeps> = {}): 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<typeof vi.fn>).mock.calls[0][0] as string;
expect(tmpPath).toMatch(/^\/config\/\.openclaw\.json\.kilotmp\.[0-9a-f]+$/);
expect((deps.writeFileSync as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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);
});
});
47 changes: 47 additions & 0 deletions kiloclaw/controller/src/atomic-write.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
84 changes: 82 additions & 2 deletions kiloclaw/controller/src/config-writer.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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');
Comment thread
evanjacobson marked this conversation as resolved.
});

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', () => {
Expand Down Expand Up @@ -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');
});
Expand Down
Loading
Loading