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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ RUN mkdir -p /root/repos
RUN npm install -g pnpm

# Install OpenClaw (formerly clawdbot/moltbot)
RUN npm install -g openclaw@2026.2.3 \
RUN npm install -g openclaw@2026.2.6-3 \
&& openclaw --version

# Create OpenClaw directories
Expand All @@ -46,7 +46,7 @@ RUN mkdir -p /root/.openclaw \
&& mkdir -p /root/clawd \
&& mkdir -p /root/clawd/skills

# Build cache bust: 2026-02-15-openclaw-rclone
# Build cache bust: 2026-02-24-openclaw-upgrade
COPY start-openclaw.sh /usr/local/bin/start-openclaw.sh
RUN chmod +x /usr/local/bin/start-openclaw.sh

Expand Down
97 changes: 62 additions & 35 deletions src/gateway/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface SyncResult {

const RCLONE_FLAGS = '--transfers=16 --fast-list --s3-no-check-bucket';
const LAST_SYNC_FILE = '/tmp/.last-sync';
const SYNC_LOCK_FILE = '/tmp/.r2-sync.lock';
const SYNC_LOCK_STALE_SECONDS = 300; // 5 min — consider lock stale after this

function rcloneRemote(env: MoltbotEnv, prefix: string): string {
return `r2:${getR2BucketName(env)}/${prefix}`;
Expand Down Expand Up @@ -40,46 +42,71 @@ export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise<SyncR
return { success: false, error: 'R2 storage is not configured' };
}

const configDir = await detectConfigDir(sandbox);
if (!configDir) {
return {
success: false,
error: 'Sync aborted: no config file found',
details: 'Neither openclaw.json nor clawdbot.json found in config directory.',
};
// Concurrency guard: prevent overlapping syncs via container-level lock file.
// Stale locks (> 5 min) are automatically cleaned up.
const lockCheck = await sandbox.exec(
`if [ -f ${SYNC_LOCK_FILE} ]; then ` +
`age=$(($(date +%s) - $(stat -c %Y ${SYNC_LOCK_FILE} 2>/dev/null || echo 0))); ` +
`if [ "$age" -lt ${SYNC_LOCK_STALE_SECONDS} ]; then echo locked; else echo stale; fi; ` +
`else echo free; fi`,
);
const lockState = lockCheck.stdout?.trim();
if (lockState === 'locked') {
console.log('[sync] Another sync is in progress, skipping');
return { success: false, error: 'Sync already in progress' };
}
if (lockState === 'stale') {
console.log('[sync] Cleaning up stale sync lock');
}

const remote = (prefix: string) => rcloneRemote(env, prefix);
// Acquire lock
await sandbox.exec(`echo $$ > ${SYNC_LOCK_FILE}`);

// Sync config (rclone sync propagates deletions)
const configResult = await sandbox.exec(
`rclone sync ${configDir}/ ${remote('openclaw/')} ${RCLONE_FLAGS} --exclude='*.lock' --exclude='*.log' --exclude='*.tmp' --exclude='.git/**'`,
{ timeout: 120000 },
);
if (!configResult.success) {
return {
success: false,
error: 'Config sync failed',
details: configResult.stderr?.slice(-500),
};
}
try {
const configDir = await detectConfigDir(sandbox);
if (!configDir) {
return {
success: false,
error: 'Sync aborted: no config file found',
details: 'Neither openclaw.json nor clawdbot.json found in config directory.',
};
}

// Sync workspace (non-fatal, rclone sync propagates deletions)
await sandbox.exec(
`test -d /root/clawd && rclone sync /root/clawd/ ${remote('workspace/')} ${RCLONE_FLAGS} --exclude='skills/**' --exclude='.git/**' || true`,
{ timeout: 120000 },
);
const remote = (prefix: string) => rcloneRemote(env, prefix);

// Sync skills (non-fatal)
await sandbox.exec(
`test -d /root/clawd/skills && rclone sync /root/clawd/skills/ ${remote('skills/')} ${RCLONE_FLAGS} || true`,
{ timeout: 120000 },
);
// Sync config (rclone sync propagates deletions)
const configResult = await sandbox.exec(
`rclone sync ${configDir}/ ${remote('openclaw/')} ${RCLONE_FLAGS} --exclude='*.lock' --exclude='*.log' --exclude='*.tmp' --exclude='.git/**'`,
{ timeout: 120000 },
);
if (!configResult.success) {
return {
success: false,
error: 'Config sync failed',
details: configResult.stderr?.slice(-500),
};
}

// Sync workspace (non-fatal, rclone sync propagates deletions)
await sandbox.exec(
`test -d /root/clawd && rclone sync /root/clawd/ ${remote('workspace/')} ${RCLONE_FLAGS} --exclude='skills/**' --exclude='.git/**' || true`,
{ timeout: 120000 },
);

// Write timestamp
await sandbox.exec(`date -Iseconds > ${LAST_SYNC_FILE}`);
const tsResult = await sandbox.exec(`cat ${LAST_SYNC_FILE}`);
const lastSync = tsResult.stdout?.trim();
// Sync skills (non-fatal)
await sandbox.exec(
`test -d /root/clawd/skills && rclone sync /root/clawd/skills/ ${remote('skills/')} ${RCLONE_FLAGS} || true`,
{ timeout: 120000 },
);

return { success: true, lastSync };
// Write timestamp
await sandbox.exec(`date -Iseconds > ${LAST_SYNC_FILE}`);
const tsResult = await sandbox.exec(`cat ${LAST_SYNC_FILE}`);
const lastSync = tsResult.stdout?.trim();

return { success: true, lastSync };
} finally {
// Release lock
await sandbox.exec(`rm -f ${SYNC_LOCK_FILE}`).catch(() => {});
}
}
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { MOLTBOT_PORT } from './config';
import { createAccessMiddleware } from './auth';
import { ensureMoltbotGateway, findExistingMoltbotProcess } from './gateway';
import { publicRoutes, api, adminUi, debug, cdp, telegram, discord, dream } from './routes';
import { redactSensitiveParams } from './utils/logging';
import { redactSensitiveParams, redactWsPayload } from './utils/logging';
import loadingPageHtml from './assets/loading.html';
import configErrorHtml from './assets/config-error.html';
import { createDiscordHandler } from './discord/handler';
Expand Down Expand Up @@ -355,7 +355,7 @@ app.all('*', async (c) => {
// Relay messages from client to container
serverWs.addEventListener('message', (event) => {
if (debugLogs) {
console.log('[WS] Client -> Container:', typeof event.data, typeof event.data === 'string' ? event.data.slice(0, 200) : '(binary)');
console.log('[WS] Client -> Container:', typeof event.data, typeof event.data === 'string' ? redactWsPayload(event.data) : '(binary)');
}
if (containerWs.readyState === WebSocket.OPEN) {
containerWs.send(event.data);
Expand All @@ -367,7 +367,7 @@ app.all('*', async (c) => {
// Relay messages from container to client, with error transformation
containerWs.addEventListener('message', (event) => {
if (debugLogs) {
console.log('[WS] Container -> Client (raw):', typeof event.data, typeof event.data === 'string' ? event.data.slice(0, 500) : '(binary)');
console.log('[WS] Container -> Client (raw):', typeof event.data, typeof event.data === 'string' ? redactWsPayload(event.data, 500) : '(binary)');
}
let data = event.data;

Expand Down
64 changes: 64 additions & 0 deletions src/utils/logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Tests for logging utilities.
*/

import { describe, it, expect } from 'vitest';
import { redactSensitiveParams, redactWsPayload } from './logging';

describe('redactSensitiveParams', () => {
it('redacts token parameters', () => {
const url = new URL('https://example.com/?token=secret123&page=1');
const result = redactSensitiveParams(url);
expect(result).toContain('token=%5BREDACTED%5D');
expect(result).toContain('page=1');
expect(result).not.toContain('secret123');
});

it('returns empty string for no params', () => {
const url = new URL('https://example.com/');
expect(redactSensitiveParams(url)).toBe('');
});
});

describe('redactWsPayload', () => {
it('redacts api_key in JSON', () => {
const payload = '{"api_key":"sk-abc123","model":"gpt-4"}';
const result = redactWsPayload(payload);
expect(result).toContain('[REDACTED]');
expect(result).not.toContain('sk-abc123');
expect(result).toContain('model');
});

it('redacts token fields', () => {
const payload = '{"token":"my-secret-token","data":"hello"}';
const result = redactWsPayload(payload);
expect(result).toContain('[REDACTED]');
expect(result).not.toContain('my-secret-token');
});

it('redacts authorization fields', () => {
const payload = '{"authorization":"Bearer xyz","type":"request"}';
const result = redactWsPayload(payload);
expect(result).toContain('[REDACTED]');
expect(result).not.toContain('Bearer xyz');
});

it('passes through non-sensitive payloads', () => {
const payload = '{"message":"hello","user":"alice"}';
const result = redactWsPayload(payload);
expect(result).toBe(payload);
});

it('truncates long payloads', () => {
const payload = 'x'.repeat(500);
const result = redactWsPayload(payload, 200);
expect(result.length).toBeLessThanOrEqual(204); // 200 + "..."
expect(result).toMatch(/\.\.\.$/);
});

it('handles binary-like strings gracefully', () => {
const payload = '\x00\x01\x02binary';
const result = redactWsPayload(payload);
expect(result).toBeTruthy();
});
});
15 changes: 15 additions & 0 deletions src/utils/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,18 @@ export function redactSensitiveParams(url: URL): string {
const search = redactedParams.toString();
return search ? `?${search}` : '';
}

/** Patterns that indicate sensitive values in JSON-like text. */
const WS_SENSITIVE_PATTERN = /"(api[_-]?key|token|secret|password|authorization|credential|bearer|auth)[^"]*"\s*:\s*"[^"]+"/gi;

/**
* Redact sensitive fields from WebSocket payload strings before logging.
* Truncates to maxLen and replaces values of sensitive JSON keys with [REDACTED].
*/
export function redactWsPayload(data: string, maxLen: number = 200): string {
const truncated = data.length > maxLen ? data.slice(0, maxLen) + '...' : data;
return truncated.replace(WS_SENSITIVE_PATTERN, (match) => {
const colonIdx = match.indexOf(':');
return match.slice(0, colonIdx + 1) + ' "[REDACTED]"';
});
}
Loading