From 6041882ae9620a2378949831fa814eed6ef8c4c4 Mon Sep 17 00:00:00 2001 From: Isaac Rowntree Date: Mon, 9 Feb 2026 10:49:39 +1100 Subject: [PATCH] fix: use stdout-based config dir detection instead of exitCode (#212) The sandbox API sometimes returns null for process.exitCode even when the process succeeds. This caused `null !== 0` to always evaluate true, making R2 backups permanently fail with "no config file found". Switch from `test -f openclaw.json` (exitCode check) to `ls -A` (stdout check) which is reliable regardless of exitCode. Also broadens the check from requiring a specific config file to checking the directory has any content, supporting OpenClaw versions that may not create openclaw.json. Fixes #212 Co-Authored-By: Claude Opus 4.6 --- src/gateway/sync.test.ts | 37 ++++++++++++++++++++++++++++--------- src/gateway/sync.ts | 27 ++++++++++++++++++--------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/gateway/sync.test.ts b/src/gateway/sync.test.ts index f062ffed7..c98913423 100644 --- a/src/gateway/sync.test.ts +++ b/src/gateway/sync.test.ts @@ -39,19 +39,38 @@ describe('syncToR2', () => { }); describe('sanity checks', () => { - it('returns error when source has no config file', async () => { + it('returns error when config directories are empty', async () => { const { sandbox, startProcessMock } = createMockSandbox(); startProcessMock .mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n')) - .mockResolvedValueOnce(createMockProcess('', { exitCode: 1 })) // No openclaw.json - .mockResolvedValueOnce(createMockProcess('', { exitCode: 1 })); // No clawdbot.json either + .mockResolvedValueOnce(createMockProcess('')) // Empty .openclaw dir + .mockResolvedValueOnce(createMockProcess('')); // Empty .clawdbot dir const env = createMockEnvWithR2(); const result = await syncToR2(sandbox, env); expect(result.success).toBe(false); - expect(result.error).toBe('Sync aborted: no config file found'); + expect(result.error).toBe('Sync aborted: no config data found'); + }); + + it('syncs when config dir has content but no openclaw.json (exitCode null)', async () => { + const { sandbox, startProcessMock } = createMockSandbox(); + const timestamp = '2026-02-09T12:00:00+00:00'; + + // ls returns directory content even when exitCode is null (#212) + startProcessMock + .mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n')) + .mockResolvedValueOnce(createMockProcess('agents\n', { exitCode: undefined as any })) + .mockResolvedValueOnce(createMockProcess('')) + .mockResolvedValueOnce(createMockProcess(timestamp)); + + const env = createMockEnvWithR2(); + + const result = await syncToR2(sandbox, env); + + expect(result.success).toBe(true); + expect(result.lastSync).toBe(timestamp); }); }); @@ -60,10 +79,10 @@ describe('syncToR2', () => { const { sandbox, startProcessMock } = createMockSandbox(); const timestamp = '2026-01-27T12:00:00+00:00'; - // Calls: mount check, check openclaw.json, rsync, cat timestamp + // Calls: mount check, ls config dir, rsync, cat timestamp startProcessMock .mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n')) - .mockResolvedValueOnce(createMockProcess('ok')) + .mockResolvedValueOnce(createMockProcess('openclaw.json\n')) .mockResolvedValueOnce(createMockProcess('')) .mockResolvedValueOnce(createMockProcess(timestamp)); @@ -78,10 +97,10 @@ describe('syncToR2', () => { it('returns error when rsync fails (no timestamp created)', async () => { const { sandbox, startProcessMock } = createMockSandbox(); - // Calls: mount check, check openclaw.json, rsync (fails), cat timestamp (empty) + // Calls: mount check, ls config dir, rsync (fails), cat timestamp (empty) startProcessMock .mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n')) - .mockResolvedValueOnce(createMockProcess('ok')) + .mockResolvedValueOnce(createMockProcess('openclaw.json\n')) .mockResolvedValueOnce(createMockProcess('', { exitCode: 1 })) .mockResolvedValueOnce(createMockProcess('')); @@ -99,7 +118,7 @@ describe('syncToR2', () => { startProcessMock .mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n')) - .mockResolvedValueOnce(createMockProcess('ok')) + .mockResolvedValueOnce(createMockProcess('openclaw.json\n')) .mockResolvedValueOnce(createMockProcess('')) .mockResolvedValueOnce(createMockProcess(timestamp)); diff --git a/src/gateway/sync.ts b/src/gateway/sync.ts index 63808c471..4de3619f9 100644 --- a/src/gateway/sync.ts +++ b/src/gateway/sync.ts @@ -41,23 +41,32 @@ export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise/dev/null | head -1'); await waitForProcess(checkNew, 5000); - if (checkNew.exitCode !== 0) { - const checkLegacy = await sandbox.startProcess('test -f /root/.clawdbot/clawdbot.json'); + const checkNewLogs = await checkNew.getLogs(); + const hasNewContent = (checkNewLogs.stdout || '').trim().length > 0; + + if (!hasNewContent) { + const checkLegacy = await sandbox.startProcess( + 'ls -A /root/.clawdbot/ 2>/dev/null | head -1', + ); await waitForProcess(checkLegacy, 5000); - if (checkLegacy.exitCode === 0) { + const checkLegacyLogs = await checkLegacy.getLogs(); + const hasLegacyContent = (checkLegacyLogs.stdout || '').trim().length > 0; + + if (hasLegacyContent) { configDir = '/root/.clawdbot'; } else { return { success: false, - error: 'Sync aborted: no config file found', - details: 'Neither openclaw.json nor clawdbot.json found in config directory.', + error: 'Sync aborted: no config data found', + details: + 'Neither /root/.openclaw/ nor /root/.clawdbot/ contain data to back up.', }; } }