- {device.platform && (
-
- Platform:
- {device.platform}
-
- )}
- {device.clientId && (
-
- Client:
- {device.clientId}
-
- )}
- {device.clientMode && (
-
- Mode:
- {device.clientMode}
-
- )}
- {device.role && (
-
- Role:
- {device.role}
-
- )}
- {device.remoteIp && (
-
- IP:
- {device.remoteIp}
-
+ {device._type === 'channel' ? (
+ <>
+ {device.channel && (
+
+ Channel:
+ {device.channel}
+
+ )}
+ {device.code && (
+
+ Code:
+ {device.code}
+
+ )}
+ >
+ ) : (
+ <>
+ {device.platform && (
+
+ Platform:
+ {device.platform}
+
+ )}
+ {device.clientId && (
+
+ Client:
+ {device.clientId}
+
+ )}
+ {device.clientMode && (
+
+ Mode:
+ {device.clientMode}
+
+ )}
+ {device.role && (
+
+ Role:
+ {device.role}
+
+ )}
+ {device.remoteIp && (
+
+ IP:
+ {device.remoteIp}
+
+ )}
+ >
)}
Requested:
diff --git a/src/gateway/r2.ts b/src/gateway/r2.ts
index a506654e3..455301fe4 100644
--- a/src/gateway/r2.ts
+++ b/src/gateway/r2.ts
@@ -5,6 +5,13 @@ import { getR2BucketName } from '../config';
const RCLONE_CONF_PATH = '/root/.config/rclone/rclone.conf';
const CONFIGURED_FLAG = '/tmp/.rclone-configured';
+/**
+ * Check if R2_BUCKET_NAME is explicitly configured in the environment.
+ */
+export function isBucketNameConfigured(env: MoltbotEnv): boolean {
+ return !!env.R2_BUCKET_NAME;
+}
+
/**
* Ensure rclone is configured in the container for R2 access.
* Idempotent — checks for a flag file to skip re-configuration.
@@ -24,21 +31,32 @@ export async function ensureRcloneConfig(sandbox: Sandbox, env: MoltbotEnv): Pro
return true;
}
- const rcloneConfig = [
+ const bucketConfigured = isBucketNameConfigured(env);
+ const configLines = [
'[r2]',
'type = s3',
'provider = Cloudflare',
`access_key_id = ${env.R2_ACCESS_KEY_ID}`,
`secret_access_key = ${env.R2_SECRET_ACCESS_KEY}`,
`endpoint = https://${env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
- 'acl = private',
- 'no_check_bucket = true',
- ].join('\n');
+ ];
+
+ if (bucketConfigured) {
+ configLines.push(`bucket = ${getR2BucketName(env)}`);
+ } else {
+ // Fallback: use no_check_bucket flag if bucket name not configured
+ configLines.push('no_check_bucket = true');
+ }
+
+ configLines.push('acl = private');
+
+ const rcloneConfig = configLines.join('\n');
await sandbox.exec(`mkdir -p $(dirname ${RCLONE_CONF_PATH})`);
await sandbox.writeFile(RCLONE_CONF_PATH, rcloneConfig);
await sandbox.exec(`touch ${CONFIGURED_FLAG}`);
- console.log('Rclone configured for R2 bucket:', getR2BucketName(env));
+ const bucketStatus = bucketConfigured ? getR2BucketName(env) : 'default (using no_check_bucket)';
+ console.log('Rclone configured for R2 bucket:', bucketStatus);
return true;
}
diff --git a/src/gateway/sync.test.ts b/src/gateway/sync.test.ts
index 054bcd3ec..50b706a8f 100644
--- a/src/gateway/sync.test.ts
+++ b/src/gateway/sync.test.ts
@@ -23,6 +23,17 @@ describe('syncToR2', () => {
expect(result.success).toBe(false);
expect(result.error).toBe('R2 storage is not configured');
});
+
+ it('returns error when bucket name is not configured', async () => {
+ const { sandbox, execMock } = createMockSandbox();
+ execMock.mockResolvedValueOnce(createMockExecResult('yes')); // rclone configured
+
+ const env = createMockEnvWithR2({ R2_BUCKET_NAME: undefined });
+ const result = await syncToR2(sandbox, env);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('bucket name is not configured');
+ });
});
describe('config detection', () => {
diff --git a/src/gateway/sync.ts b/src/gateway/sync.ts
index 99a2f6498..4167c4bf3 100644
--- a/src/gateway/sync.ts
+++ b/src/gateway/sync.ts
@@ -1,7 +1,7 @@
import type { Sandbox } from '@cloudflare/sandbox';
import type { MoltbotEnv } from '../types';
import { getR2BucketName } from '../config';
-import { ensureRcloneConfig } from './r2';
+import { ensureRcloneConfig, isBucketNameConfigured } from './r2';
export interface SyncResult {
success: boolean;
@@ -40,6 +40,14 @@ export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise {
// Ensure moltbot is running first
await ensureMoltbotGateway(sandbox, c.env);
- // Run OpenClaw CLI to list devices
- // Must specify --url and --token (OpenClaw v2026.2.3 requires explicit credentials with --url)
const token = c.env.MOLTBOT_GATEWAY_TOKEN;
const tokenArg = token ? ` --token ${token}` : '';
- const proc = await sandbox.startProcess(
+
+ // Fetch both device list and pairing list
+ let deviceList = { pending: [], paired: [] };
+ let pairingList = { pending: [], paired: [] };
+
+ // 1. Fetch devices list
+ let proc = await sandbox.startProcess(
`openclaw devices list --json --url ws://localhost:18789${tokenArg}`,
);
await waitForProcess(proc, CLI_TIMEOUT_MS);
-
- const logs = await proc.getLogs();
- const stdout = logs.stdout || '';
- const stderr = logs.stderr || '';
-
- // Try to parse JSON output
+
+ let logs = await proc.getLogs();
+ let stdout = logs.stdout || '';
+ const deviceStderr = logs.stderr || '';
+
try {
- // Find JSON in output (may have other log lines)
const jsonMatch = stdout.match(/\{[\s\S]*\}/);
if (jsonMatch) {
- const data = JSON.parse(jsonMatch[0]);
- return c.json(data);
+ deviceList = JSON.parse(jsonMatch[0]);
+ }
+ } catch {
+ // Device list parsing failed, will use empty list
+ }
+
+ // 2. Fetch pairing list (for Telegram, Discord, Slack, etc.)
+ proc = await sandbox.startProcess(
+ `openclaw pairing list --json --url ws://localhost:18789${tokenArg}`,
+ );
+ await waitForProcess(proc, CLI_TIMEOUT_MS);
+
+ logs = await proc.getLogs();
+ stdout = logs.stdout || '';
+ const pairingStderr = logs.stderr || '';
+
+ try {
+ const jsonMatch = stdout.match(/\{[\s\S]*\}/);
+ if (jsonMatch) {
+ pairingList = JSON.parse(jsonMatch[0]);
}
-
- // If no JSON found, return raw output for debugging
- return c.json({
- pending: [],
- paired: [],
- raw: stdout,
- stderr,
- });
} catch {
- return c.json({
- pending: [],
- paired: [],
- raw: stdout,
- stderr,
- parseError: 'Failed to parse CLI output',
- });
+ // Pairing list parsing failed, will use empty list
}
+
+ // 3. Merge results: combine device and channel pairings
+ const allPending = [
+ ...(deviceList.pending || []),
+ ...(pairingList.pending || []).map((p: any) => ({
+ ...p,
+ _type: 'channel', // Mark as channel pairing for UI
+ requestId: p.requestId || `${p.channel}:${p.code}`,
+ })),
+ ];
+
+ const allPaired = [
+ ...(deviceList.paired || []),
+ ...(pairingList.paired || []).map((p: any) => ({
+ ...p,
+ _type: 'channel', // Mark as channel pairing for UI
+ deviceId: p.deviceId || `${p.channel}:${p.code}`,
+ })),
+ ];
+
+ return c.json({
+ pending: allPending,
+ paired: allPaired,
+ raw: {
+ devices: deviceList,
+ pairings: pairingList,
+ },
+ });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return c.json({ error: errorMessage }, 500);
}
});
-// POST /api/admin/devices/:requestId/approve - Approve a pending device
+// POST /api/admin/devices/:requestId/approve - Approve a pending device or channel pairing
adminApi.post('/devices/:requestId/approve', async (c) => {
const sandbox = c.get('sandbox');
const requestId = c.req.param('requestId');
@@ -89,25 +122,65 @@ adminApi.post('/devices/:requestId/approve', async (c) => {
// Ensure moltbot is running first
await ensureMoltbotGateway(sandbox, c.env);
- // Run OpenClaw CLI to approve the device
const token = c.env.MOLTBOT_GATEWAY_TOKEN;
const tokenArg = token ? ` --token ${token}` : '';
- const proc = await sandbox.startProcess(
- `openclaw devices approve ${requestId} --url ws://localhost:18789${tokenArg}`,
- );
+
+ // Detect if this is a channel pairing (format: "channel:code" or "channel code")
+ const isChannelPairing = requestId.includes(':') || /^[a-z]+ \d+$/.test(requestId);
+
+ let proc;
+ let commandUsed = 'devices';
+
+ if (isChannelPairing) {
+ // For channel pairings, use pairing approve
+ // Format: "telegram:123456789" or "telegram 123456789"
+ const approveArg = requestId.replace(':', ' ');
+ proc = await sandbox.startProcess(
+ `openclaw pairing approve ${approveArg} --url ws://localhost:18789${tokenArg}`,
+ );
+ commandUsed = 'pairing';
+ } else {
+ // For device pairings, use devices approve
+ proc = await sandbox.startProcess(
+ `openclaw devices approve ${requestId} --url ws://localhost:18789${tokenArg}`,
+ );
+ commandUsed = 'devices';
+ }
+
await waitForProcess(proc, CLI_TIMEOUT_MS);
const logs = await proc.getLogs();
- const stdout = logs.stdout || '';
+ let stdout = logs.stdout || '';
const stderr = logs.stderr || '';
+ // If first attempt failed, try the other command
+ if (!stdout.toLowerCase().includes('approved') && proc.exitCode !== 0) {
+ const fallbackCommand = commandUsed === 'devices' ? 'pairing' : 'devices';
+
+ if (fallbackCommand === 'pairing') {
+ const approveArg = requestId.replace(':', ' ');
+ proc = await sandbox.startProcess(
+ `openclaw pairing approve ${approveArg} --url ws://localhost:18789${tokenArg}`,
+ );
+ } else {
+ proc = await sandbox.startProcess(
+ `openclaw devices approve ${requestId} --url ws://localhost:18789${tokenArg}`,
+ );
+ }
+
+ await waitForProcess(proc, CLI_TIMEOUT_MS);
+ const fallbackLogs = await proc.getLogs();
+ stdout = fallbackLogs.stdout || '';
+ }
+
// Check for success indicators (case-insensitive, CLI outputs "Approved ...")
const success = stdout.toLowerCase().includes('approved') || proc.exitCode === 0;
return c.json({
success,
requestId,
- message: success ? 'Device approved' : 'Approval may have failed',
+ command: commandUsed,
+ message: success ? 'Device/pairing approved' : 'Approval may have failed',
stdout,
stderr,
});
@@ -199,11 +272,13 @@ adminApi.get('/storage', async (c) => {
c.env.R2_SECRET_ACCESS_KEY &&
c.env.CF_ACCOUNT_ID
);
+ const hasBucketName = !!c.env.R2_BUCKET_NAME;
const missing: string[] = [];
if (!c.env.R2_ACCESS_KEY_ID) missing.push('R2_ACCESS_KEY_ID');
if (!c.env.R2_SECRET_ACCESS_KEY) missing.push('R2_SECRET_ACCESS_KEY');
if (!c.env.CF_ACCOUNT_ID) missing.push('CF_ACCOUNT_ID');
+ if (!hasBucketName) missing.push('R2_BUCKET_NAME');
let lastSync: string | null = null;
@@ -220,12 +295,12 @@ adminApi.get('/storage', async (c) => {
}
return c.json({
- configured: hasCredentials,
+ configured: hasCredentials && hasBucketName,
missing: missing.length > 0 ? missing : undefined,
lastSync,
- message: hasCredentials
+ message: hasCredentials && hasBucketName
? 'R2 storage is configured. Your data will persist across container restarts.'
- : 'R2 storage is not configured. Paired devices and conversations will be lost when the container restarts.',
+ : 'R2 storage is not fully configured. Paired devices and conversations will be lost when the container restarts.',
});
});
diff --git a/src/routes/debug.ts b/src/routes/debug.ts
index 8ffc05bfb..111096cf4 100644
--- a/src/routes/debug.ts
+++ b/src/routes/debug.ts
@@ -386,4 +386,137 @@ debug.get('/container-config', async (c) => {
}
});
+// GET /debug/r2-test - Test R2 configuration and connectivity
+debug.get('/r2-test', async (c) => {
+ const sandbox = c.get('sandbox');
+ const env = c.env;
+
+ const result: Record = {
+ credentials: {
+ has_access_key: !!env.R2_ACCESS_KEY_ID,
+ has_secret_key: !!env.R2_SECRET_ACCESS_KEY,
+ has_account_id: !!env.CF_ACCOUNT_ID,
+ has_bucket_name: !!env.R2_BUCKET_NAME,
+ bucket_name: env.R2_BUCKET_NAME || 'moltbot-data (default)',
+ },
+ rclone_config: null as unknown,
+ rclone_version: null as unknown,
+ r2_list_test: null as unknown,
+ };
+
+ try {
+ // Check rclone config
+ const configCheck = await sandbox.exec('cat /root/.config/rclone/rclone.conf 2>/dev/null || echo "NOT_FOUND"');
+ if (configCheck.stdout && configCheck.stdout !== 'NOT_FOUND') {
+ result.rclone_config = configCheck.stdout;
+ } else {
+ result.rclone_config = 'File not found - rclone not configured';
+ }
+
+ // Check rclone version
+ const versionCheck = await sandbox.exec('rclone --version 2>&1 | head -1');
+ result.rclone_version = versionCheck.stdout?.trim();
+
+ // Test rclone ls (basic connectivity)
+ if (env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.CF_ACCOUNT_ID) {
+ const bucket = env.R2_BUCKET_NAME || 'moltbot-data';
+ const listTest = await sandbox.exec(
+ `rclone ls "r2:${bucket}/" --max-depth=1 2>&1 | head -20`,
+ { timeout: 10000 },
+ );
+ result.r2_list_test = {
+ success: listTest.success,
+ stdout: listTest.stdout || '(empty)',
+ stderr: listTest.stderr ? listTest.stderr.slice(-200) : '(none)',
+ };
+ } else {
+ result.r2_list_test = { error: 'Missing R2 credentials' };
+ }
+ } catch (error) {
+ result.error = error instanceof Error ? error.message : 'Unknown error';
+ }
+
+ return c.json(result);
+});
+
+// GET /debug/devices - Raw device list for troubleshooting pending pairing
+debug.get('/devices', async (c) => {
+ const sandbox = c.get('sandbox');
+ const env = c.env;
+
+ // Check if debug routes are enabled
+ if (env.DEBUG_ROUTES !== 'true') {
+ return c.json({
+ error: 'Debug routes are disabled',
+ hint: 'Set DEBUG_ROUTES=true in .dev.vars or wrangler secrets',
+ }, 404);
+ }
+
+ try {
+ // Ensure gateway is running
+ const gatewayProcess = await findExistingMoltbotProcess(sandbox);
+ if (!gatewayProcess || gatewayProcess.status !== 'running') {
+ return c.json({
+ error: 'Gateway is not running',
+ status: gatewayProcess?.status || 'not found',
+ }, 503);
+ }
+
+ const token = env.MOLTBOT_GATEWAY_TOKEN;
+ const tokenArg = token ? ` --token ${token}` : '';
+
+ // 1. Fetch devices list
+ let proc = await sandbox.startProcess(
+ `openclaw devices list --json --url ws://localhost:18789${tokenArg}`,
+ );
+ await waitForProcess(proc, 20000);
+ let logs = await proc.getLogs();
+ const devicesOutput = logs.stdout || '';
+ const devicesStderr = logs.stderr || '';
+
+ // 2. Fetch pairing list
+ proc = await sandbox.startProcess(
+ `openclaw pairing list --json --url ws://localhost:18789${tokenArg}`,
+ );
+ await waitForProcess(proc, 20000);
+ logs = await proc.getLogs();
+ const pairingOutput = logs.stdout || '';
+ const pairingStderr = logs.stderr || '';
+
+ return c.json({
+ gateway_token_set: !!token,
+ devices: {
+ exit_code: proc.exitCode,
+ stdout: devicesOutput.slice(0, 3000),
+ stderr: devicesStderr.slice(0, 1000),
+ parsed: (() => {
+ try {
+ const match = devicesOutput.match(/\{[\s\S]*\}/);
+ return match ? JSON.parse(match[0]) : null;
+ } catch {
+ return null;
+ }
+ })(),
+ },
+ pairing: {
+ exit_code: proc.exitCode,
+ stdout: pairingOutput.slice(0, 3000),
+ stderr: pairingStderr.slice(0, 1000),
+ parsed: (() => {
+ try {
+ const match = pairingOutput.match(/\{[\s\S]*\}/);
+ return match ? JSON.parse(match[0]) : null;
+ } catch {
+ return null;
+ }
+ })(),
+ },
+ });
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ return c.json({ error: errorMessage }, 500);
+ }
+});
+
export { debug };
+
diff --git a/src/test-utils.ts b/src/test-utils.ts
index 057609aa9..022d852de 100644
--- a/src/test-utils.ts
+++ b/src/test-utils.ts
@@ -19,6 +19,7 @@ export function createMockEnvWithR2(overrides: Partial = {}): Moltbo
R2_ACCESS_KEY_ID: 'test-key-id',
R2_SECRET_ACCESS_KEY: 'test-secret-key',
CF_ACCOUNT_ID: 'test-account-id',
+ R2_BUCKET_NAME: 'moltbot-data',
...overrides,
});
}
diff --git a/start-openclaw.sh b/start-openclaw.sh
index c862a80ce..286eb7f1f 100644
--- a/start-openclaw.sh
+++ b/start-openclaw.sh
@@ -33,22 +33,41 @@ r2_configured() {
[ -n "$R2_ACCESS_KEY_ID" ] && [ -n "$R2_SECRET_ACCESS_KEY" ] && [ -n "$CF_ACCOUNT_ID" ]
}
-R2_BUCKET="${R2_BUCKET_NAME:-moltbot-data}"
+# Use explicit bucket name if set, otherwise use default
+R2_BUCKET_DEFAULT="${R2_BUCKET_NAME:-moltbot-data}"
setup_rclone() {
mkdir -p "$(dirname "$RCLONE_CONF")"
- cat > "$RCLONE_CONF" << EOF
+
+ # Write rclone config - determine bucket setting based on configuration
+ if [ -n "$R2_BUCKET_NAME" ]; then
+ cat > "$RCLONE_CONF" << EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = $R2_ACCESS_KEY_ID
secret_access_key = $R2_SECRET_ACCESS_KEY
endpoint = https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com
+bucket = $R2_BUCKET_NAME
acl = private
+EOF
+ echo "Rclone configured for bucket: $R2_BUCKET_NAME"
+ else
+ # Fallback: use no_check_bucket flag if bucket name not configured
+ cat > "$RCLONE_CONF" << EOF
+[r2]
+type = s3
+provider = Cloudflare
+access_key_id = $R2_ACCESS_KEY_ID
+secret_access_key = $R2_SECRET_ACCESS_KEY
+endpoint = https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com
no_check_bucket = true
+acl = private
EOF
+ echo "Rclone configured with no_check_bucket fallback (R2_BUCKET_NAME not set)"
+ fi
+
touch /tmp/.rclone-configured
- echo "Rclone configured for bucket: $R2_BUCKET"
}
RCLONE_FLAGS="--transfers=16 --fast-list --s3-no-check-bucket"
@@ -62,13 +81,13 @@ if r2_configured; then
echo "Checking R2 for existing backup..."
# Check if R2 has an openclaw config backup
- if rclone ls "r2:${R2_BUCKET}/openclaw/openclaw.json" $RCLONE_FLAGS 2>/dev/null | grep -q openclaw.json; then
+ if rclone ls "r2:${R2_BUCKET_DEFAULT}/openclaw/openclaw.json" $RCLONE_FLAGS 2>/dev/null | grep -q openclaw.json; then
echo "Restoring config from R2..."
- rclone copy "r2:${R2_BUCKET}/openclaw/" "$CONFIG_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: config restore failed with exit code $?"
+ rclone copy "r2:${R2_BUCKET_DEFAULT}/openclaw/" "$CONFIG_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: config restore failed with exit code $?"
echo "Config restored"
- elif rclone ls "r2:${R2_BUCKET}/clawdbot/clawdbot.json" $RCLONE_FLAGS 2>/dev/null | grep -q clawdbot.json; then
+ elif rclone ls "r2:${R2_BUCKET_DEFAULT}/clawdbot/clawdbot.json" $RCLONE_FLAGS 2>/dev/null | grep -q clawdbot.json; then
echo "Restoring from legacy R2 backup..."
- rclone copy "r2:${R2_BUCKET}/clawdbot/" "$CONFIG_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: legacy config restore failed with exit code $?"
+ rclone copy "r2:${R2_BUCKET_DEFAULT}/clawdbot/" "$CONFIG_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: legacy config restore failed with exit code $?"
if [ -f "$CONFIG_DIR/clawdbot.json" ] && [ ! -f "$CONFIG_FILE" ]; then
mv "$CONFIG_DIR/clawdbot.json" "$CONFIG_FILE"
fi
@@ -78,20 +97,20 @@ if r2_configured; then
fi
# Restore workspace
- REMOTE_WS_COUNT=$(rclone ls "r2:${R2_BUCKET}/workspace/" $RCLONE_FLAGS 2>/dev/null | wc -l)
+ REMOTE_WS_COUNT=$(rclone ls "r2:${R2_BUCKET_DEFAULT}/workspace/" $RCLONE_FLAGS 2>/dev/null | wc -l)
if [ "$REMOTE_WS_COUNT" -gt 0 ]; then
echo "Restoring workspace from R2 ($REMOTE_WS_COUNT files)..."
mkdir -p "$WORKSPACE_DIR"
- rclone copy "r2:${R2_BUCKET}/workspace/" "$WORKSPACE_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: workspace restore failed with exit code $?"
+ rclone copy "r2:${R2_BUCKET_DEFAULT}/workspace/" "$WORKSPACE_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: workspace restore failed with exit code $?"
echo "Workspace restored"
fi
# Restore skills
- REMOTE_SK_COUNT=$(rclone ls "r2:${R2_BUCKET}/skills/" $RCLONE_FLAGS 2>/dev/null | wc -l)
+ REMOTE_SK_COUNT=$(rclone ls "r2:${R2_BUCKET_DEFAULT}/skills/" $RCLONE_FLAGS 2>/dev/null | wc -l)
if [ "$REMOTE_SK_COUNT" -gt 0 ]; then
echo "Restoring skills from R2 ($REMOTE_SK_COUNT files)..."
mkdir -p "$SKILLS_DIR"
- rclone copy "r2:${R2_BUCKET}/skills/" "$SKILLS_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: skills restore failed with exit code $?"
+ rclone copy "r2:${R2_BUCKET_DEFAULT}/skills/" "$SKILLS_DIR/" $RCLONE_FLAGS -v 2>&1 || echo "WARNING: skills restore failed with exit code $?"
echo "Skills restored"
fi
else
@@ -290,14 +309,14 @@ if r2_configured; then
if [ "$COUNT" -gt 0 ]; then
echo "[sync] Uploading changes ($COUNT files) at $(date)" >> "$LOGFILE"
- rclone sync "$CONFIG_DIR/" "r2:${R2_BUCKET}/openclaw/" \
+ rclone sync "$CONFIG_DIR/" "r2:${R2_BUCKET_DEFAULT}/openclaw/" \
$RCLONE_FLAGS --exclude='*.lock' --exclude='*.log' --exclude='*.tmp' --exclude='.git/**' 2>> "$LOGFILE"
if [ -d "$WORKSPACE_DIR" ]; then
- rclone sync "$WORKSPACE_DIR/" "r2:${R2_BUCKET}/workspace/" \
+ rclone sync "$WORKSPACE_DIR/" "r2:${R2_BUCKET_DEFAULT}/workspace/" \
$RCLONE_FLAGS --exclude='skills/**' --exclude='.git/**' --exclude='node_modules/**' 2>> "$LOGFILE"
fi
if [ -d "$SKILLS_DIR" ]; then
- rclone sync "$SKILLS_DIR/" "r2:${R2_BUCKET}/skills/" \
+ rclone sync "$SKILLS_DIR/" "r2:${R2_BUCKET_DEFAULT}/skills/" \
$RCLONE_FLAGS 2>> "$LOGFILE"
fi
date -Iseconds > "$LAST_SYNC_FILE"
From 71adf82a2a368be681d1c18d60a28eceb3074290 Mon Sep 17 00:00:00 2001
From: MunaLombe <3223352545@qq.com>
Date: Wed, 4 Mar 2026 15:42:28 +0300
Subject: [PATCH 09/10] rebasing push
---
.RALPH/README.md | 22 ++++++++
.RALPH/decisions.md | 71 ++++++++++++++++++++++++++
.RALPH/patterns.md | 121 ++++++++++++++++++++++++++++++++++++++++++++
.RALPH/problems.md | 98 +++++++++++++++++++++++++++++++++++
4 files changed, 312 insertions(+)
create mode 100644 .RALPH/README.md
create mode 100644 .RALPH/decisions.md
create mode 100644 .RALPH/patterns.md
create mode 100644 .RALPH/problems.md
diff --git a/.RALPH/README.md b/.RALPH/README.md
new file mode 100644
index 000000000..e7ffde15b
--- /dev/null
+++ b/.RALPH/README.md
@@ -0,0 +1,22 @@
+# .RALPH – moltworker Pattern Library
+
+This is the project-level knowledge base for `moltworker` (the OpenClaw-based Cloudflare
+Worker + Sandbox project). Every validated pattern, recurring problem, and architectural
+decision from this project is logged here.
+
+> For the future **nanoworker** project, see `nanoworker/.RALPH/` — it inherits and
+> extends many of these patterns.
+
+## Rules for agents
+
+1. **Before trying an approach**, check `patterns.md` and `problems.md`.
+2. **After solving a non-trivial problem**, add an entry here.
+3. **After making an architectural decision**, log it in `decisions.md`.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `patterns.md` | Validated reusable implementation strategies |
+| `problems.md` | Recurring problems and their confirmed solutions |
+| `decisions.md` | Architectural decisions with rationale |
diff --git a/.RALPH/decisions.md b/.RALPH/decisions.md
new file mode 100644
index 000000000..b0d56fda6
--- /dev/null
+++ b/.RALPH/decisions.md
@@ -0,0 +1,71 @@
+# Architectural Decisions – moltworker
+
+---
+
+## ADR-001 – Config patcher runs unconditionally on every container boot
+
+**Date**: 2026-03-03
+**Status**: Accepted
+
+**Context**: Should the Node.js config patcher in `start-openclaw.sh` run only when
+no config exists (i.e. first boot), or unconditionally?
+
+**Decision**: Unconditionally, after any R2 restore and before the gateway starts.
+
+**Rationale**: Running it conditionally means that changing a Cloudflare secret requires
+manually deleting the R2 config to force re-onboard. This is error-prone and was the
+direct cause of PROB-001 and PROB-002 in production. Running it unconditionally means
+`wrangler secret put` + `npm run deploy` is always sufficient to propagate new secret values.
+
+**Trade-offs**: Startup adds a small overhead (~50 ms for the Node.js one-shot). Manual
+in-container edits to patched fields (provider apiKey, channel tokens, gateway token) will
+be overwritten on next restart. This is documented and acceptable.
+
+---
+
+## ADR-002 – Use rclone (not rsync or s3fs) for R2 persistence
+
+**Date**: 2026-03-03
+**Status**: Accepted
+
+**Context**: The container needs to persist OpenClaw config and workspace to R2 across restarts.
+
+**Decision**: rclone with `--fast-list --s3-no-check-bucket`, not rsync or s3fs mount.
+
+**Rationale**: R2 does not support setting file timestamps. `rsync -a` (which preserves
+timestamps) fails with I/O errors against R2 (PROB-004). rclone works correctly with R2
+by default and does not attempt to set timestamps.
+
+---
+
+## ADR-003 – CF AI Gateway requires `CF_AI_GATEWAY_MODEL` to be explicitly set
+
+**Date**: 2026-03-03
+**Status**: Accepted
+
+**Context**: Should the config patcher try to infer the model from other config,
+or require an explicit `CF_AI_GATEWAY_MODEL` env var?
+
+**Decision**: Require explicit `CF_AI_GATEWAY_MODEL` (format: `{provider}/{model}`).
+
+**Rationale**: Inferring the model is ambiguous and error-prone. An explicit var makes
+the configuration unambiguous, testable, and easy to change without touching code.
+The format `{provider}/{model}` allows the patcher to construct the correct gateway base URL
+and set the correct `api` mode (`anthropic-messages` vs `openai-completions`).
+
+---
+
+## ADR-004 – `MOLTBOT_GATEWAY_TOKEN` is mapped to `OPENCLAW_GATEWAY_TOKEN` in the container
+
+**Date**: 2026-03-03
+**Status**: Accepted
+
+**Context**: The Worker-facing secret is named `MOLTBOT_GATEWAY_TOKEN` (worker-level
+naming convention). The OpenClaw container expects `OPENCLAW_GATEWAY_TOKEN`.
+
+**Decision**: `buildEnvVars()` maps `MOLTBOT_GATEWAY_TOKEN` → `OPENCLAW_GATEWAY_TOKEN`.
+The `start-openclaw.sh` script reads `OPENCLAW_GATEWAY_TOKEN` internally.
+
+**Rationale**: Keeps the Worker env namespace decoupled from the container's internal
+naming. If OpenClaw is ever replaced, only `buildEnvVars()` needs to change, not the
+Worker-facing secret name.
diff --git a/.RALPH/patterns.md b/.RALPH/patterns.md
new file mode 100644
index 000000000..61d0aad23
--- /dev/null
+++ b/.RALPH/patterns.md
@@ -0,0 +1,121 @@
+# Validated Patterns – moltworker
+
+---
+
+## P-001 – Inline config patcher (always runs on every container boot)
+
+**Date**: 2026-03-03
+**Context**: OpenClaw reads provider config from `~/.openclaw/openclaw.json`. Secrets live
+in Cloudflare Worker env and must reach the container. The container may have a persisted
+config from R2 which must not be fully overwritten.
+
+**Approach**: In `start-openclaw.sh`, after the R2 restore, run an inline Node.js heredoc
+that reads the existing config, writes/overrides only the sections it owns (provider entry,
+gateway auth, channels), and writes it back. This runs **unconditionally** — not just on
+first boot.
+
+**Location**: `start-openclaw.sh` lines 141–265
+**Result**: ✅ Validated. Fixes stale R2 config issues (PROB-002). Ensures new secrets
+take effect on next container restart after redeploy.
+
+**Caveats**:
+- Patcher must not write fields that fail OpenClaw's strict config validation (PROB-006).
+- Patcher must be idempotent (running twice produces the same output).
+- Test: run `openclaw status` after patching; non-zero exit = bad config.
+
+---
+
+## P-002 – CF AI Gateway provider injection via config patcher
+
+**Date**: 2026-03-03
+**Context**: Using Cloudflare AI Gateway as the model provider. Requires building a provider
+entry in `openclaw.json` with a `baseUrl`, `apiKey`, and `models` array.
+
+**Approach**: In the patcher, detect `CF_AI_GATEWAY_MODEL` (format: `{provider}/{model}`).
+Extract the provider prefix and model ID. Build the base URL:
+```
+https://gateway.ai.cloudflare.com/v1/{CF_AI_GATEWAY_ACCOUNT_ID}/{CF_AI_GATEWAY_GATEWAY_ID}/{provider}
+```
+Write a provider entry named `cf-ai-gw-{provider}` with:
+- `baseUrl`: gateway URL
+- `apiKey`: value of `CLOUDFLARE_AI_GATEWAY_API_KEY`
+- `api`: `"anthropic-messages"` for Anthropic provider, `"openai-completions"` otherwise
+- `models`: array with the single specified model
+
+Set `agents.defaults.model.primary` to `cf-ai-gw-{provider}/{modelId}`.
+
+**Location**: `start-openclaw.sh` lines 183–219
+**Result**: ✅ Validated. This is the working path for CF AI Gateway models.
+
+**Caveats**:
+- For `workers-ai` provider, append `/v1` to the base URL.
+- All four env vars must be set together: `CLOUDFLARE_AI_GATEWAY_API_KEY`,
+ `CF_AI_GATEWAY_ACCOUNT_ID`, `CF_AI_GATEWAY_GATEWAY_ID`, `CF_AI_GATEWAY_MODEL`.
+- `apiKey` must be non-empty — do not write an empty string.
+
+---
+
+## P-003 – Worker WebSocket proxy with token injection
+
+**Date**: 2026-03-03
+**Context**: Cloudflare Workers proxy WebSocket connections to Sandbox containers.
+CF Access redirects strip query parameters, losing the `?token=` needed by the gateway.
+
+**Approach**: In the WS proxy handler (`src/index.ts`):
+1. Check if `MOLTBOT_GATEWAY_TOKEN` is set and URL lacks `?token=`.
+2. If so, clone the URL and inject the token as `?token={value}`.
+3. Use the modified URL for `sandbox.wsConnect()`.
+4. Create a `WebSocketPair`, accept both ends, wire `message`/`close`/`error` relays.
+5. Return `new Response(null, { status: 101, webSocket: clientWs })`.
+
+**Location**: `src/index.ts` lines 283–429
+**Result**: ✅ Validated. Fixes PROB-005.
+
+**Caveats**:
+- WS close reasons must be ≤ 123 bytes (WebSocket spec); truncate if longer.
+- `containerWs` may be null if container not ready; handle gracefully.
+- Error messages from the gateway can be transformed before relaying to the client.
+
+---
+
+## P-004 – rclone for R2 config sync (not rsync)
+
+**Date**: 2026-03-03
+**Context**: Container config and workspace must persist across restarts via R2.
+
+**Approach**: Use `rclone` (not `rsync`) with these flags:
+```bash
+rclone sync "$LOCAL_DIR/" "r2:${R2_BUCKET}/{prefix}/" \
+ --transfers=16 --fast-list --s3-no-check-bucket \
+ --exclude='*.lock' --exclude='*.log' --exclude='*.tmp' --exclude='.git/**'
+```
+Background sync loop checks for changed files every 30 s via `find -newer {marker}`.
+
+**Location**: `start-openclaw.sh` lines 270–310
+**Result**: ✅ Validated. Avoids PROB-004 (timestamp errors on R2).
+
+**Caveats**:
+- Never use `rsync -a` or `rsync --times` against R2.
+- Update the marker file (`touch $MARKER`) after each sync, not before.
+- The sync loop runs in background (`&`); do not wait for it before starting gateway.
+
+---
+
+## P-005 – `buildEnvVars()` — Worker env → container env mapping
+
+**Date**: 2026-03-03
+**Context**: Worker secrets must be forwarded to the container as process env vars.
+
+**Approach**: A dedicated `buildEnvVars(env: MoltbotEnv): Record` function
+in `src/gateway/env.ts` handles all mapping logic:
+- Conditionally includes only vars that are set (no empty strings).
+- Handles provider priority: CF AI Gateway > Anthropic (with legacy AI Gateway as override).
+- Maps `MOLTBOT_GATEWAY_TOKEN` → `OPENCLAW_GATEWAY_TOKEN` (container-internal name).
+
+**Location**: `src/gateway/env.ts`
+**Result**: ✅ Validated. Well-tested (see `src/gateway/env.test.ts`).
+
+**Caveats**:
+- Never log secret values from `buildEnvVars()` output. Log `Object.keys(envVars)` only.
+- Legacy AI Gateway path (`AI_GATEWAY_API_KEY` + `AI_GATEWAY_BASE_URL`) overrides direct
+ Anthropic key when both are set — this is intentional but can be surprising.
diff --git a/.RALPH/problems.md b/.RALPH/problems.md
new file mode 100644
index 000000000..416e36ad4
--- /dev/null
+++ b/.RALPH/problems.md
@@ -0,0 +1,98 @@
+# Recurring Problems – moltworker
+
+---
+
+## PROB-001 – `"x-api-key header is required"` on model calls
+
+**Date**: 2026-03-03
+**Symptom**:
+```json
+{ "type": "error", "error": { "type": "authentication_error", "message": "x-api-key header is required" } }
+```
+**Root causes (ordered by likelihood)**:
+
+1. **`CF_AI_GATEWAY_MODEL` not set** — Without this var, the inline Node.js config patcher
+ in `start-openclaw.sh` never creates the `cf-ai-gw-{provider}` provider entry with `apiKey`.
+ Fix: `wrangler secret put CF_AI_GATEWAY_MODEL` (format: `{provider}/{model}`) → redeploy.
+
+2. **API key secret missing from deployed worker** — Key only exists in `.dev.vars`, not
+ set via `wrangler secret put`. Fix: `wrangler secret put ANTHROPIC_API_KEY` → redeploy.
+
+3. **Stale R2 config** — First deploy ran with no key; a keyless provider entry was written to R2.
+ Subsequent boots skip `openclaw onboard` and load the stale config. The inline Node patcher
+ (which always runs) should overwrite this — if it doesn't, check that `CF_AI_GATEWAY_MODEL`
+ is set so the patcher block is triggered.
+
+4. **Two provider entries — agent using the keyless one** — Config has both the stale keyless
+ `cloudflare-ai-gateway` provider AND the correctly keyed `cf-ai-gw-anthropic` provider,
+ but `agents.defaults.model.primary` points to the keyless one. Fix: verify
+ `/debug/container-config` and ensure `agents.defaults.model.primary` matches the entry
+ with a non-empty `apiKey`.
+
+5. **Deploy cancelled (Ctrl-C)** — Secret was set but deploy never completed. Old worker
+ version is still running. Fix: run `npm run deploy` again and let it complete.
+
+**Verification**: `GET /_admin/` is not relevant. Hit `/debug/container-config` and inspect
+`models.providers.{name}.apiKey` — must be non-empty.
+
+---
+
+## PROB-002 – Stale R2 config not updated after adding new secrets
+
+**Date**: 2026-03-03
+**Symptom**: After setting new Cloudflare secrets and redeploying, the container behaves as
+if the secrets are not there. `/debug/container-config` shows old values.
+**Cause**: `start-openclaw.sh` only runs `openclaw onboard` if no config exists. R2-persisted
+config survives redeploy. Onboard is skipped; new secrets are never applied.
+**Fix**: The inline Node patcher in `start-openclaw.sh` always runs and overwrites provider
+entries from the current env. Ensure the patcher logic covers the field you changed.
+If the patcher doesn't cover it, add it.
+
+---
+
+## PROB-003 – Deploy interrupted by Ctrl-C; new secrets not live
+
+**Date**: 2026-03-03
+**Symptom**: Secret added via `wrangler secret put` but issue persists after what looks like
+a deploy. `wrangler tail` shows `Has ANTHROPIC_API_KEY: false`.
+**Cause**: `npm run deploy` was interrupted. The old worker version is still serving.
+`wrangler secret put` succeeds independently of deploy; the worker must be redeployed to
+pick up the new secret.
+**Fix**: `npm run deploy` — let it run to completion. Verify with `wrangler tail`.
+
+---
+
+## PROB-004 – rclone/rsync fails with "Input/output error" on R2
+
+**Date**: 2026-03-03
+**Symptom**: R2 sync exits non-zero with timestamp-related errors.
+**Cause**: R2 does not support setting file timestamps. `rsync -a` preserves timestamps
+and fails.
+**Fix**: Use `rclone sync` with `--transfers=16 --fast-list --s3-no-check-bucket`.
+Never use `rsync -a` or `rsync --times` against R2.
+
+---
+
+## PROB-005 – WebSocket drops immediately after CF Access redirect
+
+**Date**: 2026-03-03
+**Symptom**: User authenticates via CF Access and is redirected, but WebSocket connections
+fail with code 1006 or 4001.
+**Cause**: CF Access redirects strip query parameters. `?token=` is lost.
+**Fix**: In `src/index.ts` WS proxy handler, inject the token server-side before calling
+`sandbox.wsConnect()` — already implemented. Confirm `MOLTBOT_GATEWAY_TOKEN` is set as
+a Worker secret.
+
+---
+
+## PROB-006 – OpenClaw config validation fails after manual edits or patcher bugs
+
+**Date**: 2026-03-03
+**Symptom**: Gateway fails to start; logs show config parsing/validation error from OpenClaw.
+**Common causes**:
+- `agents.defaults.model` set to a bare string instead of `{ "primary": "provider/model" }`.
+- Provider entry missing `models` array or `api` field.
+- Channel config containing stale keys from an old backup.
+- Empty string written for `apiKey` (some OpenClaw versions reject this).
+**Fix**: Use `/debug/container-config` to inspect the config. Fix `start-openclaw.sh`
+patcher to not write the offending field, or write it correctly.
From ac054e82d7cdc48d41968ace405ee513ddca62d1 Mon Sep 17 00:00:00 2001
From: MunaLombe <3223352545@qq.com>
Date: Wed, 4 Mar 2026 15:45:28 +0300
Subject: [PATCH 10/10] blabla cra
---
start-openclaw.sh | 1 +
1 file changed, 1 insertion(+)
diff --git a/start-openclaw.sh b/start-openclaw.sh
index c862a80ce..de6e8c68c 100644
--- a/start-openclaw.sh
+++ b/start-openclaw.sh
@@ -35,6 +35,7 @@ r2_configured() {
R2_BUCKET="${R2_BUCKET_NAME:-moltbot-data}"
+
setup_rclone() {
mkdir -p "$(dirname "$RCLONE_CONF")"
cat > "$RCLONE_CONF" << EOF