From c1928fc733fb4a4dc3360b789303ff52284dbfa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 18:17:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(infra):=20upstream=20sync=20=E2=80=94=20bum?= =?UTF-8?q?p=20OpenClaw,=20add=20WS=20redaction,=20R2=20sync=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump openclaw 2026.2.3 → 2026.2.6-3 in Dockerfile (upstream PR #204) - Add redactWsPayload() to sanitize sensitive fields (api_key, token, auth, etc.) from WebSocket debug logs (upstream PR #206) - Add container-level lock file to prevent concurrent R2 sync operations, with 5-min stale lock cleanup (upstream PRs #199, #202) - Add logging.test.ts for redaction utilities https://claude.ai/code/session_01K2mQTABDGY7DnnposPdDjw --- Dockerfile | 4 +- src/gateway/sync.ts | 97 +++++++++++++++++++++++++-------------- src/index.ts | 6 +-- src/utils/logging.test.ts | 64 ++++++++++++++++++++++++++ src/utils/logging.ts | 15 ++++++ 5 files changed, 146 insertions(+), 40 deletions(-) create mode 100644 src/utils/logging.test.ts diff --git a/Dockerfile b/Dockerfile index 227e83ef7..9aa13f9ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 diff --git a/src/gateway/sync.ts b/src/gateway/sync.ts index 99a2f6498..21d18913b 100644 --- a/src/gateway/sync.ts +++ b/src/gateway/sync.ts @@ -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}`; @@ -40,46 +42,71 @@ export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise 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(() => {}); + } } diff --git a/src/index.ts b/src/index.ts index e4461e982..455a16db2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -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); @@ -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; diff --git a/src/utils/logging.test.ts b/src/utils/logging.test.ts new file mode 100644 index 000000000..f74cce90e --- /dev/null +++ b/src/utils/logging.test.ts @@ -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(); + }); +}); diff --git a/src/utils/logging.ts b/src/utils/logging.ts index f9747d04c..47d94c902 100644 --- a/src/utils/logging.ts +++ b/src/utils/logging.ts @@ -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]"'; + }); +}