From aa84be20a5580e631fd1c5f6471ab1034fe7711f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 27 Jul 2026 21:53:33 -0700 Subject: [PATCH] OpenClaw 2026.7.1-2 hotfix pin + watchdog safe-mode detection with resume UI. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin openclaw to the 2026.7.1-2 correction release: plain 2026.7.1 has the doctor --fix bug that silently strips official external-catalog plugin ids (codex) from plugins.allow (openclaw#107226, fixed by openclaw#108336) — and the watchdog runs doctor --fix --yes during auto-repair. Watchdog: detect OpenClaw's control-plane-safe mode. After the gateway's crash-loop breaker trips, /health stays green while channel autostart is suppressed; the watchdog previously reported fully healthy. Now it probes /readyz after each healthy check, exposes safeMode/suppressedChannels in status, notifies once per suppression set, and offers POST /api/watchdog/resume-channels (openclaw's channels.start override per suppressed channel). The Watchdog tab shows a safe-mode banner with the suppressed channel list and a Resume channels action, and the gateway badge shows amber "safe mode" instead of green "healthy". Set vitest testTimeout to 30s: openclaw's plugin-sdk pays a >5s dynamic import cost on first load per worker, which flakes timing-sensitive tests under parallel load. Co-Authored-By: Claude Fable 5 --- lib/public/js/components/gateway.js | 27 ++- .../js/components/watchdog-tab/helpers.js | 20 ++ .../js/components/watchdog-tab/index.js | 7 + .../watchdog-tab/safe-mode-banner.js | 37 +++ .../watchdog-tab/settings/use-settings.js | 22 ++ .../watchdog-tab/use-watchdog-tab.js | 2 + lib/public/js/lib/api.js | 7 + lib/server.js | 1 + lib/server/routes/watchdog.js | 13 + lib/server/watchdog.js | 155 +++++++++++- package-lock.json | 67 +----- package.json | 2 +- tests/frontend/api.test.js | 27 +++ tests/frontend/watchdog-helpers.test.js | 35 +++ tests/server/routes-watchdog.test.js | 37 +++ .../watchdog-gateway-hardening.e2e.test.js | 225 ++++++++++++++++++ tests/server/watchdog.test.js | 170 +++++++++++++ vitest.config.js | 3 + 18 files changed, 786 insertions(+), 71 deletions(-) create mode 100644 lib/public/js/components/watchdog-tab/safe-mode-banner.js create mode 100644 tests/server/watchdog-gateway-hardening.e2e.test.js diff --git a/lib/public/js/components/gateway.js b/lib/public/js/components/gateway.js index 99dda239..2d0724be 100644 --- a/lib/public/js/components/gateway.js +++ b/lib/public/js/components/gateway.js @@ -38,19 +38,26 @@ export const Gateway = ({ : watchdogStatus?.health; const hasConfigurationError = watchdogStatus?.lifecycle === "configuration_error"; + // Safe mode: gateway healthy but channel autostart suppressed by its + // crash-loop breaker — a green "healthy" badge would be misleading. + const isSafeMode = !!watchdogStatus?.safeMode; const watchdogDotClass = - watchdogHealth === "healthy" - ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset" - : watchdogHealth === "degraded" - ? "bg-yellow-500" - : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop" - ? "bg-red-500" - : "bg-gray-500"; + isSafeMode && watchdogHealth === "healthy" + ? "bg-yellow-500" + : watchdogHealth === "healthy" + ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset" + : watchdogHealth === "degraded" + ? "bg-yellow-500" + : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop" + ? "bg-red-500" + : "bg-gray-500"; const watchdogLabel = hasConfigurationError ? "configuration error" - : watchdogHealth === "unknown" - ? "initializing" - : watchdogHealth || "unknown"; + : isSafeMode + ? "safe mode" + : watchdogHealth === "unknown" + ? "initializing" + : watchdogHealth || "unknown"; const isRepairInProgress = repairing || !!watchdogStatus?.operationInProgress; const showInspectButton = watchdogHealth === "degraded" && !!onOpenWatchdog; const showRepairButton = diff --git a/lib/public/js/components/watchdog-tab/helpers.js b/lib/public/js/components/watchdog-tab/helpers.js index baaa244e..ff4264d7 100644 --- a/lib/public/js/components/watchdog-tab/helpers.js +++ b/lib/public/js/components/watchdog-tab/helpers.js @@ -115,6 +115,26 @@ export const getIncidentStatusTone = (event) => { }; }; +// OpenClaw 2026.7.1+ can boot into control-plane-safe mode after its +// crash-loop breaker trips: the gateway reports healthy while channel +// autostart stays suppressed. Returns null when no banner should render. +export const buildSafeModeBannerModel = (watchdogStatus = null) => { + if (!watchdogStatus?.safeMode) return null; + const channels = Array.isArray(watchdogStatus.suppressedChannels) + ? watchdogStatus.suppressedChannels + .map((entry) => String(entry || "").trim()) + .filter(Boolean) + : []; + return { + title: "Gateway is in safe mode", + body: + channels.length > 0 + ? `Channel autostart was suppressed by the gateway's crash-loop breaker. Suppressed: ${channels.join(", ")}. These channels are not delivering messages.` + : "Channel autostart was suppressed by the gateway's crash-loop breaker.", + channels, + }; +}; + export const formatWatchdogCopyAllText = ({ logs = "", generatedAt = null, diff --git a/lib/public/js/components/watchdog-tab/index.js b/lib/public/js/components/watchdog-tab/index.js index b56936ca..ba9e67f3 100644 --- a/lib/public/js/components/watchdog-tab/index.js +++ b/lib/public/js/components/watchdog-tab/index.js @@ -2,6 +2,7 @@ import { h } from "preact"; import htm from "htm"; import { Gateway } from "../gateway.js"; import { useWatchdogTab } from "./use-watchdog-tab.js"; +import { WatchdogSafeModeBanner } from "./safe-mode-banner.js"; import { WatchdogResourcesCard } from "./resources/index.js"; import { WatchdogSettingsCard } from "./settings/index.js"; import { WatchdogConsoleCard } from "./console/index.js"; @@ -26,6 +27,12 @@ export const WatchdogTab = ({ return html`
+ <${WatchdogSafeModeBanner} + watchdogStatus=${state.currentWatchdogStatus} + onResumeChannels=${state.onResumeChannels} + resuming=${state.resumingChannels} + /> + <${Gateway} status=${gatewayStatus} openclawVersion=${openclawVersion} diff --git a/lib/public/js/components/watchdog-tab/safe-mode-banner.js b/lib/public/js/components/watchdog-tab/safe-mode-banner.js new file mode 100644 index 00000000..c32215ce --- /dev/null +++ b/lib/public/js/components/watchdog-tab/safe-mode-banner.js @@ -0,0 +1,37 @@ +import { h } from "preact"; +import htm from "htm"; +import { UpdateActionButton } from "../update-action-button.js"; +import { buildSafeModeBannerModel } from "./helpers.js"; + +const html = htm.bind(h); + +export const WatchdogSafeModeBanner = ({ + watchdogStatus = null, + onResumeChannels = () => {}, + resuming = false, +}) => { + const model = buildSafeModeBannerModel(watchdogStatus); + if (!model) return null; + + return html` +
+
+
+
+ + ${model.title} +
+

${model.body}

+
+ <${UpdateActionButton} + onClick=${onResumeChannels} + loading=${resuming} + disabled=${resuming} + warning=${true} + idleLabel="Resume channels" + loadingLabel="Resuming..." + /> +
+
+ `; +}; diff --git a/lib/public/js/components/watchdog-tab/settings/use-settings.js b/lib/public/js/components/watchdog-tab/settings/use-settings.js index 228e36d3..c87c9804 100644 --- a/lib/public/js/components/watchdog-tab/settings/use-settings.js +++ b/lib/public/js/components/watchdog-tab/settings/use-settings.js @@ -1,6 +1,7 @@ import { useEffect, useState } from "preact/hooks"; import { fetchWatchdogSettings, + resumeWatchdogChannels, triggerWatchdogRepair, updateWatchdogSettings, } from "../../../lib/api.js"; @@ -17,6 +18,7 @@ export const useWatchdogSettings = ({ }); const [savingSettings, setSavingSettings] = useState(false); const [repairing, setRepairing] = useState(false); + const [resumingChannels, setResumingChannels] = useState(false); const isRepairInProgress = repairing || !!(watchdogStatus || {})?.operationInProgress; @@ -106,6 +108,24 @@ export const useWatchdogSettings = ({ } }; + const onResumeChannels = async () => { + if (resumingChannels) return; + setResumingChannels(true); + try { + const data = await resumeWatchdogChannels(); + if (!data.ok) throw new Error(data.error || "Resume failed"); + showToast("Channels resuming", "success"); + setTimeout(() => { + onRefreshStatuses(); + onRefreshIncidents(); + }, 800); + } catch (error) { + showToast(error.message || "Could not resume channels", "error"); + } finally { + setResumingChannels(false); + } + }; + return { settings, savingSettings, @@ -113,5 +133,7 @@ export const useWatchdogSettings = ({ onToggleAutoRepair, onToggleNotifications, onRepair, + onResumeChannels, + resumingChannels, }; }; diff --git a/lib/public/js/components/watchdog-tab/use-watchdog-tab.js b/lib/public/js/components/watchdog-tab/use-watchdog-tab.js index 9abd45f9..f2277567 100644 --- a/lib/public/js/components/watchdog-tab/use-watchdog-tab.js +++ b/lib/public/js/components/watchdog-tab/use-watchdog-tab.js @@ -34,6 +34,8 @@ export const useWatchdogTab = ({ onToggleNotifications: settings.onToggleNotifications, onRepair: settings.onRepair, isRepairInProgress: settings.isRepairInProgress, + onResumeChannels: settings.onResumeChannels, + resumingChannels: settings.resumingChannels, logs: consoleState.logs, loadingLogs: consoleState.loadingLogs, copyingAll: consoleState.copyingAll, diff --git a/lib/public/js/lib/api.js b/lib/public/js/lib/api.js index 3f582997..3b2f9658 100644 --- a/lib/public/js/lib/api.js +++ b/lib/public/js/lib/api.js @@ -445,6 +445,13 @@ export async function triggerWatchdogRepair() { return parseJsonOrThrow(res, "Could not trigger watchdog repair"); } +export async function resumeWatchdogChannels() { + const res = await authFetch("/api/watchdog/resume-channels", { + method: "POST", + }); + return parseJsonOrThrow(res, "Could not resume channels"); +} + export async function fetchWatchdogResources() { const res = await authFetch("/api/watchdog/resources"); return parseJsonOrThrow(res, "Could not load system resources"); diff --git a/lib/server.js b/lib/server.js index e0ae22e4..de78afdd 100644 --- a/lib/server.js +++ b/lib/server.js @@ -291,6 +291,7 @@ const watchdog = createWatchdog({ reloadEnv, resolveSetupUrl, resolveGatewayHealthUrl: () => `${getGatewayUrl()}/health`, + resolveGatewayReadyzUrl: () => `${getGatewayUrl()}/readyz`, }); const watchdogTerminal = createWatchdogTerminalService({ cwd: constants.OPENCLAW_DIR, diff --git a/lib/server/routes/watchdog.js b/lib/server/routes/watchdog.js index 45b1f078..23ddfef1 100644 --- a/lib/server/routes/watchdog.js +++ b/lib/server/routes/watchdog.js @@ -51,6 +51,19 @@ const registerWatchdogRoutes = ({ } }); + app.post("/api/watchdog/resume-channels", requireAuth, async (req, res) => { + try { + const result = await watchdog.resumeChannels(); + if (result?.skipped) { + res.status(409).json({ ok: false, error: result.reason, result }); + return; + } + res.json({ ok: !!result?.ok, result }); + } catch (err) { + res.status(500).json({ ok: false, error: err.message }); + } + }); + app.get("/api/watchdog/settings", requireAuth, (req, res) => { try { res.json({ ok: true, settings: watchdog.getSettings() }); diff --git a/lib/server/watchdog.js b/lib/server/watchdog.js index 856e7fca..e9788ca0 100644 --- a/lib/server/watchdog.js +++ b/lib/server/watchdog.js @@ -11,6 +11,16 @@ const kHealthStartupGraceMs = 30 * 1000; const kBootstrapHealthCheckMs = 5 * 1000; const kExpectedRestartWindowMs = 15 * 1000; const kGatewayHealthTimeoutMs = 5 * 1000; +// OpenClaw 2026.7.1+ exits with EX_CONFIG (78, sysexits.h) on fatal +// configuration errors. The contract is "do not restart until the config is +// fixed" — restarting blindly recreates the restart storm the gateway is +// trying to prevent. +const kOpenclawConfigErrorExitCode = 78; + +const shellEscapeArg = (value) => { + const safeValue = String(value || ""); + return `'${safeValue.replace(/'/g, `'\\''`)}'`; +}; const isTruthy = (value) => ["1", "true", "yes", "on"].includes( @@ -42,6 +52,7 @@ const createWatchdog = ({ reloadEnv, resolveSetupUrl, resolveGatewayHealthUrl = () => "", + resolveGatewayReadyzUrl = () => "", }) => { const state = { lifecycle: "stopped", @@ -64,6 +75,9 @@ const createWatchdog = ({ awaitingAutoRepairRecovery: false, startupConsecutiveHealthFailures: 0, configurationErrorActive: false, + safeMode: false, + suppressedChannels: [], + safeModeNotifiedKey: "", }; let healthTimer = null; let bootstrapHealthTimer = null; @@ -338,6 +352,138 @@ const createWatchdog = ({ } }; + // OpenClaw 2026.7.1+ can boot into control-plane-safe mode after its own + // crash-loop breaker trips: /health stays green while channel autostart is + // suppressed. /readyz reports the suppressed channels. + const probeGatewayReadiness = async () => { + const readyzUrl = String(resolveGatewayReadyzUrl() || "").trim(); + if (!readyzUrl) { + return { ok: false, reason: "gateway readyz URL unavailable" }; + } + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + kGatewayHealthTimeoutMs, + ); + try { + const response = await fetch(readyzUrl, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + const rawBody = await response.text(); + let parsedBody = null; + try { + parsedBody = rawBody ? JSON.parse(rawBody) : null; + } catch {} + if (!response.ok || !parsedBody || typeof parsedBody !== "object") { + return { + ok: false, + reason: `gateway readyz returned HTTP ${response.status}`, + }; + } + return { + ok: true, + ready: parsedBody.ready !== false, + failing: Array.isArray(parsedBody.failing) ? parsedBody.failing : [], + suppressed: Array.isArray(parsedBody.suppressed) + ? parsedBody.suppressed.map((entry) => String(entry || "")).filter(Boolean) + : [], + }; + } catch (error) { + const message = + error?.name === "AbortError" + ? `gateway readyz timed out after ${kGatewayHealthTimeoutMs}ms` + : error?.message || "gateway readyz request failed"; + return { ok: false, reason: message }; + } finally { + clearTimeout(timeoutId); + } + }; + + const clearSafeModeState = () => { + state.safeMode = false; + state.suppressedChannels = []; + state.safeModeNotifiedKey = ""; + }; + + const evaluateChannelSuppression = async (source, correlationId) => { + const readiness = await probeGatewayReadiness(); + if (!readiness.ok) return; + const suppressed = readiness.suppressed; + if (suppressed.length > 0) { + const notifiedKey = suppressed.slice().sort().join(","); + const changed = state.safeModeNotifiedKey !== notifiedKey; + state.safeMode = true; + state.suppressedChannels = suppressed; + if (!changed) return; + state.safeModeNotifiedKey = notifiedKey; + logEvent( + "safe_mode", + source, + "failed", + { suppressed, failing: readiness.failing }, + correlationId, + ); + await notify( + [ + "🐺 *AlphaClaw Watchdog*", + withViewLogsSuffix( + "🟡 Gateway is in safe mode — channel autostart suppressed by its crash-loop breaker", + ), + `Suppressed channels: ${suppressed.join(", ")}`, + "The gateway is up but these channels are not delivering messages. Resume them from the Watchdog tab once the crash cause is fixed.", + ].join("\n"), + correlationId, + "crash", + ); + return; + } + if (state.safeMode) { + clearSafeModeState(); + logEvent("safe_mode", source, "ok", { recovered: true }, correlationId); + await notify( + [ + "🐺 *AlphaClaw Watchdog*", + withViewLogsSuffix("🟢 Gateway safe mode cleared — channels resumed"), + ].join("\n"), + correlationId, + "recovery", + ); + } + }; + + const resumeChannels = async () => { + const correlationId = createCorrelationId(); + const channels = [...state.suppressedChannels]; + if (channels.length === 0) { + return { ok: false, skipped: true, reason: "no_suppressed_channels" }; + } + const results = []; + for (const channel of channels) { + const params = JSON.stringify({ channel }); + const result = await clawCmd( + `gateway call channels.start --params ${shellEscapeArg(params)}`, + { quiet: true }, + ); + const ok = !!result?.ok; + results.push({ channel, ok, stderr: ok ? undefined : result?.stderr }); + logEvent( + "safe_mode_resume", + "manual", + ok ? "ok" : "failed", + { channel, stderr: result?.stderr || null }, + correlationId, + ); + } + await runHealthCheck({ + source: "resume_channels", + allowAutoRepair: false, + allowDuringOperation: true, + }); + return { ok: results.every((entry) => entry.ok), results }; + }; + const updateSettings = ({ autoRepair, notificationsEnabled } = {}) => { const hasAutoRepair = typeof autoRepair === "boolean"; const hasNotificationsEnabled = typeof notificationsEnabled === "boolean"; @@ -573,6 +719,7 @@ const createWatchdog = ({ parsed.details || { ok: true }, correlationId, ); + await evaluateChannelSuppression(source, correlationId); return true; } if (restartWindowActive) { @@ -702,6 +849,7 @@ const createWatchdog = ({ } = {}) => { const correlationId = createCorrelationId(); clearDegradedHealthCheckTimer(); + clearSafeModeState(); if (expectedExit && (code == null || code === 0)) { state.lifecycle = "restarting"; state.health = "unknown"; @@ -742,7 +890,7 @@ const createWatchdog = ({ return; } - if (code === 78) { + if (code === kOpenclawConfigErrorExitCode) { state.configurationErrorActive = true; state.lifecycle = "configuration_error"; state.health = "unhealthy"; @@ -843,6 +991,7 @@ const createWatchdog = ({ const onExpectedRestart = () => { clearDegradedHealthCheckTimer(); + clearSafeModeState(); state.lifecycle = "restarting"; state.health = "unknown"; state.uptimeStartedAt = null; @@ -886,6 +1035,7 @@ const createWatchdog = ({ state.startupConsecutiveHealthFailures = 0; state.awaitingAutoRepairRecovery = false; state.pendingRecoveryNoticeSource = ""; + clearSafeModeState(); closeIncident(); }; @@ -906,6 +1056,8 @@ const createWatchdog = ({ crashLoopWindowMs: kWatchdogCrashLoopWindowMs, operationInProgress: state.operationInProgress, gatewayPid: state.gatewayPid, + safeMode: state.safeMode, + suppressedChannels: [...state.suppressedChannels], }; }; @@ -914,6 +1066,7 @@ const createWatchdog = ({ getSettings, updateSettings, triggerRepair, + resumeChannels, onExpectedRestart, onGatewayExit, onGatewayLaunch, diff --git a/package-lock.json b/package-lock.json index db86e1e6..46d69c14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "2026.7.1", + "openclaw": "2026.7.1-2", "ws": "^8.19.0" }, "bin": { @@ -1039,18 +1039,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.19.0" - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", @@ -2252,18 +2240,6 @@ "node": ">=8" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -2549,9 +2525,9 @@ } }, "node_modules/openclaw": { - "version": "2026.7.1", - "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1.tgz", - "integrity": "sha512-ge/Xss99CHAjPL/ikmH/UFoiOrjcxDB4sW3y9mhyCD+dYW3wzV7TKbAVdkrXFgAG2d2BjpJofP97zUZ+umxo8g==", + "version": "2026.7.1-2", + "resolved": "https://registry.npmjs.org/openclaw/-/openclaw-2026.7.1-2.tgz", + "integrity": "sha512-ycF3yPcbjN6bUPeaUx6Mh6vze1hQWoD3CT/wWcmD7a8xaHHHRUaAlaq+lFxMHf1ssEgODVAwjlzYqp2twkYZ7g==", "hasInstallScript": true, "hasShrinkwrap": true, "license": "MIT", @@ -2569,7 +2545,7 @@ "@mistralai/mistralai": "2.4.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "0.6.0", - "@openclaw/ai": "2026.7.1", + "@openclaw/ai": "2026.7.1-2", "@openclaw/fs-safe": "0.4.1", "@openclaw/proxyline": "0.3.3", "@silvia-odwyer/photon-node": "0.3.4", @@ -2974,9 +2950,9 @@ } }, "node_modules/openclaw/node_modules/@openclaw/ai": { - "version": "2026.7.1", - "resolved": "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.7.1.tgz", - "integrity": "sha512-FsKy5DXSHf4qyN8Huoz/10HZRgoEwLF4uk8UWaCafaIler+q5Fsl51HcrIqIrEe0S38OT7LOaxnR++MOshAlmw==", + "version": "2026.7.1-2", + "resolved": "https://registry.npmjs.org/@openclaw/ai/-/ai-2026.7.1-2.tgz", + "integrity": "sha512-st+NH0cxlQqdbEur//yYqM7WlYBjeEBnop3cztJTSCONKjv6LNoGguI9cH65asZG94FdM/39z857isRGPnZvEw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.109.1", @@ -7224,15 +7200,6 @@ "node": ">= 0.6" } }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7962,24 +7929,6 @@ "optional": true } } - }, - "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } } } } diff --git a/package.json b/package.json index fa5f739a..36ebaea4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dependencies": { "express": "^4.21.0", "http-proxy": "^1.18.1", - "openclaw": "2026.7.1", + "openclaw": "2026.7.1-2", "ws": "^8.19.0" }, "devDependencies": { diff --git a/tests/frontend/api.test.js b/tests/frontend/api.test.js index 5f982b06..78f90d66 100644 --- a/tests/frontend/api.test.js +++ b/tests/frontend/api.test.js @@ -655,4 +655,31 @@ describe("frontend/api", () => { expectLastFetchHeaders("application/json"); expect(result).toEqual({ ok: true }); }); + + it("resumeWatchdogChannels posts to the resume endpoint and returns the result", async () => { + const payload = { + ok: true, + result: { ok: true, results: [{ channel: "telegram", ok: true }] }, + }; + global.fetch.mockResolvedValue(mockJsonResponse(200, payload)); + const api = await loadApiModule(); + + const result = await api.resumeWatchdogChannels(); + + const [url, options] = global.fetch.mock.calls.at(-1); + expect(url).toBe("/api/watchdog/resume-channels"); + expect(options.method).toBe("POST"); + expect(result).toEqual(payload); + }); + + it("resumeWatchdogChannels surfaces server error messages", async () => { + global.fetch.mockResolvedValue( + mockJsonResponse(409, { ok: false, error: "no_suppressed_channels" }), + ); + const api = await loadApiModule(); + + await expect(api.resumeWatchdogChannels()).rejects.toThrow( + "no_suppressed_channels", + ); + }); }); diff --git a/tests/frontend/watchdog-helpers.test.js b/tests/frontend/watchdog-helpers.test.js index 56771377..6ff594eb 100644 --- a/tests/frontend/watchdog-helpers.test.js +++ b/tests/frontend/watchdog-helpers.test.js @@ -27,4 +27,39 @@ describe("frontend/watchdog-helpers", () => { expect(text).toContain("## Gateway Logs"); expect(text).toContain("No logs yet."); }); + + it("returns no safe-mode banner model when the gateway is not in safe mode", async () => { + const { buildSafeModeBannerModel } = await loadWatchdogHelpers(); + + expect(buildSafeModeBannerModel(null)).toBeNull(); + expect(buildSafeModeBannerModel({})).toBeNull(); + expect(buildSafeModeBannerModel({ safeMode: false })).toBeNull(); + }); + + it("builds a safe-mode banner model listing suppressed channels", async () => { + const { buildSafeModeBannerModel } = await loadWatchdogHelpers(); + + const model = buildSafeModeBannerModel({ + safeMode: true, + suppressedChannels: ["telegram", " discord ", "", null], + }); + + expect(model.title).toBe("Gateway is in safe mode"); + expect(model.channels).toEqual(["telegram", "discord"]); + expect(model.body).toContain("Suppressed: telegram, discord"); + expect(model.body).toContain("not delivering messages"); + }); + + it("builds a safe-mode banner model without a channel list when none reported", async () => { + const { buildSafeModeBannerModel } = await loadWatchdogHelpers(); + + const model = buildSafeModeBannerModel({ + safeMode: true, + suppressedChannels: "not-an-array", + }); + + expect(model.channels).toEqual([]); + expect(model.body).toContain("crash-loop breaker"); + expect(model.body).not.toContain("Suppressed:"); + }); }); diff --git a/tests/server/routes-watchdog.test.js b/tests/server/routes-watchdog.test.js index b718089b..29461224 100644 --- a/tests/server/routes-watchdog.test.js +++ b/tests/server/routes-watchdog.test.js @@ -8,6 +8,10 @@ const createDeps = () => { const watchdog = { getStatus: vi.fn(() => ({ lifecycle: "running", health: "healthy" })), triggerRepair: vi.fn(async () => ({ ok: true })), + resumeChannels: vi.fn(async () => ({ + ok: true, + results: [{ channel: "telegram", ok: true }], + })), getSettings: vi.fn(() => ({ autoRepair: true, notificationsEnabled: true })), updateSettings: vi.fn(({ autoRepair }) => ({ autoRepair, notificationsEnabled: true })), }; @@ -98,6 +102,39 @@ describe("server/routes/watchdog", () => { }); }); + it("resumes suppressed channels on POST /api/watchdog/resume-channels", async () => { + const deps = createDeps(); + const app = createApp(deps); + + const res = await request(app).post("/api/watchdog/resume-channels"); + + expect(res.status).toBe(200); + expect(deps.watchdog.resumeChannels).toHaveBeenCalledTimes(1); + expect(res.body).toEqual({ + ok: true, + result: { + ok: true, + results: [{ channel: "telegram", ok: true }], + }, + }); + }); + + it("returns 409 when resume-channels has nothing to resume", async () => { + const deps = createDeps(); + deps.watchdog.resumeChannels.mockResolvedValue({ + ok: false, + skipped: true, + reason: "no_suppressed_channels", + }); + const app = createApp(deps); + + const res = await request(app).post("/api/watchdog/resume-channels"); + + expect(res.status).toBe(409); + expect(res.body.ok).toBe(false); + expect(res.body.error).toBe("no_suppressed_channels"); + }); + it("returns 400 when updateSettings throws", async () => { const deps = createDeps(); deps.watchdog.updateSettings.mockImplementation(() => { diff --git a/tests/server/watchdog-gateway-hardening.e2e.test.js b/tests/server/watchdog-gateway-hardening.e2e.test.js new file mode 100644 index 00000000..47087e59 --- /dev/null +++ b/tests/server/watchdog-gateway-hardening.e2e.test.js @@ -0,0 +1,225 @@ +const express = require("express"); +const request = require("supertest"); + +const { createWatchdog } = require("../../lib/server/watchdog"); +const { registerWatchdogRoutes } = require("../../lib/server/routes/watchdog"); + +// End-to-end coverage for the OpenClaw 2026.7.1+ gateway-lifecycle contract: +// exit code 78 (EX_CONFIG) fatal config errors, and control-plane-safe mode +// where /health stays green while /readyz reports suppressed channels. Uses +// the real watchdog wired into the real routes against a stateful fake +// gateway. + +const flushMicrotasks = async () => + new Promise((resolve) => { + setImmediate(resolve); + }); + +const kOriginalAutoRepair = process.env.WATCHDOG_AUTO_REPAIR; +const kOriginalNotificationsDisabled = + process.env.WATCHDOG_NOTIFICATIONS_DISABLED; +const kOriginalFetch = global.fetch; + +const createFakeGateway = () => ({ + healthy: true, + suppressed: [], +}); + +const createStack = ({ autoRepair = false, fakeGateway } = {}) => { + process.env.WATCHDOG_AUTO_REPAIR = autoRepair ? "true" : "false"; + process.env.WATCHDOG_NOTIFICATIONS_DISABLED = "false"; + + const gateway = fakeGateway || createFakeGateway(); + + global.fetch = vi.fn(async (url) => { + if (!gateway.healthy) throw new Error("gateway unavailable"); + if (String(url).includes("/readyz")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + ready: true, + failing: [], + ...(gateway.suppressed.length > 0 + ? { suppressed: gateway.suppressed } + : {}), + }), + }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true, status: "live" }), + }; + }); + + const clawCmd = vi.fn(async (command) => { + if (command.startsWith("gateway call channels.start")) { + const paramsJson = command.replace( + /^gateway call channels\.start --params '(.*)'$/, + "$1", + ); + const params = JSON.parse(paramsJson); + gateway.suppressed = gateway.suppressed.filter( + (channel) => channel !== params.channel, + ); + return { ok: true, stdout: "{}" }; + } + if (command === "doctor --fix --yes") { + gateway.healthy = true; + return { ok: true, stdout: "fixed" }; + } + return { ok: true, stdout: "" }; + }); + + const launchGatewayProcess = vi.fn(() => ({ pid: 4242 })); + const insertWatchdogEvent = vi.fn(); + const notifier = { notify: vi.fn(async () => ({ ok: true })) }; + + const watchdog = createWatchdog({ + clawCmd, + launchGatewayProcess, + insertWatchdogEvent, + notifier, + readEnvFile: vi.fn(() => []), + writeEnvFile: vi.fn(), + reloadEnv: vi.fn(), + resolveSetupUrl: () => "https://setup.example.com", + resolveGatewayHealthUrl: () => "http://127.0.0.1:18789/health", + resolveGatewayReadyzUrl: () => "http://127.0.0.1:18789/readyz", + }); + + const app = express(); + app.use(express.json()); + registerWatchdogRoutes({ + app, + requireAuth: (req, res, next) => next(), + watchdog, + watchdogNotifier: notifier, + getRecentEvents: vi.fn(() => []), + readLogTail: vi.fn(() => ""), + watchdogTerminal: {}, + }); + + return { + app, + watchdog, + gateway, + clawCmd, + launchGatewayProcess, + insertWatchdogEvent, + notifier, + }; +}; + +describe("server/watchdog gateway hardening (e2e)", () => { + afterEach(() => { + if (kOriginalAutoRepair == null) { + delete process.env.WATCHDOG_AUTO_REPAIR; + } else { + process.env.WATCHDOG_AUTO_REPAIR = kOriginalAutoRepair; + } + if (kOriginalNotificationsDisabled == null) { + delete process.env.WATCHDOG_NOTIFICATIONS_DISABLED; + } else { + process.env.WATCHDOG_NOTIFICATIONS_DISABLED = + kOriginalNotificationsDisabled; + } + if (kOriginalFetch == null) { + delete global.fetch; + } else { + global.fetch = kOriginalFetch; + } + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("surfaces safe mode in the status API and recovers via resume-channels", async () => { + const { app, watchdog, gateway, clawCmd } = createStack(); + gateway.suppressed = ["telegram", "discord"]; + + watchdog.start(); + await flushMicrotasks(); + await flushMicrotasks(); + + const statusRes = await request(app).get("/api/watchdog/status"); + expect(statusRes.status).toBe(200); + expect(statusRes.body.status).toEqual( + expect.objectContaining({ + lifecycle: "running", + health: "healthy", + safeMode: true, + suppressedChannels: ["telegram", "discord"], + }), + ); + + const resumeRes = await request(app).post("/api/watchdog/resume-channels"); + expect(resumeRes.status).toBe(200); + expect(resumeRes.body.ok).toBe(true); + expect(resumeRes.body.result.results).toEqual([ + { channel: "telegram", ok: true }, + { channel: "discord", ok: true }, + ]); + expect(clawCmd).toHaveBeenCalledWith( + `gateway call channels.start --params '{"channel":"telegram"}'`, + { quiet: true }, + ); + expect(clawCmd).toHaveBeenCalledWith( + `gateway call channels.start --params '{"channel":"discord"}'`, + { quiet: true }, + ); + expect(gateway.suppressed).toEqual([]); + + const clearedRes = await request(app).get("/api/watchdog/status"); + expect(clearedRes.body.status).toEqual( + expect.objectContaining({ safeMode: false, suppressedChannels: [] }), + ); + + const idempotentRes = await request(app).post( + "/api/watchdog/resume-channels", + ); + expect(idempotentRes.status).toBe(409); + expect(idempotentRes.body.error).toBe("no_suppressed_channels"); + watchdog.stop(); + }); + + it("reports configuration_error on exit 78 and recovers through manual repair", async () => { + const { app, watchdog, gateway, clawCmd, launchGatewayProcess } = + createStack({ autoRepair: false }); + gateway.healthy = false; + + watchdog.onGatewayLaunch({ startedAt: Date.now(), pid: 1234 }); + watchdog.onGatewayExit({ + code: 78, + expectedExit: false, + stderrTail: ["fatal configuration error"], + }); + await flushMicrotasks(); + + const statusRes = await request(app).get("/api/watchdog/status"); + expect(statusRes.body.status).toEqual( + expect.objectContaining({ + lifecycle: "configuration_error", + health: "unhealthy", + crashCountInWindow: 0, + }), + ); + // The EX_CONFIG contract: no automatic relaunch without repair. + expect(launchGatewayProcess).not.toHaveBeenCalled(); + + const repairRes = await request(app).post("/api/watchdog/repair"); + expect(repairRes.status).toBe(200); + expect(repairRes.body.ok).toBe(true); + expect(clawCmd).toHaveBeenCalledWith("doctor --fix --yes", { + quiet: true, + }); + expect(launchGatewayProcess).toHaveBeenCalledTimes(1); + + const recoveredRes = await request(app).get("/api/watchdog/status"); + expect(recoveredRes.body.status).toEqual( + expect.objectContaining({ lifecycle: "running", health: "healthy" }), + ); + watchdog.stop(); + }); +}); diff --git a/tests/server/watchdog.test.js b/tests/server/watchdog.test.js index 06b6a3c5..8c5c8f75 100644 --- a/tests/server/watchdog.test.js +++ b/tests/server/watchdog.test.js @@ -15,6 +15,7 @@ const createHarness = ({ clawCmdImpl, resolveSetupUrl = () => "https://setup.example.com", resolveGatewayHealthUrl = () => "http://127.0.0.1:18789/health", + resolveGatewayReadyzUrl = () => "", fetchImpl = async () => ({ ok: true, status: 200, @@ -49,6 +50,7 @@ const createHarness = ({ reloadEnv, resolveSetupUrl, resolveGatewayHealthUrl, + resolveGatewayReadyzUrl, }); return { @@ -714,4 +716,172 @@ describe("server/watchdog", () => { notificationsEnabled: false, }); }); + + const buildSafeModeFetch = (gatewayState) => async (url) => { + if (String(url).includes("/readyz")) { + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + ready: true, + failing: [], + ...(gatewayState.suppressed.length > 0 + ? { suppressed: gatewayState.suppressed } + : {}), + }), + }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ ok: true, status: "live" }), + }; + }; + + it("detects gateway safe mode from readyz and notifies once", async () => { + vi.useFakeTimers(); + const gatewayState = { suppressed: ["telegram", "discord"] }; + const { watchdog, insertWatchdogEvent, notifier } = createHarness({ + autoRepair: false, + resolveGatewayReadyzUrl: () => "http://127.0.0.1:18789/readyz", + fetchImpl: buildSafeModeFetch(gatewayState), + }); + + watchdog.start(); + await vi.advanceTimersByTimeAsync(10); + + expect(watchdog.getStatus()).toEqual( + expect.objectContaining({ + lifecycle: "running", + health: "healthy", + safeMode: true, + suppressedChannels: ["telegram", "discord"], + }), + ); + expect(insertWatchdogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "safe_mode", + status: "failed", + details: expect.objectContaining({ + suppressed: ["telegram", "discord"], + }), + }), + ); + const safeModeNotices = () => + notifier.notify.mock.calls.filter((call) => + String(call?.[0] || "").includes("safe mode"), + ); + expect(safeModeNotices()).toHaveLength(1); + + // Subsequent checks with unchanged suppression must not re-notify. + await vi.advanceTimersByTimeAsync(120_000); + expect(safeModeNotices()).toHaveLength(1); + watchdog.stop(); + }); + + it("clears safe mode and notifies recovery when suppression ends", async () => { + vi.useFakeTimers(); + const gatewayState = { suppressed: ["telegram"] }; + const { watchdog, insertWatchdogEvent, notifier } = createHarness({ + autoRepair: false, + resolveGatewayReadyzUrl: () => "http://127.0.0.1:18789/readyz", + fetchImpl: buildSafeModeFetch(gatewayState), + }); + + watchdog.start(); + await vi.advanceTimersByTimeAsync(10); + expect(watchdog.getStatus().safeMode).toBe(true); + + gatewayState.suppressed = []; + await vi.advanceTimersByTimeAsync(120_000); + + expect(watchdog.getStatus()).toEqual( + expect.objectContaining({ safeMode: false, suppressedChannels: [] }), + ); + expect(insertWatchdogEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "safe_mode", + status: "ok", + details: expect.objectContaining({ recovered: true }), + }), + ); + expect( + notifier.notify.mock.calls.some((call) => + String(call?.[0] || "").includes("safe mode cleared"), + ), + ).toBe(true); + watchdog.stop(); + }); + + it("resumeChannels issues channels.start for each suppressed channel", async () => { + vi.useFakeTimers(); + const gatewayState = { suppressed: ["telegram", "discord"] }; + const startCalls = []; + const { watchdog } = createHarness({ + autoRepair: false, + resolveGatewayReadyzUrl: () => "http://127.0.0.1:18789/readyz", + fetchImpl: buildSafeModeFetch(gatewayState), + clawCmdImpl: async (command) => { + if (command.startsWith("gateway call channels.start")) { + startCalls.push(command); + return { ok: true, stdout: "{}" }; + } + return { ok: true, stdout: "" }; + }, + }); + + watchdog.start(); + await vi.advanceTimersByTimeAsync(10); + expect(watchdog.getStatus().safeMode).toBe(true); + + gatewayState.suppressed = []; + const resultPromise = watchdog.resumeChannels(); + await vi.advanceTimersByTimeAsync(10); + const result = await resultPromise; + + expect(result.ok).toBe(true); + expect(startCalls).toEqual([ + `gateway call channels.start --params '{"channel":"telegram"}'`, + `gateway call channels.start --params '{"channel":"discord"}'`, + ]); + expect(watchdog.getStatus()).toEqual( + expect.objectContaining({ safeMode: false, suppressedChannels: [] }), + ); + watchdog.stop(); + }); + + it("resumeChannels skips when no channels are suppressed", async () => { + const { watchdog, clawCmd } = createHarness({ autoRepair: false }); + + const result = await watchdog.resumeChannels(); + + expect(result).toEqual({ + ok: false, + skipped: true, + reason: "no_suppressed_channels", + }); + expect(clawCmd).not.toHaveBeenCalled(); + }); + + it("clears safe-mode status when the gateway exits", async () => { + vi.useFakeTimers(); + const gatewayState = { suppressed: ["telegram"] }; + const { watchdog } = createHarness({ + autoRepair: false, + resolveGatewayReadyzUrl: () => "http://127.0.0.1:18789/readyz", + fetchImpl: buildSafeModeFetch(gatewayState), + }); + + watchdog.start(); + await vi.advanceTimersByTimeAsync(10); + expect(watchdog.getStatus().safeMode).toBe(true); + + watchdog.onGatewayExit({ code: 1, expectedExit: false }); + + expect(watchdog.getStatus()).toEqual( + expect.objectContaining({ safeMode: false, suppressedChannels: [] }), + ); + watchdog.stop(); + }); }); diff --git a/vitest.config.js b/vitest.config.js index bd171f4b..7a320d1e 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -7,5 +7,8 @@ export default defineConfig({ include: ["tests/**/*.test.js"], restoreMocks: true, clearMocks: true, + // Tests that touch the openclaw plugin-sdk pay a >5s dynamic-import cost + // on first load per worker, which flakes under parallel machine load. + testTimeout: 30000, }, });