diff --git a/README.md b/README.md index 3dfe7eb..80826c4 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ bash clawfix.sh # Run after reviewing ## How It Works 1. **Run one command** — The diagnostic script scans your OpenClaw config, logs, plugins, and ports -2. **AI analyzes** — Pattern matching catches 40+ known issues instantly. AI handles novel problems +2. **AI analyzes** — Pattern matching catches 45+ known issues instantly. AI handles novel problems 3. **Review & apply** — You get a commented fix script. Nothing runs without your approval -## What It Detects (v0.9.0) +## What It Detects (v0.11.0) - 💀 Gateway crashes (port conflicts, process hangs, restart loops) - 🧠 Memory issues (Mem0 silent failures, missing flush, broken search) @@ -53,6 +53,13 @@ bash clawfix.sh # Run after reviewing - 🌊 Session context overflow (>100 % window, auto-compaction failing) - 🔐 FileVault blocking unattended reboots (macOS) - 📦 LaunchAgent plist carrying stale managed-env secrets after a `.env` migration (macOS) +- 🩹 `__OPENCLAW_REDACTED__` literal persisted to config (blocks `openclaw update` and schema validation) +- 📉 Incomplete openclaw npm install (unmet transitive deps breaking plugin load — e.g. Discord missing `discord-api-types`) +- ↕️ Config last written by a newer OpenClaw than the installed CLI (version drift when the macOS app auto-updates) +- 🧩 Stale bundled `plugins.load.paths` aliases left by older OpenClaw channel/setup flows +- ⏳ ACPX/Codex bridge warm-up after updates, where short health probes can time out before the gateway is actually stuck +- 🛡️ ACPX `permissionMode=approve-all` and Codex runtime routing advisories +- 🔄 Latest release/update status via `openclaw update status --json` ## Security & Transparency diff --git a/SCRIPT_HASH b/SCRIPT_HASH index 6a7eefa..4c4a32a 100644 --- a/SCRIPT_HASH +++ b/SCRIPT_HASH @@ -1 +1 @@ -ad44a06f441497913c1c1bf6332bb85f58d5c58cb1f2e232a061018fd8de1064 +b00e5d7039c5c09ee48d80e57076b789872fff15390b283eb72bac4dc62310ba diff --git a/cli/bin/clawfix.js b/cli/bin/clawfix.js index 7caa30c..652f4c5 100755 --- a/cli/bin/clawfix.js +++ b/cli/bin/clawfix.js @@ -17,7 +17,7 @@ import { createInterface } from 'node:readline'; // --- Config --- const API_URL = process.env.CLAWFIX_API || 'https://clawfix.dev'; -const VERSION = '0.8.0'; +const VERSION = '0.11.0'; // --- Flags --- const args = process.argv.slice(2); @@ -86,6 +86,108 @@ function sanitizeConfig(config) { return redact(config); } +function parseJsonSafe(value, fallback = {}) { + try { return JSON.parse(value); } catch { return fallback; } +} + +const BUNDLED_PLUGIN_PATH_RE = /[/\\]openclaw[/\\]dist[/\\]extensions[/\\][^/\\]+[/\\]?$/i; + +function bundledPluginLoadPaths(config) { + const paths = config?.plugins?.load?.paths; + if (!Array.isArray(paths)) return []; + return paths.filter(p => typeof p === 'string' && BUNDLED_PLUGIN_PATH_RE.test(p)); +} + +function walkScalars(value, visitor, path = []) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value == null) { + visitor(path, value); + return; + } + if (Array.isArray(value)) { + value.forEach((item, i) => walkScalars(item, visitor, path.concat(String(i)))); + return; + } + if (value && typeof value === 'object') { + Object.entries(value).forEach(([key, child]) => walkScalars(child, visitor, path.concat(key))); + } +} + +function collectConfigDiagnostics(config) { + const redactedPlaceholderPaths = []; + walkScalars(config, (path, value) => { + if (value === '__OPENCLAW_REDACTED__') redactedPlaceholderPaths.push(path.join('.')); + }); + return { + lastTouchedVersion: config?.meta?.lastTouchedVersion || null, + redactedPlaceholderPaths, + bundledPluginLoadPaths: bundledPluginLoadPaths(config), + }; +} + +function updateAvailable(updateStatus) { + if (!updateStatus || typeof updateStatus !== 'object') return false; + return updateStatus.available === true || + updateStatus.updateAvailable === true || + updateStatus.hasUpdate === true || + updateStatus.hasRegistryUpdate === true || + updateStatus.availability?.available === true || + updateStatus.registry?.available === true || + updateStatus.registry?.hasUpdate === true; +} + +function codexRuntimeAutoPi(config) { + let hasCodexModelRef = false; + walkScalars(config, (path, value) => { + if (typeof value !== 'string') return; + if (!/^openai-codex\//.test(value)) return; + const leaf = path[path.length - 1] || ''; + if (/^(model|primary|fallback)$/i.test(leaf)) hasCodexModelRef = true; + }); + + if (!hasCodexModelRef) return false; + const codexPlugin = config?.plugins?.entries?.codex || config?.plugins?.entries?.['openclaw-codex']; + if (codexPlugin?.enabled === false) return false; + + const runtimeId = + config?.agents?.defaults?.agentRuntime?.id || + config?.agentRuntime?.id || + 'auto'; + return runtimeId !== 'codex'; +} + +function findOpenClawInstallDir() { + const globalRoot = run('npm root -g 2>/dev/null'); + const candidates = [ + globalRoot ? join(globalRoot, 'openclaw') : '', + '/opt/homebrew/lib/node_modules/openclaw', + '/usr/local/lib/node_modules/openclaw', + ].filter(Boolean); + return candidates.find(p => run(`test -d "${p}" && printf yes`) === 'yes') || ''; +} + +function collectInstallDiagnostics() { + const root = findOpenClawInstallDir(); + if (!root) return { root: null, unmetCount: 0, unmet: [] }; + + let output = ''; + try { + output = execSync(`npm ls --prefix "${root}" --depth=0 2>&1`, { encoding: 'utf8', timeout: 15000 }); + } catch (err) { + output = `${err.stdout || ''}${err.stderr || ''}`; + } + const unmet = output + .split('\n') + .filter(line => /UNMET DEPENDENCY/.test(line)) + .map(line => line.trim().split(/\s+/).pop()) + .filter(Boolean); + + return { + root, + unmetCount: unmet.length, + unmet, + }; +} + // ============================================================ // Built-in Safe Fix Functions — no jq, no bash, no copy-paste // ============================================================ @@ -189,6 +291,63 @@ const BUILTIN_FIXES = { } }, + 'bundled-plugin-load-path-aliases': { + description: 'Remove bundled OpenClaw plugin aliases from plugins.load.paths', + risk: 'low', + needsConfig: true, + needsRestart: true, + informational: false, + apply: (config) => { + if (!config.plugins) config.plugins = {}; + if (!config.plugins.load) config.plugins.load = {}; + const paths = config.plugins.load.paths; + if (!Array.isArray(paths) || paths.length === 0) { + config.plugins.load.paths = []; + return { changes: ['plugins.load.paths already empty'] }; + } + const removed = paths.filter(p => typeof p === 'string' && BUNDLED_PLUGIN_PATH_RE.test(p)); + config.plugins.load.paths = paths.filter(p => typeof p !== 'string' || !BUNDLED_PLUGIN_PATH_RE.test(p)); + if (removed.length === 0) return { changes: ['No bundled OpenClaw plugin aliases found'] }; + return { changes: [`Removed bundled plugin path aliases: ${removed.join(', ')}`] }; + } + }, + + 'acpx-startup-warmup-timeout': { + description: 'Informational: wait for ACPX/Codex warm-up before repeated restarts', + risk: 'none', + needsConfig: false, + needsRestart: false, + informational: true, + apply: () => ({ changes: ['Wait up to 90 seconds, then verify with openclaw gateway status --deep and openclaw health'] }) + }, + + 'acpx-approve-all-warning': { + description: 'Informational: ACPX approve-all is a security policy decision', + risk: 'none', + needsConfig: false, + needsRestart: false, + informational: true, + apply: () => ({ changes: ['Review plugins.entries.acpx.config.permissionMode before narrowing it'] }) + }, + + 'codex-runtime-auto-pi-warning': { + description: 'Informational: choose Codex runtime routing intentionally', + risk: 'none', + needsConfig: false, + needsRestart: false, + informational: true, + apply: () => ({ changes: ['Review openai-codex model references and agentRuntime.id together'] }) + }, + + 'openclaw-update-available': { + description: 'Informational: run openclaw update in a maintenance window', + risk: 'none', + needsConfig: false, + needsRestart: false, + informational: true, + apply: () => ({ changes: ['Run openclaw update, then openclaw gateway status --deep and openclaw doctor --non-interactive'] }) + }, + 'gateway-not-running': { description: 'Restart the OpenClaw gateway', risk: 'low', @@ -617,8 +776,10 @@ async function collectDiagnostics({ quiet = false } = {}) { const hostHash = hashStr(hostname()); let ocVersion = ''; + let updateStatus = {}; if (openclawBin) { ocVersion = run(`"${openclawBin}" --version`); + updateStatus = parseJsonSafe(run(`"${openclawBin}" update status --json 2>/dev/null`), {}); } log(` OS: ${osName} ${osVersion} (${osArch})`); @@ -631,10 +792,12 @@ async function collectDiagnostics({ quiet = false } = {}) { let config = null; let sanitizedConfig = {}; + let configDiagnostics = {}; if (configPath && await exists(configPath)) { config = await readJson(configPath); sanitizedConfig = sanitizeConfig(config) || {}; + configDiagnostics = collectConfigDiagnostics(config || {}); log(c.green(' ✅ Config read and sanitized')); } else { log(c.yellow(' ⚠️ No config file found')); @@ -825,35 +988,50 @@ async function collectDiagnostics({ quiet = false } = {}) { checkPort(18800, 'browser CDP'); checkPort(18791, 'browser control'); + // --- OpenClaw Install Integrity --- + log(''); + log(c.blue('📦 Checking OpenClaw install integrity...')); + + const installDiagnostics = collectInstallDiagnostics(); + if (installDiagnostics.root) { + if (installDiagnostics.unmetCount > 1) { + log(c.yellow(` ⚠️ ${installDiagnostics.unmetCount} unmet OpenClaw npm dependencies`)); + } else { + log(c.green(' ✅ OpenClaw npm deps look sane')); + } + } else { + log(c.dim(' OpenClaw package directory not found')); + } + // --- Local Issue Detection --- const issues = []; const gatewayRunning = /running.*pid|state active|listening/i.test(gatewayStatus); const gatewayFailed = /not running|failed to start|stopped|inactive/i.test(gatewayStatus); if (gatewayFailed || (!gatewayRunning && !/warning/i.test(gatewayStatus))) { - issues.push({ severity: 'critical', text: 'Gateway is not running' }); + issues.push({ id: 'gateway-not-running', severity: 'critical', text: 'Gateway is not running' }); } if (/EADDRINUSE/i.test(errorLogs)) { - issues.push({ severity: 'critical', text: 'Port conflict detected' }); + issues.push({ id: 'port-conflict', severity: 'critical', text: 'Port conflict detected' }); } const sigtermCount = (gatewayLogTail.match(/signal SIGTERM/gi) || []).length; const restartCount = (gatewayLogTail.match(/listening.*PID/gi) || []).length; if (config?.update?.auto?.enabled === true && (sigtermCount >= 2 || restartCount >= 3)) { - issues.push({ severity: 'critical', text: 'Auto-update causing gateway restart loop' }); + issues.push({ id: 'auto-update-restart-loop', severity: 'critical', text: 'Auto-update causing gateway restart loop' }); } else if (config?.update?.auto?.enabled === true) { - issues.push({ severity: 'medium', text: 'Auto-update enabled (risk of restart loops)' }); + issues.push({ id: 'auto-update-enabled-warning', severity: 'medium', text: 'Auto-update enabled (risk of restart loops)' }); } const reloadCount = (gatewayLogTail.match(/config change detected.*evaluating reload/gi) || []).length; if (reloadCount >= 3) { - issues.push({ severity: 'high', text: `Config reload cascade detected (${reloadCount} reloads in recent logs)` }); + issues.push({ id: 'config-reload-sigterm-cascade', severity: 'high', text: `Config reload cascade detected (${reloadCount} reloads in recent logs)` }); } if (serviceHealth.runs > 2 && (serviceHealth.uptimeSeconds || 0) < 300) { - issues.push({ severity: 'critical', text: `Gateway crash loop — ${serviceHealth.runs} restarts, only ${serviceHealth.uptimeStr} uptime` }); + issues.push({ id: 'gateway-extended-downtime', severity: 'critical', text: `Gateway crash loop — ${serviceHealth.runs} restarts, only ${serviceHealth.uptimeStr} uptime` }); } else if ((serviceHealth.nRestarts || 0) > 0) { - issues.push({ severity: 'high', text: `Gateway has restarted ${serviceHealth.nRestarts} time(s) (systemd)` }); + issues.push({ id: 'gateway-extended-downtime', severity: 'high', text: `Gateway has restarted ${serviceHealth.nRestarts} time(s) (systemd)` }); } const handshakeSpam = (stderrLogs.match(/invalid handshake.*chrome-extension|closed before connect.*chrome-extension/gi) || []).length; @@ -871,22 +1049,72 @@ async function collectDiagnostics({ quiet = false } = {}) { } if (config?.plugins?.entries?.['openclaw-mem0']?.config?.enableGraph === true) { - issues.push({ severity: 'high', text: 'Mem0 enableGraph requires Pro plan (will silently fail)' }); + issues.push({ id: 'mem0-graph-free', severity: 'high', text: 'Mem0 enableGraph requires Pro plan (will silently fail)' }); } if (!config?.agents?.defaults?.memorySearch?.query?.hybrid?.enabled) { - issues.push({ severity: 'medium', text: 'Hybrid search not enabled (recommended)' }); + issues.push({ id: 'no-hybrid-search', severity: 'medium', text: 'Hybrid search not enabled (recommended)' }); } if (!config?.agents?.defaults?.contextPruning) { - issues.push({ severity: 'medium', text: 'No context pruning configured' }); + issues.push({ id: 'no-context-pruning', severity: 'medium', text: 'No context pruning configured' }); } if (!config?.agents?.defaults?.compaction?.memoryFlush?.enabled) { - issues.push({ severity: 'medium', text: 'Memory flush not enabled (data loss on compaction)' }); + issues.push({ id: 'no-memory-flush', severity: 'medium', text: 'Memory flush not enabled (data loss on compaction)' }); + } + + if (configDiagnostics.bundledPluginLoadPaths?.length) { + issues.push({ + id: 'bundled-plugin-load-path-aliases', + severity: 'medium', + text: `Bundled plugin aliases in plugins.load.paths (${configDiagnostics.bundledPluginLoadPaths.length})`, + }); + } + + if (config?.plugins?.entries?.acpx?.config?.permissionMode === 'approve-all') { + issues.push({ + id: 'acpx-approve-all-warning', + severity: 'medium', + text: 'ACPX permissionMode=approve-all (security policy advisory)', + }); + } + + if (codexRuntimeAutoPi(config)) { + issues.push({ + id: 'codex-runtime-auto-pi-warning', + severity: 'low', + text: 'Codex plugin enabled but openai-codex model refs still route through auto/PI runtime', + }); + } + + if (updateAvailable(updateStatus)) { + issues.push({ + id: 'openclaw-update-available', + severity: 'medium', + text: 'OpenClaw update available', + }); + } + + if (installDiagnostics.unmetCount > 1) { + issues.push({ + id: 'incomplete-npm-install', + severity: 'high', + text: `Incomplete OpenClaw npm install (${installDiagnostics.unmetCount} unmet deps)`, + }); + } + + const warmupText = `${gatewayStatus}\n${errorLogs}\n${stderrLogs}\n${gatewayLogTail}`; + if (/gateway timeout after 10000ms|Warm-up: launch agents|health.*timed out/i.test(warmupText) && + /acpx|loaded \d+ internal hook handlers|embedded acpx runtime backend registered/i.test(warmupText)) { + issues.push({ + id: 'acpx-startup-warmup-timeout', + severity: 'low', + text: 'Gateway probe timed out during ACPX/Codex warm-up', + }); } if (!hasSoul && workspaceDir) { - issues.push({ severity: 'low', text: 'No SOUL.md found (agent has no personality)' }); + issues.push({ id: 'no-soul', severity: 'low', text: 'No SOUL.md found (agent has no personality)' }); } if (memoryFiles === 0 && workspaceDir) { - issues.push({ severity: 'low', text: 'No memory files found' }); + issues.push({ id: 'no-memory-files', severity: 'low', text: 'No memory files found' }); } // --- Build Payload --- @@ -909,7 +1137,10 @@ async function collectDiagnostics({ quiet = false } = {}) { gatewayPid: gatewayPid || 'none', gatewayPort, }, + update: updateStatus, config: sanitizedConfig, + configDiagnostics, + install: installDiagnostics, logs: { errors: errorLogs, stderr: stderrLogs, @@ -1060,7 +1291,7 @@ async function runOneShotMode() { const response = await fetch(`${API_URL}/api/diagnose`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(diagnostic), + body: JSON.stringify({ ...diagnostic, _localIssues: issues.map(i => ({ id: i.id, severity: i.severity, text: i.text, title: i.title })) }), }); if (!response.ok) { @@ -1158,7 +1389,7 @@ async function runInteractiveMode() { // --- Send diagnostic to server for AI context --- try { // Include locally-detected issues so server can match them to known fixes - const payload = { ...diagnostic, _localIssues: issues.map(i => ({ severity: i.severity, text: i.text })) }; + const payload = { ...diagnostic, _localIssues: issues.map(i => ({ id: i.id, severity: i.severity, text: i.text, title: i.title })) }; const resp = await fetch(`${API_URL}/api/diagnose`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1218,7 +1449,7 @@ async function runInteractiveMode() { // Re-send to server try { - const payload = { ...diagnostic, _localIssues: issues.map(i => ({ severity: i.severity, text: i.text })) }; + const payload = { ...diagnostic, _localIssues: issues.map(i => ({ id: i.id, severity: i.severity, text: i.text, title: i.title })) }; const resp = await fetch(`${API_URL}/api/diagnose`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1258,7 +1489,7 @@ async function runInteractiveMode() { summary = result.summary; // Re-send to server for updated known issues try { - const payload = { ...diagnostic, _localIssues: issues.map(i => ({ severity: i.severity, text: i.text })) }; + const payload = { ...diagnostic, _localIssues: issues.map(i => ({ id: i.id, severity: i.severity, text: i.text, title: i.title })) }; const resp = await fetch(`${API_URL}/api/diagnose`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1300,7 +1531,7 @@ async function runInteractiveMode() { issues = result.issues; summary = result.summary; try { - const payload = { ...diagnostic, _localIssues: issues.map(i => ({ severity: i.severity, text: i.text })) }; + const payload = { ...diagnostic, _localIssues: issues.map(i => ({ id: i.id, severity: i.severity, text: i.text, title: i.title })) }; const resp = await fetch(`${API_URL}/api/diagnose`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1489,11 +1720,13 @@ function renderHelp() { function mergeIssues(localIssues, serverIssues) { const merged = []; const seen = new Set(); + const seenIds = new Set(); // Server issues first (they have fix scripts) if (serverIssues) { for (const si of serverIssues) { merged.push(si); + if (si.id) seenIds.add(si.id); seen.add((si.title || '').toLowerCase()); } } @@ -1501,6 +1734,7 @@ function mergeIssues(localIssues, serverIssues) { // Then local issues that aren't duplicated for (const li of localIssues) { const key = (li.text || '').toLowerCase(); + if (li.id && seenIds.has(li.id)) continue; const isDup = [...seen].some(s => s.includes(key.slice(0, 20)) || key.includes(s.slice(0, 20)) ); diff --git a/cli/index.js b/cli/index.js index fa2c66a..e2e7c36 100644 --- a/cli/index.js +++ b/cli/index.js @@ -14,7 +14,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; -const VERSION = '0.9.0'; +const VERSION = '0.10.0'; const DEFAULT_API = 'https://clawfix.dev'; const ARGS = process.argv.slice(2); const JSON_MODE = ARGS.includes('--json'); @@ -83,6 +83,60 @@ function hashHostname() { return Math.abs(h).toString(16).slice(0, 8); } +// --- Pre-sanitize scan for config-corruption signals that must survive sanitization --- +// The openclaw CLI's own display-redaction uses the literal string +// "__OPENCLAW_REDACTED__"; newer openclaw versions have been observed +// persisting that placeholder to disk as the real value of some fields +// (SecretRef ids, env.vars entries). Schema validation then rejects the +// config. This scan runs BEFORE sanitizeConfig() so we can flag the +// corruption class even when the affected key name (like "token") would +// otherwise be replaced by our own sanitization. +function scanConfigCorruption(rawConfig) { + if (!rawConfig) return { redactedPlaceholderPaths: [], writtenByNewerVersion: null }; + const redactedPlaceholderPaths = []; + const walk = (node, path) => { + if (typeof node === 'string') { + if (node === '__OPENCLAW_REDACTED__') redactedPlaceholderPaths.push(path); + } else if (Array.isArray(node)) { + node.forEach((v, i) => walk(v, `${path}[${i}]`)); + } else if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k); + } + }; + walk(rawConfig, ''); + const meta = rawConfig.meta || {}; + return { + redactedPlaceholderPaths, + lastTouchedVersion: typeof meta.lastTouchedVersion === 'string' ? meta.lastTouchedVersion : null, + lastTouchedAt: typeof meta.lastTouchedAt === 'string' ? meta.lastTouchedAt : null, + }; +} + +// --- Check the openclaw global install for missing deps --- +// Incomplete upstream publishes have shipped in the past (e.g. 2026.4.21 +// omitted discord-api-types, @buape/carbon, @discordjs/opus, opusscript +// from its declared deps). `npm ls` in the install dir shows them as +// UNMET DEPENDENCY. One unmet is tolerable (node-llama-cpp is optional); +// more than one is a broken install. +function inspectOpenclawInstall(ocBin) { + if (!ocBin) return { dir: null, unmetCount: null, unmetNames: [] }; + let dir = run(`dirname "${ocBin}" 2>/dev/null | xargs -I{} readlink -f {}/../lib/node_modules/openclaw 2>/dev/null`); + if (!dir || !existsSync(dir)) { + // fallback: try the common homebrew path + dir = existsSync('/opt/homebrew/lib/node_modules/openclaw') ? '/opt/homebrew/lib/node_modules/openclaw' + : existsSync('/usr/local/lib/node_modules/openclaw') ? '/usr/local/lib/node_modules/openclaw' + : null; + } + if (!dir) return { dir: null, unmetCount: null, unmetNames: [] }; + const out = run(`npm ls --prefix "${dir}" --depth=0 2>&1`); + const unmet = []; + for (const line of out.split('\n')) { + const m = line.match(/UNMET (?:OPTIONAL )?DEPENDENCY\s+([@\w/-]+)/); + if (m) unmet.push(m[1]); + } + return { dir, unmetCount: unmet.length, unmetNames: unmet.slice(0, 20) }; +} + // --- Sanitize config (redact secrets) --- function sanitizeConfig(config) { if (!config) return {}; @@ -157,8 +211,19 @@ async function main() { log(); log(c.blue('🔒 Reading config (secrets redacted)...')); const rawConfig = configPath ? readJson(configPath) : null; + // Run pre-sanitize scans so corruption / version-drift signals survive + const configCorruption = scanConfigCorruption(rawConfig); const config = sanitizeConfig(rawConfig); log(rawConfig ? c.green(' ✅ Config read and sanitized') : c.yellow(' ⚠️ No config file found')); + if (configCorruption.redactedPlaceholderPaths.length > 0) { + log(c.red(` ⚠️ Redacted-placeholder corruption at ${configCorruption.redactedPlaceholderPaths.length} path(s)`)); + } + + // 3b. Check openclaw global install for missing deps (incomplete npm publishes) + const install = inspectOpenclawInstall(ocBin); + if (install.unmetCount != null && install.unmetCount > 1) { + log(c.red(` ⚠️ ${install.unmetCount} unmet dep(s) in ${install.dir}`)); + } // 4. Gateway status log(); @@ -391,6 +456,16 @@ async function main() { host: { fileVaultOn, }, + install: { + dir: install.dir, + unmetCount: install.unmetCount, + unmetNames: install.unmetNames, + }, + configDiagnostics: { + redactedPlaceholderPaths: configCorruption.redactedPlaceholderPaths, + lastTouchedVersion: configCorruption.lastTouchedVersion, + lastTouchedAt: configCorruption.lastTouchedAt, + }, config, logs: { errors: errorLogs, diff --git a/cli/package.json b/cli/package.json index f0c4409..04eb539 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "clawfix", - "version": "0.9.0", + "version": "0.11.0", "description": "AI-powered diagnostic and repair for OpenClaw installations", "bin": { "clawfix": "./bin/clawfix.js" diff --git a/package-lock.json b/package-lock.json index 7f9d5c0..41635a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawfix", - "version": "0.1.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawfix", - "version": "0.1.0", + "version": "0.11.0", "dependencies": { "cors": "^2.8.5", "express": "^5.1.0", diff --git a/package.json b/package.json index f579aa0..afd2214 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawfix", - "version": "0.7.0", + "version": "0.11.0", "description": "AI-powered OpenClaw diagnostic and repair service", "type": "module", "main": "src/server.js", diff --git a/src/known-issues.js b/src/known-issues.js index ca57d30..7ebffb1 100644 --- a/src/known-issues.js +++ b/src/known-issues.js @@ -4,6 +4,64 @@ * These are issues we've personally encountered and solved. */ +const BUNDLED_PLUGIN_PATH_RE = /[/\\]openclaw[/\\]dist[/\\]extensions[/\\][^/\\]+[/\\]?$/i; + +function logText(diag) { + return [ + diag.logs?.errors, + diag.logs?.stderr, + diag.logs?.gatewayLog, + diag.openclaw?.gatewayStatus, + ].filter(Boolean).join('\n'); +} + +function bundledPluginLoadPaths(config) { + const paths = config?.plugins?.load?.paths; + if (!Array.isArray(paths)) return []; + return paths.filter(p => typeof p === 'string' && BUNDLED_PLUGIN_PATH_RE.test(p)); +} + +function collectStringValues(obj, out = [], path = []) { + if (typeof obj === 'string') { + out.push({ path: path.join('.'), value: obj }); + return out; + } + if (Array.isArray(obj)) { + obj.forEach((v, i) => collectStringValues(v, out, path.concat(String(i)))); + return out; + } + if (obj && typeof obj === 'object') { + Object.entries(obj).forEach(([k, v]) => collectStringValues(v, out, path.concat(k))); + } + return out; +} + +function codexRuntimeAutoPi(config) { + const refs = collectStringValues(config) + .filter(({ path, value }) => /(^|\.)(model|primary|fallback)$/i.test(path) && /^openai-codex\//.test(value)); + if (refs.length === 0) return false; + + const codexPlugin = config?.plugins?.entries?.codex || config?.plugins?.entries?.['openclaw-codex']; + if (codexPlugin?.enabled === false) return false; + + const runtimeId = + config?.agents?.defaults?.agentRuntime?.id || + config?.agentRuntime?.id || + 'auto'; + return runtimeId !== 'codex'; +} + +function updateAvailable(update) { + if (!update || typeof update !== 'object') return false; + return update.available === true || + update.updateAvailable === true || + update.hasUpdate === true || + update.hasRegistryUpdate === true || + update.availability?.available === true || + update.registry?.available === true || + update.registry?.hasUpdate === true; +} + export const KNOWN_ISSUES = [ { id: 'mem0-graph-free', @@ -955,6 +1013,293 @@ else fi`, }, + // ─── 2026-04-28 additions ────────────────────────────────────────────── + // Patterns discovered while refreshing a live 2026.4.26 beta install. + // OpenClaw now bundles stock plugins directly, ACPX warm-up can exceed + // short health probes, and update status is available from the CLI. + + { + id: 'bundled-plugin-load-path-aliases', + severity: 'medium', + title: 'Redundant bundled plugin paths in plugins.load.paths', + description: 'plugins.load.paths points back into OpenClaw\'s own bundled dist/extensions directory (for example codex or discord). Current OpenClaw releases bundle those stock plugins directly, so these paths are stale aliases that trigger config warnings and can confuse post-update gateway health checks.', + detect: (diag) => { + const paths = diag.configDiagnostics?.bundledPluginLoadPaths || bundledPluginLoadPaths(diag.config); + if (Array.isArray(paths) && paths.length > 0) return true; + return /ignored plugins\.load\.paths entry that points at OpenClaw's current bundled plugin directory/i.test(logText(diag)); + }, + fix: `# Fix: remove only bundled OpenClaw plugin aliases from plugins.load.paths +set -u +CFG=~/.openclaw/openclaw.json +[ -f "$CFG" ] || { echo "no config found"; exit 1; } + +TS=$(date +%Y%m%d-%H%M%S) +/bin/cp -p "$CFG" "$CFG.pre-bundled-plugin-paths-$TS" +echo "snapshot: $CFG.pre-bundled-plugin-paths-$TS" + +CURRENT=$(openclaw config get plugins.load.paths 2>/dev/null || echo '[]') +FILTERED=$(node -e ' +let paths = []; +try { paths = JSON.parse(process.argv[1] || "[]"); } catch {} +const bundled = /[\\\\/]openclaw[\\\\/]dist[\\\\/]extensions[\\\\/][^\\\\/]+[\\\\/]?$/i; +const kept = Array.isArray(paths) ? paths.filter(p => typeof p !== "string" || !bundled.test(p)) : []; +process.stdout.write(JSON.stringify(kept)); +' "$CURRENT") + +if [ "$CURRENT" = "$FILTERED" ]; then + echo "No bundled plugin aliases found; nothing changed." +else + openclaw config set plugins.load.paths "$FILTERED" --strict-json --replace + openclaw config validate + openclaw gateway restart + echo "✅ Removed bundled plugin path aliases and restarted gateway" +fi + +echo "" +echo "Verify:" +echo " openclaw plugins doctor" +echo " openclaw gateway status --deep"`, + }, + + { + id: 'acpx-startup-warmup-timeout', + severity: 'low', + title: 'Gateway health probe timed out during ACPX/Codex warm-up', + description: 'OpenClaw is still progressing through ACPX/Codex bridge startup, but a short health probe timed out first. Avoid repeated restart loops while logs are moving from hook loading to "embedded acpx runtime backend registered" and finally "[gateway] ready".', + detect: (diag) => { + const logs = logText(diag); + return /gateway timeout after 10000ms|Warm-up: launch agents can take a few seconds|health.*timed out/i.test(logs) && + /acpx|loaded \d+ internal hook handlers|embedded acpx runtime backend registered/i.test(logs); + }, + fix: `# Info: wait for ACPX/Codex warm-up before restarting again +echo "ACPX/Codex startup can take longer than a 10 second health probe." +echo "Wait up to 90 seconds while logs are still progressing:" +echo " tail -f ~/.openclaw/logs/gateway.log" +echo "" +echo "Healthy progression usually ends with:" +echo " embedded acpx runtime backend registered" +echo " Browser control listening on http://127.0.0.1:18791/" +echo " [gateway] ready" +echo "" +echo "Verify after the warm-up window:" +echo " openclaw gateway status --deep" +echo " openclaw health"`, + }, + + { + id: 'acpx-approve-all-warning', + severity: 'medium', + title: 'ACPX permissionMode is approve-all', + description: 'plugins.entries.acpx.config.permissionMode is "approve-all". This may be intentional for a trusted single-operator workflow, but it is a security audit warning and should be an explicit policy choice rather than a silent default.', + detect: (diag) => diag.config?.plugins?.entries?.acpx?.config?.permissionMode === 'approve-all' || + /plugins\.entries\.acpx\.config\.permissionMode.*approve-all/i.test(logText(diag)), + fix: `# Info: ACPX approve-all is a policy choice, not an automatic fix +echo "Current setting:" +openclaw config get plugins.entries.acpx.config.permissionMode 2>/dev/null || true +echo "" +echo "If approve-all is not required, pick a narrower ACPX permission mode" +echo "and restart the gateway. Do not change this blindly on a live workflow." +echo "" +echo "Example:" +echo " openclaw config set plugins.entries.acpx.config.permissionMode approve-reads" +echo " openclaw gateway restart" +echo "" +echo "Then verify:" +echo " openclaw security audit --deep"`, + }, + + { + id: 'codex-runtime-auto-pi-warning', + severity: 'low', + title: 'Codex plugin enabled but openai-codex model refs still route through auto/PI runtime', + description: 'OpenClaw doctor warns when the Codex plugin is enabled while openai-codex/* model references are still routed through agentRuntime.id=auto / PI. Existing installs may intentionally use that route, so ClawFix reports it as an advisory rather than changing model runtime policy.', + detect: (diag) => codexRuntimeAutoPi(diag.config) || + /Codex plugin enabled.*openai-codex|agentRuntime\.id.*auto.*PI/i.test(logText(diag)), + fix: `# Info: choose Codex runtime routing intentionally +echo "This is advisory. Do not auto-switch runtimes without testing model auth." +echo "" +echo "Current relevant settings:" +openclaw config get agents.defaults.agentRuntime.id 2>/dev/null || true +openclaw config get agents.defaults.model 2>/dev/null || true +openclaw config get agents.defaults.heartbeat.model 2>/dev/null || true +echo "" +echo "If you want native Codex plugin routing, update the runtime/model settings" +echo "in one maintenance window, then run:" +echo " openclaw config validate" +echo " openclaw gateway restart" +echo " openclaw doctor --non-interactive"`, + }, + + { + id: 'openclaw-update-available', + severity: 'medium', + title: 'OpenClaw update is available', + description: 'openclaw update status reports a newer release than the installed package. Current OpenClaw releases include update hardening, bundled-plugin path fixes, runtime dependency repair progress, openclaw migrate, and nodes remove support.', + detect: (diag) => updateAvailable(diag.update), + fix: `# Fix: update OpenClaw and run post-update health checks +set -u +openclaw update status --json +echo "" +read -r -p "Run openclaw update now? [y/N] " ANS +[ "$ANS" = "y" ] || [ "$ANS" = "Y" ] || { echo "skipped"; exit 0; } + +openclaw update --yes + +echo "" +echo "If post-update health times out, wait for ACPX warm-up before restarting again." +echo "Verify:" +echo " openclaw gateway status --deep" +echo " openclaw health" +echo " openclaw doctor --non-interactive"`, + }, + + // ─── 2026-04-22 additions ────────────────────────────────────────────── + // Patterns discovered after an `openclaw update` from 2026.4.15 to + // 2026.4.21 broke in two independent ways. See docs.openclaw.ai/ for + // upstream context. + + { + id: 'redacted-placeholder-corruption', + severity: 'critical', + title: 'Config has "__OPENCLAW_REDACTED__" persisted as real value', + description: 'The openclaw CLI\'s display-redaction placeholder ("__OPENCLAW_REDACTED__") is stored as a real value in openclaw.json. Schema validation rejects it because the literal starts with underscore and fails pattern checks (like the ^[A-Z][A-Z0-9_]{0,127}$ for SecretRef ids). `openclaw update` and other commands will refuse to run. Observed after the macOS app auto-updated to a newer version than the installed CLI and rewrote the config through a write path that persisted the placeholder.', + detect: (diag) => { + const paths = diag.configDiagnostics?.redactedPlaceholderPaths; + return Array.isArray(paths) && paths.length > 0; + }, + fix: `# Fix: restore corrupted fields by direct JSON write +# (openclaw config set refuses to mutate an invalid config — chicken-and-egg) +set -u +CFG=~/.openclaw/openclaw.json +[ -f "$CFG" ] || { echo "no config found"; exit 1; } + +TS=$(date +%Y%m%d-%H%M%S) +/bin/cp -p "$CFG" "$CFG.pre-redacted-fix-$TS" +echo "snapshot: $CFG.pre-redacted-fix-$TS" + +# Find every path holding the literal placeholder +/usr/bin/env python3 - <<'PY' +import json +d = json.load(open('/Users/'+__import__('os').environ['USER']+'/.openclaw/openclaw.json')) +def walk(o, p=''): + out=[] + if isinstance(o, dict): + for k,v in o.items(): + out += walk(v, f'{p}.{k}' if p else k) + elif isinstance(o, list): + for i,v in enumerate(o): out += walk(v, f'{p}[{i}]') + elif o == '__OPENCLAW_REDACTED__': + out.append(p) + return out +paths = walk(d) +if not paths: + print("No corruption found (maybe already fixed)") +else: + print(f"Corrupted paths ({len(paths)}):") + for p in paths: print(f" {p}") +PY + +echo "" +echo "For each path printed above, choose one of:" +echo " 1) If it's a SecretRef .id field (e.g. channels.discord.token.id):" +echo " edit the JSON to set it back to the real env var name you use," +echo " e.g. DISCORD_BOT_TOKEN / MATRIX_ACCESS_TOKEN / _API_KEY" +echo " 2) If it's an env.vars.* entry: the plist already propagates env vars," +echo " so the safest fix is to remove the whole env section entirely:" +echo " python3 -c 'import json,os,tempfile; p=os.path.expanduser(\"~/.openclaw/openclaw.json\"); d=json.load(open(p)); d.pop(\"env\",None); fd,tmp=tempfile.mkstemp(dir=os.path.dirname(p)); os.write(fd, json.dumps(d,indent=2).encode()); os.close(fd); os.chmod(tmp,0o600); os.replace(tmp,p); print(\"removed env block\")'" +echo "" +echo "After fixing the paths, validate + reload:" +echo " openclaw config validate && openclaw gateway restart"`, + }, + + { + id: 'incomplete-npm-install', + severity: 'high', + title: 'Incomplete openclaw npm install — deps referenced by built bundle are missing', + description: 'The installed openclaw package has unmet dependencies that its own built code require()s. Typical symptom: `openclaw channels status --probe` reports "discord failed during register: Cannot find module discord-api-types/v10" (or similar chained missing modules like @buape/carbon, @discordjs/opus, opusscript). This happens when an upstream publish is missing transitive deps from its declared dependencies. One unmet (node-llama-cpp, optional) is expected; more than one is broken.', + detect: (diag) => { + const c = diag.install?.unmetCount; + return typeof c === 'number' && c > 1; + }, + fix: `# Fix: install the missing deps directly into the openclaw global install +# Uses --no-save to avoid mutating the upstream package.json, and +# --legacy-peer-deps to skirt unrelated peer-dep conflicts. +set -u + +OC_BIN=$(which openclaw 2>/dev/null) +OC_DIR=$(dirname "$OC_BIN" | xargs -I{} sh -c 'cd {}/../lib/node_modules/openclaw && pwd') +[ -d "$OC_DIR" ] || OC_DIR=/opt/homebrew/lib/node_modules/openclaw +[ -d "$OC_DIR" ] || { echo "can't find openclaw install"; exit 1; } +echo "openclaw install: $OC_DIR" + +# Find what's missing +MISSING=$(/opt/homebrew/bin/npm ls --prefix "$OC_DIR" --depth=0 2>&1 \\ + | grep 'UNMET DEPENDENCY' \\ + | awk '{print $NF}' \\ + | sed 's/@[^@]*$//' \\ + | sort -u \\ + | grep -v '^node-llama-cpp$') + +if [ -z "$MISSING" ]; then + echo "no unmet deps other than the expected optional ones" + exit 0 +fi + +echo "Will install into $OC_DIR:" +echo "$MISSING" | sed 's/^/ /' +echo "" +read -r -p "Proceed? [y/N] " ANS +[ "$ANS" = "y" ] || [ "$ANS" = "Y" ] || { echo "skipped"; exit 0; } + +cd "$OC_DIR" +/opt/homebrew/bin/npm install --no-save --no-package-lock --legacy-peer-deps $MISSING + +# reload the gateway so it picks up the new modules +if launchctl list 2>/dev/null | grep -q ai.openclaw.gateway; then + launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway + echo "✅ gateway reloaded" +elif command -v systemctl >/dev/null 2>&1; then + sudo systemctl restart openclaw-gateway +fi + +echo "" +echo "Verify with: openclaw channels status --probe"`, + }, + + { + id: 'config-written-by-newer-version', + severity: 'medium', + title: 'Config was last written by a newer OpenClaw than the installed CLI', + description: 'openclaw.json\'s meta.lastTouchedVersion is newer than the currently installed openclaw CLI. This usually means the macOS app (or another installation) auto-updated and touched the shared config file, possibly introducing schema elements the older CLI doesn\'t understand. If you see unexpected "Config invalid" errors, this is often the upstream cause. Often co-occurs with redacted-placeholder-corruption.', + detect: (diag) => { + const touched = diag.configDiagnostics?.lastTouchedVersion; + const installed = diag.openclaw?.version; + if (!touched || !installed) return false; + // strip any "OpenClaw " prefix and "(hash)" suffix from installed + const iv = String(installed).replace(/^OpenClaw\s+/, '').split(/\s+/)[0]; + const semverish = /^\d+\.\d+\.\d+/; + if (!semverish.test(touched) || !semverish.test(iv)) return false; + const parts = (v) => v.split('.').map(Number); + const [a1,a2,a3] = parts(touched); + const [b1,b2,b3] = parts(iv); + if (a1 !== b1) return a1 > b1; + if (a2 !== b2) return a2 > b2; + return a3 > b3; + }, + fix: `# Fix: bring the CLI up to the newer version +# The newer version is whatever rewrote the config (usually the macOS app). +# Syncing the CLI removes the mismatch warnings and unlocks openclaw update / doctor. +echo "CLI version installed: $(openclaw --version)" +echo "Config lastTouchedVersion (from openclaw.json): $(jq -r '.meta.lastTouchedVersion // "(unset)"' ~/.openclaw/openclaw.json)" +echo "" +echo "Bring the CLI up to the latest published version:" +echo " npm install -g openclaw@beta # or @latest for the stable channel" +echo "" +echo "If you don't want the macOS app to keep pulling ahead:" +echo " open the OpenClaw app preferences and disable auto-updates," +echo " or lock it to the same release channel as your CLI."`, + }, + // ─── 2026-04-20 additions ────────────────────────────────────────────── // Patterns discovered while fixing a live production Mac mini. Each fix // script is conservative: backs up before writing, pauses before any @@ -1150,23 +1495,37 @@ echo "✅ Gateway restarted. New cron runs will use gh's stored token."`, const logs = (diag.logs?.errors || '') + '\n' + (diag.logs?.stderr || ''); return /\[skills-remote\] remote bin probe timed out/i.test(logs); }, - fix: `# Fix: clear stale paired nodes (backs up first; restart gateway) + fix: `# Fix: remove stale paired nodes (backs up first; restart gateway) set -u PAIRED=~/.openclaw/nodes/paired.json [ -f "$PAIRED" ] || { echo "No paired.json found; nothing to do."; exit 0; } -# Show current pairings before clearing echo "Current paired nodes:" openclaw nodes list 2>/dev/null || /usr/bin/env python3 -c "import json;print(list(json.load(open('$PAIRED')).keys()))" echo "" -read -r -p "Clear all paired nodes? [y/N] " ANS -[ "$ANS" = "y" ] || [ "$ANS" = "Y" ] || { echo "skipped"; exit 0; } TS=$(date +%Y%m%d-%H%M%S) /bin/cp -p "$PAIRED" "$PAIRED.pre-unpair-$TS" -echo '{}' > "$PAIRED" -/bin/chmod 600 "$PAIRED" -echo "✅ Paired-nodes cleared (backup at $PAIRED.pre-unpair-$TS)" + +if openclaw nodes remove --help >/dev/null 2>&1; then + echo "OpenClaw supports targeted node removal." + read -r -p "Node id/name/ip to remove (leave blank to skip): " NODE + if [ -n "$NODE" ]; then + openclaw nodes remove --node "$NODE" + echo "✅ Removed stale node: $NODE" + else + echo "skipped" + exit 0 + fi +else + echo "This OpenClaw build lacks 'openclaw nodes remove'; falling back to clearing paired.json." + read -r -p "Clear all paired nodes? [y/N] " ANS + [ "$ANS" = "y" ] || [ "$ANS" = "Y" ] || { echo "skipped"; exit 0; } + echo '{}' > "$PAIRED" + /bin/chmod 600 "$PAIRED" + echo "✅ Paired nodes cleared (backup at $PAIRED.pre-unpair-$TS)" +fi + openclaw gateway restart`, }, diff --git a/src/landing.js b/src/landing.js index f1b9334..51bbf77 100644 --- a/src/landing.js +++ b/src/landing.js @@ -8,7 +8,7 @@ landingRouter.get('/', (req, res) => { return res.json({ name: 'ClawFix', tagline: 'AI-powered OpenClaw repair', - version: '0.9.0', + version: '0.10.0', fix: 'curl -sSL clawfix.dev/fix | bash', }); } @@ -546,6 +546,27 @@ const LANDING_HTML = `

macOS — LaunchAgent EnvironmentVariables carries old secrets after a .env migration

+
+ 🩹 +
+

Config Redacted-Placeholder Corruption

+

Literal __OPENCLAW_REDACTED__ persisted to openclaw.json — blocks openclaw update and schema validation

+
+
+
+ 📉 +
+

Incomplete npm Install

+

Unmet transitive deps break plugin load — e.g. Discord missing discord-api-types after a bad upstream publish

+
+
+
+ ↕️ +
+

Version Drift

+

Config last written by a newer OpenClaw than the installed CLI (usually when the macOS app auto-updates ahead)

+
+
diff --git a/src/routes/diagnose.js b/src/routes/diagnose.js index f6e6cab..4d8664a 100644 --- a/src/routes/diagnose.js +++ b/src/routes/diagnose.js @@ -36,6 +36,7 @@ Your expertise comes from real-world experience running OpenClaw in production: - Browser automation (Chrome relay, managed browser, headless deployments) - Plugin configuration (Mem0, LanceDB, Matrix, Discord) - Token usage optimization (heartbeat intervals, model selection, pruning) +- OpenClaw update/channel issues (2026.4.26 bundled plugin paths, update status, runtime deps) - VPS and headless deployment issues - macOS-specific issues (Metal GPU, Peekaboo, Apple Silicon) - Service manager recovery (launchd on macOS, systemd on Linux) @@ -69,6 +70,19 @@ Your expertise comes from real-world experience running OpenClaw in production: **Pattern:** Extension sends raw gateway token instead of HMAC-derived relay token. Missing connect.challenge handshake. Single-attempt 500ms re-attach instead of multi-attempt [300, 700, 1500ms]. No options-validation.js file. **Fix:** Update OpenClaw (openclaw update) then reload the extension in chrome://extensions. If needed, manually sync from upstream assets/chrome-extension/. +### Bundled Plugin Path Aliases After 2026.4.26 +**Pattern:** configDiagnostics.bundledPluginLoadPaths contains paths like /opt/homebrew/lib/node_modules/openclaw/dist/extensions/codex or /discord, or logs mention "ignored plugins.load.paths entry that points at OpenClaw's current bundled plugin directory". +**Root cause:** Older channel/setup flows wrote stock plugin paths into plugins.load.paths. Current OpenClaw bundles stock plugins directly, so these are redundant aliases. +**Fix:** Remove only bundled .../openclaw/dist/extensions/* entries from plugins.load.paths, validate config, restart gateway. Preserve real external plugin paths. + +### ACPX/Codex Warm-Up Probe Timeout +**Pattern:** update or gateway status reports "gateway timeout after 10000ms" or "Warm-up", while logs still show ACPX/hook startup progress such as "loaded internal hook handlers" followed later by "embedded acpx runtime backend registered" and "[gateway] ready". +**Fix:** Do not restart repeatedly while logs are progressing. Wait up to 90 seconds, then run openclaw gateway status --deep and openclaw health. + +### 2026.4.26 Release-Aware Fixes +**Pattern:** update.available is true, configDiagnostics shows version drift, or install.unmetCount > 1. +**Fix:** Prefer the current CLI's built-in repairs: openclaw update, openclaw doctor --fix for runtime deps, openclaw migrate where applicable, and openclaw nodes remove --node for stale paired nodes. OPENCLAW_NO_AUTO_UPDATE=1 is an incident-recovery kill switch. + ### Diagnostic Field Reference (new fields in v0.4.0+) - service.manager: "launchd" (macOS) | "systemd" (Linux) | "none" - service.state: "running" | "sigterm" | "crashed" | "inactive" | "not_registered" @@ -84,6 +98,11 @@ Your expertise comes from real-world experience running OpenClaw in production: - browser.extension.missingOptionsValidation: true if options-validation.js is missing (outdated) - browser.extension.hasDeriveRelayToken: true if HMAC token derivation is present - browser.wrongPortHits: count of log lines showing extension connecting to wrong port (18789) +- update: parsed output from "openclaw update status --json" +- configDiagnostics.lastTouchedVersion: config meta.lastTouchedVersion +- configDiagnostics.redactedPlaceholderPaths: config paths holding "__OPENCLAW_REDACTED__" +- configDiagnostics.bundledPluginLoadPaths: plugins.load.paths entries pointing into OpenClaw's own dist/extensions +- install.unmetCount: number of "UNMET DEPENDENCY" lines from npm ls against the OpenClaw install Rules: 1. Generate bash fix scripts that are safe, idempotent, and well-commented @@ -112,9 +131,18 @@ diagnoseRouter.post('/diagnose', async (req, res) => { let knownIssues = detectIssues(diagnostic); // Step 1b: If CLI sent local issues, try to match them to known fixes by text similarity - if (diagnostic._localIssues?.length && knownIssues.length === 0) { + if (diagnostic._localIssues?.length) { const matchedIds = new Set(knownIssues.map(i => i.id)); for (const local of diagnostic._localIssues) { + if (local.id && !matchedIds.has(local.id)) { + const direct = KNOWN_ISSUES.find(known => known.id === local.id); + if (direct) { + knownIssues.push(direct); + matchedIds.add(direct.id); + continue; + } + } + const text = (local.text || '').toLowerCase(); for (const known of KNOWN_ISSUES) { if (matchedIds.has(known.id)) continue; @@ -128,7 +156,12 @@ diagnoseRouter.post('/diagnose', async (req, res) => { (text.includes('restart') && title.includes('restart')) || (text.includes('watchdog') && title.includes('watchdog')) || (text.includes('zombie') && title.includes('zombie')) || - (text.includes('metadata') && title.includes('metadata'))) { + (text.includes('metadata') && title.includes('metadata')) || + (text.includes('bundled') && title.includes('bundled')) || + (text.includes('plugins.load.paths') && title.includes('plugins.load.paths')) || + (text.includes('acpx') && title.includes('acpx')) || + (text.includes('codex runtime') && title.includes('codex')) || + (text.includes('update available') && title.includes('update'))) { knownIssues.push(known); matchedIds.add(known.id); break; @@ -253,7 +286,7 @@ diagnoseRouter.get('/stats', async (req, res) => { sigtermCrashes: dbStats?.sigtermCrashes || 0, zombieProcesses: dbStats?.zombieProcesses || 0, uptime: process.uptime(), - version: '0.6.0', + version: '0.11.0', aiProvider: AI_CONFIG.provider, aiModel: AI_CONFIG.model, aiAvailable: !!AI_CONFIG.apiKey, diff --git a/src/routes/script.js b/src/routes/script.js index 4ec8ced..a21cadc 100644 --- a/src/routes/script.js +++ b/src/routes/script.js @@ -52,7 +52,7 @@ set -euo pipefail # --- Config --- API_URL="\${CLAWFIX_API:-https://clawfix.dev}" -VERSION="0.4.0" +VERSION="0.11.0" # --- Colors --- RED='\\033[0;31m' @@ -130,6 +130,15 @@ if [ -n "\$OPENCLAW_BIN" ]; then OC_VERSION=\$("\$OPENCLAW_BIN" --version 2>/dev/null || echo "unknown") fi +# OpenClaw update status (safe metadata; no secrets) +UPDATE_STATUS_JSON="{}" +if [ -n "\$OPENCLAW_BIN" ]; then + UPDATE_STATUS_RAW=\$("\$OPENCLAW_BIN" update status --json 2>/dev/null || true) + if [ -n "\$UPDATE_STATUS_RAW" ]; then + UPDATE_STATUS_JSON=\$(printf '%s' "\$UPDATE_STATUS_RAW" | jq -c . 2>/dev/null || echo '{}') + fi +fi + echo -e " OS: \$OS_NAME \$OS_VERSION (\$OS_ARCH)" echo -e " Node: \$NODE_VERSION" echo -e " OpenClaw: \${OC_VERSION:-not found}" @@ -139,6 +148,7 @@ echo "" echo -e "\${BLUE}🔒 Reading config (secrets will be redacted)...\${NC}" SANITIZED_CONFIG="{}" +CONFIG_DIAGNOSTICS="{}" if [ -n "\$OPENCLAW_CONFIG" ]; then # Redact anything that looks like a key, token, secret, or password SANITIZED_CONFIG=\$(jq ' @@ -157,6 +167,15 @@ if [ -n "\$OPENCLAW_CONFIG" ]; then | if .gateway.auth then .gateway.auth.token = "***REDACTED***" else . end | if .channels then (.channels | to_entries | map(.value.accessToken = "***REDACTED***" | .value.apiKey = "***REDACTED***") | from_entries) as \$ch | .channels = \$ch else . end ' "\$OPENCLAW_CONFIG" 2>/dev/null || echo '{"error": "could not parse config"}') + + CONFIG_DIAGNOSTICS=\$(jq -c ' + def dotted: map(tostring) | join("."); + { + lastTouchedVersion: (.meta.lastTouchedVersion // null), + redactedPlaceholderPaths: ([paths(scalars) as $p | select(getpath($p) == "__OPENCLAW_REDACTED__") | $p | dotted]), + bundledPluginLoadPaths: ((.plugins.load.paths // []) | map(select(type == "string" and test("/openclaw/dist/extensions/[^/]+/?$")))) + } + ' "\$OPENCLAW_CONFIG" 2>/dev/null || echo '{}') echo -e "\${GREEN} ✅ Config read and sanitized\${NC}" else @@ -358,6 +377,39 @@ check_port "\${GATEWAY_PORT:-18789}" "gateway" check_port 18800 "browser CDP" check_port 18791 "browser control" +# --- Check OpenClaw Install Integrity --- +echo "" +echo -e "\${BLUE}📦 Checking OpenClaw install integrity...\${NC}" + +INSTALL_DIAGNOSTICS="{}" +if [ -n "\$OPENCLAW_BIN" ] && command -v npm &>/dev/null; then + GLOBAL_ROOT=\$(npm root -g 2>/dev/null || true) + OC_INSTALL_DIR="" + if [ -n "\$GLOBAL_ROOT" ] && [ -d "\$GLOBAL_ROOT/openclaw" ]; then + OC_INSTALL_DIR="\$GLOBAL_ROOT/openclaw" + elif [ -d "/opt/homebrew/lib/node_modules/openclaw" ]; then + OC_INSTALL_DIR="/opt/homebrew/lib/node_modules/openclaw" + elif [ -d "/usr/local/lib/node_modules/openclaw" ]; then + OC_INSTALL_DIR="/usr/local/lib/node_modules/openclaw" + fi + + if [ -n "\$OC_INSTALL_DIR" ]; then + NPM_LS_OUT=\$(npm ls --prefix "\$OC_INSTALL_DIR" --depth=0 2>&1 || true) + UNMET_COUNT=\$(printf '%s' "\$NPM_LS_OUT" | grep -c 'UNMET DEPENDENCY' || true) + UNMET_JSON=\$(printf '%s' "\$NPM_LS_OUT" | grep 'UNMET DEPENDENCY' | awk '{print \$NF}' | jq -Rsc 'split("\\n")[:-1]' 2>/dev/null || echo '[]') + INSTALL_DIAGNOSTICS=\$(jq -n --arg root "\$OC_INSTALL_DIR" --argjson unmetCount "\$UNMET_COUNT" --argjson unmet "\$UNMET_JSON" '{root:$root, unmetCount:$unmetCount, unmet:$unmet}') + if [ "\$UNMET_COUNT" -gt 1 ]; then + echo -e " \${YELLOW}⚠️ \$UNMET_COUNT unmet OpenClaw npm dependencies\${NC}" + else + echo -e " \${GREEN}✅ OpenClaw npm deps look sane\${NC}" + fi + else + echo -e " \${YELLOW}⚠️ Could not locate global OpenClaw package directory\${NC}" + fi +else + echo -e " \${YELLOW}⚠️ npm/openclaw unavailable; skipped install integrity check\${NC}" +fi + # --- Build Diagnostic Payload --- echo "" echo -e "\${BLUE}📦 Building diagnostic report...\${NC}" @@ -384,6 +436,7 @@ DIAGNOSTIC=\$(cat </dev/null || echo "0") +if [ "\$BUNDLED_PLUGIN_PATH_COUNT" -gt 0 ]; then + ISSUES=\$((ISSUES + 1)) + ISSUE_LIST="\${ISSUE_LIST} \${YELLOW}⚠️ Bundled plugin aliases in plugins.load.paths\${NC}\\n" +fi + +if echo "\$SANITIZED_CONFIG" | jq -e '.plugins.entries.acpx.config.permissionMode == "approve-all"' &>/dev/null; then + ISSUES=\$((ISSUES + 1)) + ISSUE_LIST="\${ISSUE_LIST} \${YELLOW}⚠️ ACPX permissionMode=approve-all (security policy advisory)\${NC}\\n" +fi + +if echo "\$UPDATE_STATUS_JSON" | jq -e '(.available == true) or (.updateAvailable == true) or (.hasUpdate == true) or (.hasRegistryUpdate == true) or (.availability.available == true) or (.registry.available == true) or (.registry.hasUpdate == true)' &>/dev/null; then + ISSUES=\$((ISSUES + 1)) + ISSUE_LIST="\${ISSUE_LIST} \${YELLOW}⚠️ OpenClaw update available\${NC}\\n" +fi + +INSTALL_UNMET_COUNT=\$(echo "\$INSTALL_DIAGNOSTICS" | jq -r '.unmetCount // 0' 2>/dev/null || echo "0") +if [ "\$INSTALL_UNMET_COUNT" -gt 1 ]; then + ISSUES=\$((ISSUES + 1)) + ISSUE_LIST="\${ISSUE_LIST} \${RED}❌ Incomplete OpenClaw npm install (\$INSTALL_UNMET_COUNT unmet deps)\${NC}\\n" +fi + +if printf '%s\\n%s\\n%s' "\$GATEWAY_STATUS" "\$GATEWAY_LOG" "\$ERROR_LOG" | grep -Eqi 'gateway timeout after 10000ms|Warm-up: launch agents|health.*timed out' && + printf '%s\\n%s\\n%s' "\$GATEWAY_STATUS" "\$GATEWAY_LOG" "\$ERROR_LOG" | grep -Eqi 'acpx|loaded [0-9]+ internal hook handlers|embedded acpx runtime backend registered'; then + ISSUES=\$((ISSUES + 1)) + ISSUE_LIST="\${ISSUE_LIST} \${YELLOW}⚠️ Gateway probe timed out during ACPX/Codex warm-up\${NC}\\n" +fi + if [ "\$SOUL_EXISTS" = "false" ]; then ISSUES=\$((ISSUES + 1)) ISSUE_LIST="\${ISSUE_LIST} \${YELLOW}⚠️ No SOUL.md found (agent has no personality)\${NC}\\n"