Skip to content

Commit f688dfb

Browse files
Merge pull request #240 from cloudflare/rclone
Replace s3fs/rsync with rclone for R2 persistence
2 parents c92bf6d + 95307a3 commit f688dfb

16 files changed

Lines changed: 410 additions & 582 deletions

File tree

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
FROM docker.io/cloudflare/sandbox:0.7.0
22

3-
# Install Node.js 22 (required by OpenClaw) and rsync (for R2 backup sync)
3+
# Install Node.js 22 (required by OpenClaw) and rclone (for R2 persistence)
44
# The base image has Node 20, we need to replace it with Node 22
55
# Using direct binary download for reliability
66
ENV NODE_VERSION=22.13.1
@@ -10,7 +10,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
1010
arm64) NODE_ARCH="arm64" ;; \
1111
*) echo "Unsupported architecture: ${ARCH}" >&2; exit 1 ;; \
1212
esac \
13-
&& apt-get update && apt-get install -y xz-utils ca-certificates rsync \
13+
&& apt-get update && apt-get install -y xz-utils ca-certificates rclone \
1414
&& curl -fsSLk https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz -o /tmp/node.tar.xz \
1515
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
1616
&& rm /tmp/node.tar.xz \
@@ -32,7 +32,7 @@ RUN mkdir -p /root/.openclaw \
3232
&& mkdir -p /root/clawd/skills
3333

3434
# Copy startup script
35-
# Build cache bust: 2026-02-06-v29-sync-workspace
35+
# Build cache bust: 2026-02-11-v30-rclone
3636
COPY start-openclaw.sh /usr/local/bin/start-openclaw.sh
3737
RUN chmod +x /usr/local/bin/start-openclaw.sh
3838

src/client/pages/AdminPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,8 @@ export default function AdminPage() {
358358
</div>
359359
) : (
360360
<div className="devices-grid">
361-
{paired.map((device, index) => (
362-
<div key={device.deviceId || index} className="device-card paired">
361+
{paired.map((device) => (
362+
<div key={device.deviceId} className="device-card paired">
363363
<div className="device-header">
364364
<span className="device-name">
365365
{device.displayName || device.deviceId || 'Unknown Device'}

src/config.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ export const MOLTBOT_PORT = 18789;
88
/** Maximum time to wait for Moltbot to start (3 minutes) */
99
export const STARTUP_TIMEOUT_MS = 180_000;
1010

11-
/** Mount path for R2 persistent storage inside the container */
12-
export const R2_MOUNT_PATH = '/data/moltbot';
13-
1411
/**
1512
* R2 bucket name for persistent storage.
1613
* Can be overridden via R2_BUCKET_NAME env var for test isolation.

src/gateway/env.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,10 @@ export function buildEnvVars(env: MoltbotEnv): Record<string, string> {
5050
if (env.CDP_SECRET) envVars.CDP_SECRET = env.CDP_SECRET;
5151
if (env.WORKER_URL) envVars.WORKER_URL = env.WORKER_URL;
5252

53+
// R2 persistence credentials (used by rclone in start-openclaw.sh)
54+
if (env.R2_ACCESS_KEY_ID) envVars.R2_ACCESS_KEY_ID = env.R2_ACCESS_KEY_ID;
55+
if (env.R2_SECRET_ACCESS_KEY) envVars.R2_SECRET_ACCESS_KEY = env.R2_SECRET_ACCESS_KEY;
56+
if (env.R2_BUCKET_NAME) envVars.R2_BUCKET_NAME = env.R2_BUCKET_NAME;
57+
5358
return envVars;
5459
}

src/gateway/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
export { buildEnvVars } from './env';
2-
export { mountR2Storage } from './r2';
3-
export { findExistingMoltbotProcess, ensureMoltbotGateway } from './process';
4-
export { fireAndForgetSync, syncToR2 } from './sync';
1+
export { ensureMoltbotGateway, findExistingMoltbotProcess } from './process';
52
export { waitForProcess } from './utils';
3+
export { ensureRcloneConfig } from './r2';
4+
export { syncToR2 } from './sync';

src/gateway/process.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Sandbox, Process } from '@cloudflare/sandbox';
22
import type { MoltbotEnv } from '../types';
33
import { MOLTBOT_PORT, STARTUP_TIMEOUT_MS } from '../config';
44
import { buildEnvVars } from './env';
5-
import { mountR2Storage } from './r2';
5+
import { ensureRcloneConfig } from './r2';
66

77
/**
88
* Find an existing OpenClaw gateway process
@@ -54,9 +54,9 @@ export async function findExistingMoltbotProcess(sandbox: Sandbox): Promise<Proc
5454
* @returns The running gateway process
5555
*/
5656
export async function ensureMoltbotGateway(sandbox: Sandbox, env: MoltbotEnv): Promise<Process> {
57-
// Mount R2 storage for persistent data (non-blocking if not configured)
58-
// R2 is used as a backup - the startup script will restore from it on boot
59-
await mountR2Storage(sandbox, env);
57+
// Configure rclone for R2 persistence (non-blocking if not configured).
58+
// The startup script uses rclone to restore data from R2 on boot.
59+
await ensureRcloneConfig(sandbox, env);
6060

6161
// Check if gateway is already running or starting
6262
const existingProcess = await findExistingMoltbotProcess(sandbox);

src/gateway/r2.test.ts

Lines changed: 32 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,161 +1,73 @@
11
import { describe, it, expect, beforeEach } from 'vitest';
2-
import { mountR2Storage } from './r2';
2+
import { ensureRcloneConfig } from './r2';
33
import {
44
createMockEnv,
55
createMockEnvWithR2,
6-
createMockProcess,
6+
createMockExecResult,
77
createMockSandbox,
88
suppressConsole,
99
} from '../test-utils';
1010

11-
describe('mountR2Storage', () => {
11+
describe('ensureRcloneConfig', () => {
1212
beforeEach(() => {
1313
suppressConsole();
1414
});
1515

1616
describe('credential validation', () => {
1717
it('returns false when R2_ACCESS_KEY_ID is missing', async () => {
1818
const { sandbox } = createMockSandbox();
19-
const env = createMockEnv({
20-
R2_SECRET_ACCESS_KEY: 'secret',
21-
CF_ACCOUNT_ID: 'account123',
22-
});
23-
24-
const result = await mountR2Storage(sandbox, env);
25-
26-
expect(result).toBe(false);
19+
const env = createMockEnv({ R2_SECRET_ACCESS_KEY: 'secret', CF_ACCOUNT_ID: 'acct' });
20+
expect(await ensureRcloneConfig(sandbox, env)).toBe(false);
2721
});
2822

2923
it('returns false when R2_SECRET_ACCESS_KEY is missing', async () => {
3024
const { sandbox } = createMockSandbox();
31-
const env = createMockEnv({
32-
R2_ACCESS_KEY_ID: 'key123',
33-
CF_ACCOUNT_ID: 'account123',
34-
});
35-
36-
const result = await mountR2Storage(sandbox, env);
37-
38-
expect(result).toBe(false);
25+
const env = createMockEnv({ R2_ACCESS_KEY_ID: 'key', CF_ACCOUNT_ID: 'acct' });
26+
expect(await ensureRcloneConfig(sandbox, env)).toBe(false);
3927
});
4028

4129
it('returns false when CF_ACCOUNT_ID is missing', async () => {
4230
const { sandbox } = createMockSandbox();
43-
const env = createMockEnv({
44-
R2_ACCESS_KEY_ID: 'key123',
45-
R2_SECRET_ACCESS_KEY: 'secret',
46-
});
47-
48-
const result = await mountR2Storage(sandbox, env);
49-
50-
expect(result).toBe(false);
31+
const env = createMockEnv({ R2_ACCESS_KEY_ID: 'key', R2_SECRET_ACCESS_KEY: 'secret' });
32+
expect(await ensureRcloneConfig(sandbox, env)).toBe(false);
5133
});
5234

5335
it('returns false when all R2 credentials are missing', async () => {
5436
const { sandbox } = createMockSandbox();
5537
const env = createMockEnv();
56-
57-
const result = await mountR2Storage(sandbox, env);
58-
59-
expect(result).toBe(false);
60-
expect(console.log).toHaveBeenCalledWith(
61-
expect.stringContaining('R2 storage not configured'),
62-
);
38+
expect(await ensureRcloneConfig(sandbox, env)).toBe(false);
6339
});
6440
});
6541

66-
describe('mounting behavior', () => {
67-
it('mounts R2 bucket when credentials provided and not already mounted', async () => {
68-
const { sandbox, mountBucketMock } = createMockSandbox({ mounted: false });
69-
const env = createMockEnvWithR2({
70-
R2_ACCESS_KEY_ID: 'key123',
71-
R2_SECRET_ACCESS_KEY: 'secret',
72-
CF_ACCOUNT_ID: 'account123',
73-
});
74-
75-
const result = await mountR2Storage(sandbox, env);
76-
77-
expect(result).toBe(true);
78-
expect(mountBucketMock).toHaveBeenCalledWith('moltbot-data', '/data/moltbot', {
79-
endpoint: 'https://account123.r2.cloudflarestorage.com',
80-
credentials: {
81-
accessKeyId: 'key123',
82-
secretAccessKey: 'secret',
83-
},
84-
});
85-
});
86-
87-
it('uses custom bucket name from R2_BUCKET_NAME env var', async () => {
88-
const { sandbox, mountBucketMock } = createMockSandbox({ mounted: false });
89-
const env = createMockEnvWithR2({
90-
R2_ACCESS_KEY_ID: 'key123',
91-
R2_SECRET_ACCESS_KEY: 'secret',
92-
CF_ACCOUNT_ID: 'account123',
93-
R2_BUCKET_NAME: 'moltbot-e2e-test123',
94-
});
95-
96-
const result = await mountR2Storage(sandbox, env);
97-
98-
expect(result).toBe(true);
99-
expect(mountBucketMock).toHaveBeenCalledWith(
100-
'moltbot-e2e-test123',
101-
'/data/moltbot',
102-
expect.any(Object),
103-
);
104-
});
105-
106-
it('returns true immediately when bucket is already mounted', async () => {
107-
const { sandbox, mountBucketMock } = createMockSandbox({ mounted: true });
108-
const env = createMockEnvWithR2();
109-
110-
const result = await mountR2Storage(sandbox, env);
111-
112-
expect(result).toBe(true);
113-
expect(mountBucketMock).not.toHaveBeenCalled();
114-
expect(console.log).toHaveBeenCalledWith('R2 bucket already mounted at', '/data/moltbot');
115-
});
116-
117-
it('logs success message when mounted successfully', async () => {
118-
const { sandbox } = createMockSandbox({ mounted: false });
119-
const env = createMockEnvWithR2();
120-
121-
await mountR2Storage(sandbox, env);
122-
123-
expect(console.log).toHaveBeenCalledWith(
124-
'R2 bucket mounted successfully - moltbot data will persist across sessions',
125-
);
126-
});
127-
});
128-
129-
describe('error handling', () => {
130-
it('returns false when mountBucket throws and mount check fails', async () => {
131-
const { sandbox, mountBucketMock, startProcessMock } = createMockSandbox({ mounted: false });
132-
mountBucketMock.mockRejectedValue(new Error('Mount failed'));
133-
startProcessMock
134-
.mockResolvedValueOnce(createMockProcess(''))
135-
.mockResolvedValueOnce(createMockProcess(''));
136-
42+
describe('configuration behavior', () => {
43+
it('skips setup if already configured (flag file exists)', async () => {
44+
const { sandbox, execMock, writeFileMock } = createMockSandbox();
45+
execMock.mockResolvedValue(createMockExecResult('yes'));
13746
const env = createMockEnvWithR2();
13847

139-
const result = await mountR2Storage(sandbox, env);
140-
141-
expect(result).toBe(false);
142-
expect(console.error).toHaveBeenCalledWith('Failed to mount R2 bucket:', expect.any(Error));
48+
expect(await ensureRcloneConfig(sandbox, env)).toBe(true);
49+
expect(writeFileMock).not.toHaveBeenCalled();
14350
});
14451

145-
it('returns true if mount fails but check shows it is actually mounted', async () => {
146-
const { sandbox, mountBucketMock, startProcessMock } = createMockSandbox();
147-
startProcessMock
148-
.mockResolvedValueOnce(createMockProcess('not-mounted\n'))
149-
.mockResolvedValueOnce(createMockProcess('mounted\n'));
150-
151-
mountBucketMock.mockRejectedValue(new Error('Transient error'));
52+
it('writes rclone config and sets flag when not configured', async () => {
53+
const { sandbox, execMock, writeFileMock } = createMockSandbox();
54+
execMock
55+
.mockResolvedValueOnce(createMockExecResult('no')) // flag check
56+
.mockResolvedValueOnce(createMockExecResult()) // mkdir
57+
.mockResolvedValueOnce(createMockExecResult()); // touch flag
15258

153-
const env = createMockEnvWithR2();
59+
const env = createMockEnvWithR2({
60+
R2_ACCESS_KEY_ID: 'mykey',
61+
R2_SECRET_ACCESS_KEY: 'mysecret',
62+
CF_ACCOUNT_ID: 'myaccount',
63+
});
15464

155-
const result = await mountR2Storage(sandbox, env);
65+
expect(await ensureRcloneConfig(sandbox, env)).toBe(true);
15666

157-
expect(result).toBe(true);
158-
expect(console.log).toHaveBeenCalledWith('R2 bucket is mounted despite error');
67+
const writtenConfig = writeFileMock.mock.calls[0][1];
68+
expect(writtenConfig).toContain('access_key_id = mykey');
69+
expect(writtenConfig).toContain('secret_access_key = mysecret');
70+
expect(writtenConfig).toContain('endpoint = https://myaccount.r2.cloudflarestorage.com');
15971
});
16072
});
16173
});

src/gateway/r2.ts

Lines changed: 24 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,44 @@
11
import type { Sandbox } from '@cloudflare/sandbox';
22
import type { MoltbotEnv } from '../types';
3-
import { R2_MOUNT_PATH, getR2BucketName } from '../config';
4-
import { waitForProcess } from './utils';
3+
import { getR2BucketName } from '../config';
54

6-
/**
7-
* Check if R2 is already mounted by looking at the mount table.
8-
* Uses stdout marker pattern because getLogs() often returns empty.
9-
*/
10-
async function isR2Mounted(sandbox: Sandbox): Promise<boolean> {
11-
try {
12-
const proc = await sandbox.startProcess(
13-
`mount | grep -q "s3fs on ${R2_MOUNT_PATH}" && echo mounted || echo not-mounted`,
14-
);
15-
await waitForProcess(proc, 5000);
16-
const logs = await proc.getLogs();
17-
const mounted = !!(
18-
logs.stdout &&
19-
logs.stdout.includes('mounted') &&
20-
!logs.stdout.includes('not-mounted')
21-
);
22-
console.log('isR2Mounted check:', mounted, 'stdout:', logs.stdout?.slice(0, 100));
23-
return mounted;
24-
} catch (err) {
25-
console.log('isR2Mounted error:', err);
26-
return false;
27-
}
28-
}
5+
const RCLONE_CONF_PATH = '/root/.config/rclone/rclone.conf';
6+
const CONFIGURED_FLAG = '/tmp/.rclone-configured';
297

308
/**
31-
* Mount R2 bucket for persistent storage
9+
* Ensure rclone is configured in the container for R2 access.
10+
* Idempotent — checks for a flag file to skip re-configuration.
3211
*
33-
* @param sandbox - The sandbox instance
34-
* @param env - Worker environment bindings
35-
* @returns true if mounted successfully, false otherwise
12+
* @returns true if rclone is configured, false if credentials are missing
3613
*/
37-
export async function mountR2Storage(sandbox: Sandbox, env: MoltbotEnv): Promise<boolean> {
14+
export async function ensureRcloneConfig(sandbox: Sandbox, env: MoltbotEnv): Promise<boolean> {
3815
if (!env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.CF_ACCOUNT_ID) {
3916
console.log(
4017
'R2 storage not configured (missing R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or CF_ACCOUNT_ID)',
4118
);
4219
return false;
4320
}
4421

45-
if (await isR2Mounted(sandbox)) {
46-
console.log('R2 bucket already mounted at', R2_MOUNT_PATH);
22+
const check = await sandbox.exec(`test -f ${CONFIGURED_FLAG} && echo yes || echo no`);
23+
if (check.stdout?.trim() === 'yes') {
4724
return true;
4825
}
4926

50-
const bucketName = getR2BucketName(env);
51-
try {
52-
console.log('Mounting R2 bucket', bucketName, 'at', R2_MOUNT_PATH);
53-
await sandbox.mountBucket(bucketName, R2_MOUNT_PATH, {
54-
endpoint: `https://${env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
55-
credentials: {
56-
accessKeyId: env.R2_ACCESS_KEY_ID,
57-
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
58-
},
59-
});
60-
console.log('R2 bucket mounted successfully - moltbot data will persist across sessions');
61-
return true;
62-
} catch (err) {
63-
const errorMessage = err instanceof Error ? err.message : String(err);
64-
console.log('R2 mount error:', errorMessage);
27+
const rcloneConfig = [
28+
'[r2]',
29+
'type = s3',
30+
'provider = Cloudflare',
31+
`access_key_id = ${env.R2_ACCESS_KEY_ID}`,
32+
`secret_access_key = ${env.R2_SECRET_ACCESS_KEY}`,
33+
`endpoint = https://${env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
34+
'acl = private',
35+
'no_check_bucket = true',
36+
].join('\n');
6537

66-
// Check again if it's mounted - the error might be misleading (e.g. "already mounted")
67-
if (await isR2Mounted(sandbox)) {
68-
console.log('R2 bucket is mounted despite error');
69-
return true;
70-
}
38+
await sandbox.exec(`mkdir -p $(dirname ${RCLONE_CONF_PATH})`);
39+
await sandbox.writeFile(RCLONE_CONF_PATH, rcloneConfig);
40+
await sandbox.exec(`touch ${CONFIGURED_FLAG}`);
7141

72-
console.error('Failed to mount R2 bucket:', err);
73-
return false;
74-
}
42+
console.log('Rclone configured for R2 bucket:', getR2BucketName(env));
43+
return true;
7544
}

0 commit comments

Comments
 (0)