diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c268b6b8..f49e4575d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot ### Fixes - Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos. +- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output. ## 2026.1.16-2 diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index f249661d6..0ad05f9f7 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,5 +1,8 @@ import { resolveIsNixMode } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js"; +import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js"; +import { isWSL } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js"; import { renderGatewayServiceStartHints } from "./shared.js"; @@ -89,7 +92,13 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) { return; } if (!loaded) { - const hints = renderGatewayServiceStartHints(); + let hints = renderGatewayServiceStartHints(); + if (process.platform === "linux") { + const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false); + if (!systemdAvailable) { + hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })]; + } + } emit({ ok: true, result: "not-loaded", @@ -229,7 +238,13 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi return false; } if (!loaded) { - const hints = renderGatewayServiceStartHints(); + let hints = renderGatewayServiceStartHints(); + if (process.platform === "linux") { + const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false); + if (!systemdAvailable) { + hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })]; + } + } emit({ ok: true, result: "not-loaded", diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 73567f583..d2fbd9187 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -114,7 +114,9 @@ export async function gatherDaemonStatus( const [loaded, command, runtime] = await Promise.all([ service.isLoaded({ env: process.env }).catch(() => false), service.readCommand(process.env).catch(() => null), - service.readRuntime(process.env).catch(() => undefined), + service + .readRuntime(process.env) + .catch((err) => ({ status: "unknown", detail: String(err) })), ]); const configAudit = await auditGatewayServiceConfig({ env: process.env, diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 832d02e58..43391da0a 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -5,6 +5,11 @@ import { } from "../../daemon/constants.js"; import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js"; import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; +import { + isSystemdUnavailableDetail, + renderSystemdUnavailableHints, +} from "../../daemon/systemd-hints.js"; +import { isWSLEnv } from "../../infra/wsl.js"; import { getResolvedLoggerSettings } from "../../logging.js"; import { defaultRuntime } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; @@ -164,6 +169,16 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) spacer(); } + const systemdUnavailable = + process.platform === "linux" && isSystemdUnavailableDetail(service.runtime?.detail); + if (systemdUnavailable) { + defaultRuntime.error(errorText("systemd user services unavailable.")); + for (const hint of renderSystemdUnavailableHints({ wsl: isWSLEnv() })) { + defaultRuntime.error(errorText(hint)); + } + spacer(); + } + if (service.runtime?.missingUnit) { defaultRuntime.error(errorText("Service unit not found.")); for (const hint of renderRuntimeHints(service.runtime)) { diff --git a/src/commands/doctor-format.ts b/src/commands/doctor-format.ts index d54e7a36f..2b120b146 100644 --- a/src/commands/doctor-format.ts +++ b/src/commands/doctor-format.ts @@ -4,6 +4,11 @@ import { resolveGatewayWindowsTaskName, } from "../daemon/constants.js"; import { resolveGatewayLogPaths } from "../daemon/launchd.js"; +import { + isSystemdUnavailableDetail, + renderSystemdUnavailableHints, +} from "../daemon/systemd-hints.js"; +import { isWSLEnv } from "../infra/wsl.js"; import type { GatewayServiceRuntime } from "../daemon/service-runtime.js"; import { getResolvedLoggerSettings } from "../logging.js"; @@ -54,6 +59,11 @@ export function buildGatewayRuntimeHints( return null; } })(); + if (platform === "linux" && isSystemdUnavailableDetail(runtime.detail)) { + hints.push(...renderSystemdUnavailableHints({ wsl: isWSLEnv() })); + if (fileLog) hints.push(`File logs: ${fileLog}`); + return hints; + } if (runtime.cachedLabel && platform === "darwin") { const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE); hints.push( diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index b898a7cca..1f4cf4dc7 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -12,7 +12,10 @@ import { } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; +import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; +import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; +import { isWSL } from "../infra/wsl.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { sleep } from "../utils.js"; @@ -55,6 +58,14 @@ export async function maybeRepairGatewayDaemon(params: { } if (!loaded) { + if (process.platform === "linux") { + const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false); + if (!systemdAvailable) { + const wsl = await isWSL(); + note(renderSystemdUnavailableHints({ wsl }).join("\n"), "Gateway"); + return; + } + } note("Gateway daemon not installed.", "Gateway"); if (params.cfg.gateway?.mode !== "remote") { const install = await params.prompter.confirmSkipInNonInteractive({ diff --git a/src/commands/oauth-env.ts b/src/commands/oauth-env.ts index 1f2967f8b..6cddf7a48 100644 --- a/src/commands/oauth-env.ts +++ b/src/commands/oauth-env.ts @@ -1,14 +1,4 @@ -import { readFileSync } from "node:fs"; - -function isWSL(): boolean { - if (process.platform !== "linux") return false; - try { - const release = readFileSync("/proc/version", "utf8").toLowerCase(); - return release.includes("microsoft") || release.includes("wsl"); - } catch { - return false; - } -} +import { isWSLEnv } from "../infra/wsl.js"; export function isRemoteEnvironment(): boolean { if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) { @@ -23,7 +13,7 @@ export function isRemoteEnvironment(): boolean { process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && - !isWSL() + !isWSLEnv() ) { return true; } diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 90eb39c93..b609ee3b7 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -13,6 +13,7 @@ import { callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; +import { isWSL } from "../infra/wsl.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; @@ -91,27 +92,6 @@ type BrowserOpenSupport = { command?: string; }; -let wslCached: boolean | null = null; - -async function isWSL(): Promise { - if (wslCached !== null) return wslCached; - if (process.platform !== "linux") { - wslCached = false; - return wslCached; - } - if (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME || process.env.WSLENV) { - wslCached = true; - return wslCached; - } - try { - const release = (await fs.readFile("/proc/version", "utf8")).toLowerCase(); - wslCached = release.includes("microsoft") || release.includes("wsl"); - } catch { - wslCached = false; - } - return wslCached; -} - type BrowserOpenCommand = { argv: string[] | null; reason?: string; diff --git a/src/daemon/systemd-hints.ts b/src/daemon/systemd-hints.ts new file mode 100644 index 000000000..a393657ca --- /dev/null +++ b/src/daemon/systemd-hints.ts @@ -0,0 +1,25 @@ +export function isSystemdUnavailableDetail(detail?: string): boolean { + if (!detail) return false; + const normalized = detail.toLowerCase(); + return ( + normalized.includes("systemctl --user unavailable") || + normalized.includes("systemctl not available") || + normalized.includes("not been booted with systemd") || + normalized.includes("failed to connect to bus") || + normalized.includes("systemd user services are required") + ); +} + +export function renderSystemdUnavailableHints(options: { wsl?: boolean } = {}): string[] { + if (options.wsl) { + return [ + "WSL2 needs systemd enabled: edit /etc/wsl.conf with [boot]\\nsystemd=true", + "Then run: wsl --shutdown (from PowerShell) and reopen your distro.", + "Verify: systemctl --user status", + ]; + } + return [ + "systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.", + "If you're in a container, run the gateway in the foreground instead of `clawdbot daemon`.", + ]; +} diff --git a/src/infra/wsl.ts b/src/infra/wsl.ts new file mode 100644 index 000000000..20861f9e0 --- /dev/null +++ b/src/infra/wsl.ts @@ -0,0 +1,25 @@ +import fs from "node:fs/promises"; + +let wslCached: boolean | null = null; + +export function isWSLEnv(): boolean { + if (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME || process.env.WSLENV) { + return true; + } + return false; +} + +export async function isWSL(): Promise { + if (wslCached !== null) return wslCached; + if (isWSLEnv()) { + wslCached = true; + return wslCached; + } + try { + const release = await fs.readFile("/proc/sys/kernel/osrelease", "utf8"); + wslCached = release.toLowerCase().includes("microsoft") || release.toLowerCase().includes("wsl"); + } catch { + wslCached = false; + } + return wslCached; +}