import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; import assert from 'node:assert/strict'; import { buildChecks, runRepairs, writeDiagnostics } from '../portable/lib/self-heal.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); const HEALTHY_CONFIG = { gateway: { auth: { token: 'uclaw' } }, models: { providers: { anthropic: { apiKey: 'sk-ant-api03-SECRETVALUE0123456789', baseUrl: 'https://api.anthropic.com/v1' } } }, channels: { telegram: { botToken: '123456:TELEGRAMSECRET' } }, uclaw: { locale: 'en', personas: ['finance'], tier: 'standard' }, }; function makeDrive() { const root = mkdtempSync(join(tmpdir(), 'uclaw-heal-')); for (const dir of ['data/.openclaw', 'data/memory', 'data/backups', 'data/logs', 'app/core/node_modules/openclaw']) { mkdirSync(join(root, dir), { recursive: true }); } writeFileSync(join(root, 'app/core/node_modules/openclaw/openclaw.mjs'), '// entry\n'); const paths = { data: join(root, 'data'), state: join(root, 'data/.openclaw'), config: join(root, 'data/.openclaw/openclaw.json'), runtimeJson: join(root, 'data/.openclaw/runtime.json'), core: join(root, 'app/core'), }; writeFileSync(paths.config, JSON.stringify(HEALTHY_CONFIG, null, 2)); return { root, paths }; } const checksFor = (paths, stamp = 'TEST') => buildChecks({ paths, defaultConfigPath: null, portRange: { from: 18789, to: 18799 }, stamp }); test('a healthy drive is left completely alone', async () => { const { root, paths } = makeDrive(); try { const before = readFileSync(paths.config, 'utf8'); const applied = await runRepairs(checksFor(paths)); // Every check runs on every failed start, including failures none of them // explain. Touching a healthy drive would turn one problem into two. assert.deepEqual(applied, [], `repairs fired on a healthy drive: ${JSON.stringify(applied)}`); assert.equal(readFileSync(paths.config, 'utf8'), before, 'the settings file was rewritten'); } finally { rmSync(root, { recursive: true, force: true }); } }); test('a corrupt settings file is rebuilt, and the original is kept', async () => { const { root, paths } = makeDrive(); try { writeFileSync(paths.config, '{"gateway": {half-written'); const applied = await runRepairs(checksFor(paths, 'STAMP1')); assert.ok(applied.some((a) => a.id === 'config-readable'), 'the damaged file was not noticed'); JSON.parse(readFileSync(paths.config, 'utf8')); // throws if still broken // Repairs must never destroy user data — the diagnosis could be wrong. const kept = readdirSync(paths.state).filter((n) => n.includes('broken-STAMP1')); assert.equal(kept.length, 1, 'the original file was not preserved'); assert.match(readFileSync(join(paths.state, kept[0]), 'utf8'), /half-written/); } finally { rmSync(root, { recursive: true, force: true }); } }); test('an interrupted copy is detected so startup can fetch it again', async () => { const { root, paths } = makeDrive(); try { rmSync(join(paths.core, 'node_modules/openclaw/openclaw.mjs')); const applied = await runRepairs(checksFor(paths, 'STAMP2')); assert.ok(applied.some((a) => a.id === 'openclaw-present'), 'a half-copied drive went unnoticed'); assert.ok(!existsSync(join(paths.core, 'node_modules')), 'node_modules should be moved aside so it reinstalls'); } finally { rmSync(root, { recursive: true, force: true }); } }); test('a leftover port record from a crash is cleared', async () => { const { root, paths } = makeDrive(); try { // Nothing is listening on 18795, so this record only makes the launcher wait. writeFileSync(paths.runtimeJson, JSON.stringify({ configServerPort: 18795 })); const applied = await runRepairs(checksFor(paths)); assert.ok(applied.some((a) => a.id === 'stale-runtime'), 'the stale record survived'); assert.ok(!existsSync(paths.runtimeJson)); } finally { rmSync(root, { recursive: true, force: true }); } }); test('missing folders are recreated', async () => { const { root, paths } = makeDrive(); try { rmSync(join(paths.data, 'memory'), { recursive: true }); rmSync(join(paths.data, 'logs'), { recursive: true }); const applied = await runRepairs(checksFor(paths)); assert.ok(applied.some((a) => a.id === 'data-dirs')); assert.ok(existsSync(join(paths.data, 'memory')) && existsSync(join(paths.data, 'logs'))); } finally { rmSync(root, { recursive: true, force: true }); } }); test('the diagnostics report carries no secrets', async () => { const { root, paths } = makeDrive(); try { const checks = checksFor(paths); const report = await writeDiagnostics({ paths, checks, applied: [], stamp: 'STAMP3', versions: { openclaw: '2026.7.1-2', node_pinned: 'v22.22.1' }, }); const body = readFileSync(report, 'utf8'); // The user should be able to read this before deciding to send it anywhere. for (const secret of ['sk-ant-api03-SECRETVALUE0123456789', 'TELEGRAMSECRET']) { assert.ok(!body.includes(secret), `${secret} leaked into the diagnostics report`); } assert.match(body, /redacted/, 'redaction should be visible so the user can tell it happened'); assert.match(body, /api\.anthropic\.com/, 'non-secret settings should survive — the report has to be useful'); assert.match(body, /## System[\s\S]*## Repairs attempted[\s\S]*## Settings/, 'report sections are missing'); } finally { rmSync(root, { recursive: true, force: true }); } }); test('startup heals before it complains, and never dead-ends', () => { const start = readFileSync(join(repoRoot, 'portable', 'lib', 'start.mjs'), 'utf8'); assert.match(start, /healAndRetry/, 'startup should try to repair itself'); // Diagnose was a tool you had to know existed; by the time startup visibly // failed, anyone who did not know had already given up. assert.match(start, /start\.checking/, 'the user should see "checking", not a stack trace'); assert.match(start, /writeDiagnostics/, 'an unrepairable failure should still leave a report'); assert.match(start, /start\.diagnostics_written/, 'the user should be told where the report is'); const en = JSON.parse(readFileSync(join(repoRoot, 'portable', 'lib', 'messages', 'en.json'), 'utf8')); assert.match(en['start.diagnostics_hint'], /help@u-claw\.org/, 'the report needs somewhere to go'); assert.match(en['start.diagnostics_hint'], /removed/i, 'say that keys were stripped, or nobody will send it'); });