From 03a7b19228edeba3295fa51ea075b2653c964d2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 13:05:32 +0100 Subject: [PATCH] fix: recover gateway dashboard startup in stripped shells --- CHANGELOG.md | 1 + src/cli/daemon-cli/install.test.ts | 71 +++++++++++++++++++++++++++--- src/cli/daemon-cli/install.ts | 34 +++++++++++++- src/daemon/systemd.test.ts | 30 +++++++++++++ src/daemon/systemd.ts | 39 ++++++++++++++-- 5 files changed, 164 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 268d5039f58..ffc9a69d319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - CLI/status: show plain empty-state messages instead of empty Channels and Sessions tables when no channels or sessions exist. - CLI/dashboard: probe Gateway readiness before handing out the dashboard URL, prompting to start or install the managed service when the Gateway is stopped and printing recovery commands instead of opening a dead browser tab. - CLI: hide decorative startup and status emoji on terminals that are unlikely to render them correctly, keeping semantic message and identity emoji intact. +- CLI/gateway: recover the Linux user systemd bus environment when `openclaw dashboard` starts the Gateway from stripped desktop shells such as VNC terminals. - CLI/context engines: bootstrap and finalize non-legacy context engines for CLI turns while preserving transcript snapshots and deferred maintenance ownership. (#81869) Thanks @sahilsatralkar. - Telegram: persist polling updates through restart replay so queued same-topic messages resume in order instead of losing context after a gateway restart. (#82256) Thanks @VACInc. - Gateway/Gmail: abort in-flight Gmail watcher startup and hot-reload restarts before shutdown so reloads cannot spawn `gog serve` after the Gateway is closing. Thanks @frankekn. diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index e6288b0547f..bb9089730e2 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -90,6 +90,10 @@ vi.mock("../../config/io.js", () => ({ })), })); +vi.mock("../../config/mutate.js", () => ({ + replaceConfigFile: replaceConfigFileMock, +})); + vi.mock("../../config/paths.js", () => ({ resolveGatewayPort: resolveGatewayPortMock, resolveIsNixMode: resolveIsNixModeMock, @@ -197,13 +201,13 @@ function readFirstInstallPlanArg(): Record { } function readFirstConfigWriteParams(): { - nextConfig?: { gateway?: { auth?: { token?: string } } }; + nextConfig?: { gateway?: { mode?: string; auth?: { token?: string } } }; } { const [params] = replaceConfigFileMock.mock.calls[0] ?? []; if (!params || typeof params !== "object") { throw new Error("expected first config write params"); } - return params as { nextConfig?: { gateway?: { auth?: { token?: string } } } }; + return params as { nextConfig?: { gateway?: { mode?: string; auth?: { token?: string } } } }; } function readFirstNodeStartupTlsEnvironmentArg(): Record { @@ -255,12 +259,12 @@ describe("runDaemonInstall", () => { actionState.emitted.length = 0; actionState.failed.length = 0; - loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token" } } }); + loadConfigMock.mockReturnValue({ gateway: { mode: "local", auth: { mode: "token" } } }); readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {}, - sourceConfig: { gateway: { auth: { mode: "token" } } }, + sourceConfig: { gateway: { mode: "local", auth: { mode: "token" } } }, }); resolveGatewayPortMock.mockReturnValue(18789); resolveIsNixModeMock.mockReturnValue(false); @@ -381,7 +385,7 @@ describe("runDaemonInstall", () => { exists: true, valid: true, config: { gateway: { auth: { mode: "token" } } }, - sourceConfig: { gateway: { auth: { mode: "token" } } }, + sourceConfig: { gateway: { mode: "local", auth: { mode: "token" } } }, }); await runDaemonInstall({ json: true }); @@ -396,6 +400,63 @@ describe("runDaemonInstall", () => { expect(actionState.warnings.join("\n")).toContain("Auto-generated"); }); + it("persists local gateway mode when installing from config missing gateway.mode", async () => { + readConfigFileSnapshotMock + .mockResolvedValueOnce({ + exists: true, + valid: true, + config: { gateway: { auth: { mode: "token", token: "durable-token" } } }, + sourceConfig: { gateway: { auth: { mode: "token", token: "durable-token" } } }, + }) + .mockResolvedValue({ + exists: true, + valid: true, + config: { + gateway: { mode: "local", auth: { mode: "token", token: "durable-token" } }, + }, + sourceConfig: { + gateway: { mode: "local", auth: { mode: "token", token: "durable-token" } }, + }, + }); + resolveGatewayAuthMock.mockReturnValue({ + mode: "token", + token: "durable-token", + password: undefined, + allowTailscale: false, + }); + + await runDaemonInstall({ json: true }); + + expect(actionState.failed).toStrictEqual([]); + expect(replaceConfigFileMock).toHaveBeenCalledTimes(1); + expect(readFirstConfigWriteParams().nextConfig?.gateway?.mode).toBe("local"); + expect(actionState.warnings).toContain( + "No gateway.mode found. Set gateway.mode=local for managed gateway install.", + ); + expectFields(readFirstInstallPlanArg().config as Record, { + gateway: { + mode: "local", + auth: { mode: "token", token: "durable-token" }, + }, + }); + }); + + it("does not persist gateway mode when runtime validation fails", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + exists: true, + valid: true, + config: { gateway: { auth: { mode: "token", token: "durable-token" } } }, + sourceConfig: { gateway: { auth: { mode: "token", token: "durable-token" } } }, + }); + isGatewayDaemonRuntimeMock.mockReturnValue(false); + + await runDaemonInstall({ json: true, runtime: "bogus" }); + + expect(actionState.failed[0]?.message).toContain("Invalid --runtime"); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); + expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); + }); + it("continues Linux install when service probe hits a non-fatal systemd bus failure", async () => { service.isLoaded.mockRejectedValueOnce( new Error("systemctl is-enabled unavailable: Failed to connect to bus"), diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 7f7a8ce60d5..f571a9ff021 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -8,6 +8,7 @@ import { import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js"; import { readConfigFileSnapshotForWrite } from "../../config/io.js"; +import { replaceConfigFile } from "../../config/mutate.js"; import { resolveGatewayPort } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.js"; import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js"; @@ -82,7 +83,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { return; } - const { snapshot: configSnapshot, writeOptions: configWriteOptions } = + let { snapshot: configSnapshot, writeOptions: configWriteOptions } = await readConfigFileSnapshotForWrite(); const futureBlock = resolveFutureConfigActionBlock({ action: "install or rewrite the gateway service", @@ -92,7 +93,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { fail(`Gateway install blocked: ${futureBlock.message}`, futureBlock.hints); return; } - const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config; + let cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config; const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { fail(formatInvalidPortOption("--port")); @@ -121,6 +122,35 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { return; } } + if (configSnapshot.valid && cfg.gateway?.mode === undefined) { + const baseConfig = configSnapshot.sourceConfig ?? configSnapshot.config; + await replaceConfigFile({ + nextConfig: { + ...baseConfig, + gateway: { + ...baseConfig.gateway, + mode: "local", + }, + }, + snapshot: configSnapshot, + writeOptions: { + baseSnapshot: configSnapshot, + ...configWriteOptions, + skipRuntimeSnapshotRefresh: true, + }, + afterWrite: { mode: "auto" }, + }); + const refreshed = await readConfigFileSnapshotForWrite(); + configSnapshot = refreshed.snapshot; + configWriteOptions = refreshed.writeOptions; + cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config; + const message = "No gateway.mode found. Set gateway.mode=local for managed gateway install."; + if (json) { + warnings.push(message); + } else { + defaultRuntime.log(message); + } + } const service = resolveGatewayService(); let loaded = false; diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index aa208f28a83..6dd616e8153 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -4,6 +4,12 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const execFileMock = vi.hoisted(() => vi.fn()); +const existsSyncMock = vi.hoisted(() => vi.fn(() => false)); + +vi.mock("node:fs", async (importOriginal) => ({ + ...(await importOriginal()), + existsSync: existsSyncMock, +})); vi.mock("node:child_process", async () => { const { mockNodeChildProcessExecFile } = await import("openclaw/plugin-sdk/test-node-mocks"); @@ -138,6 +144,11 @@ const assertRestartSuccess = async (env: NodeJS.ProcessEnv) => { expect(requireFirstWrite(write)).toContain("Restarted systemd service"); }; +beforeEach(() => { + existsSyncMock.mockReset(); + existsSyncMock.mockReturnValue(false); +}); + describe("systemd availability", () => { beforeEach(() => { execFileMock.mockReset(); @@ -150,6 +161,25 @@ describe("systemd availability", () => { await expect(isSystemdUserServiceAvailable()).resolves.toBe(true); }); + it("repairs missing user bus environment when the runtime bus exists", async () => { + mockEffectiveUid(1000); + existsSyncMock.mockReturnValue(true); + execFileMock.mockImplementation((_cmd, args, opts, cb) => { + assertUserSystemctlArgs(args, "status"); + expect(opts.env.XDG_RUNTIME_DIR).toBe("/run/user/1000"); + expect(opts.env.DBUS_SESSION_BUS_ADDRESS).toBe("unix:path=/run/user/1000/bus"); + cb(null, "", ""); + }); + + await expect( + isSystemdUserServiceAvailable({ + USER: "debian", + XDG_RUNTIME_DIR: undefined, + DBUS_SESSION_BUS_ADDRESS: undefined, + }), + ).resolves.toBe(true); + }); + it("returns false when systemd user bus is unavailable", async () => { execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { const err = new Error("Failed to connect to bus") as Error & { diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 2c49a078da2..90a2d23b97d 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -1,3 +1,4 @@ +import * as fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -324,8 +325,11 @@ export type SystemdUnitScope = "system" | "user"; async function execSystemctl( args: string[], + env?: GatewayServiceEnv, ): Promise<{ stdout: string; stderr: string; code: number }> { - return await execFileUtf8("systemctl", args); + return await execFileUtf8("systemctl", args, { + env: env ? resolveSystemctlProcessEnv(env) : process.env, + }); } function readSystemctlDetail(result: { stdout: string; stderr: string }): string { @@ -424,6 +428,30 @@ function readSystemctlEffectiveUid(): number | null { } } +function resolveSystemctlProcessEnv(env: GatewayServiceEnv): NodeJS.ProcessEnv { + const processEnv = { ...process.env, ...env }; + if (processEnv.XDG_RUNTIME_DIR?.trim() && processEnv.DBUS_SESSION_BUS_ADDRESS?.trim()) { + return processEnv; + } + + const uid = readSystemctlEffectiveUid(); + if (uid === null || uid === 0) { + return processEnv; + } + + const runtimeDir = processEnv.XDG_RUNTIME_DIR?.trim() || `/run/user/${uid}`; + const busPath = path.posix.join(runtimeDir, "bus"); + if (!fsSync.existsSync(busPath)) { + return processEnv; + } + + return { + ...processEnv, + XDG_RUNTIME_DIR: runtimeDir, + DBUS_SESSION_BUS_ADDRESS: processEnv.DBUS_SESSION_BUS_ADDRESS?.trim() || `unix:path=${busPath}`, + }; +} + function isNonRootUser(user: string | null): user is string { return Boolean(user && user !== "root"); } @@ -480,11 +508,14 @@ async function execSystemctlUser( const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser); if (machineScopeArgs.length > 0) { // Do not fall through to bare --user: under sudo that can target root's user manager. - return await execSystemctl([...machineScopeArgs, ...args]); + return await execSystemctl([...machineScopeArgs, ...args], env); } } - const directResult = await execSystemctl([...resolveSystemctlDirectUserScopeArgs(), ...args]); + const directResult = await execSystemctl( + [...resolveSystemctlDirectUserScopeArgs(), ...args], + env, + ); if (directResult.code === 0) { return directResult; } @@ -498,7 +529,7 @@ async function execSystemctlUser( if (machineScopeArgs.length === 0) { return directResult; } - return await execSystemctl([...machineScopeArgs, ...args]); + return await execSystemctl([...machineScopeArgs, ...args], env); } export async function isSystemdUserServiceAvailable(