diff --git a/CHANGELOG.md b/CHANGELOG.md index cbfe422e845..a23f3b08a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Agents/sandbox: make blocked-tool guidance glob-aware again, redact/sanitize session-specific explain hints for safer copy-paste, and avoid leaking control-character session keys in those hints. (#54684) Thanks @ngutman. - Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin. - Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin. +- Daemon/Linux: stop flagging non-gateway systemd services as duplicate gateways just because their unit files mention OpenClaw, reducing false-positive doctor/log noise. (#45328) Thanks @gregretkowski. - Plugins/SDK: thread `moduleUrl` through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory (e.g. `~/.openclaw/extensions/`) correctly resolve `openclaw/plugin-sdk/*` subpath imports, and gate `plugin-sdk:check-exports` in `release:check`. (#54283) Thanks @xieyongliang. - Telegram/pairing: ignore self-authored DM `message` updates so bot-pinned status cards and similar service updates do not trigger bogus pairing requests or re-enter inbound dispatch. (#54530) thanks @huntharo - iMessage: stop leaking inline `[[reply_to:...]]` tags into delivered text by sending `reply_to` as RPC metadata and stripping stray directive tags from outbound messages. (#39512) Thanks @mvanhorn. diff --git a/src/daemon/inspect.test.ts b/src/daemon/inspect.test.ts index 0e1f8793899..e65acdad2fb 100644 --- a/src/daemon/inspect.test.ts +++ b/src/daemon/inspect.test.ts @@ -1,5 +1,8 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { findExtraGatewayServices } from "./inspect.js"; +import { detectMarkerLineWithGateway, findExtraGatewayServices } from "./inspect.js"; const { execSchtasksMock } = vi.hoisted(() => ({ execSchtasksMock: vi.fn(), @@ -9,6 +12,131 @@ vi.mock("./schtasks-exec.js", () => ({ execSchtasks: (...args: unknown[]) => execSchtasksMock(...args), })); +// Real content from the openclaw-gateway.service unit file (the canonical gateway unit). +const GATEWAY_SERVICE_CONTENTS = `\ +[Unit] +Description=OpenClaw Gateway (v2026.3.8) +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/bin/node /home/openclaw/.npm-global/lib/node_modules/openclaw/dist/entry.js gateway --port 18789 +Restart=always +Environment=OPENCLAW_SERVICE_MARKER=openclaw +Environment=OPENCLAW_SERVICE_KIND=gateway +Environment=OPENCLAW_SERVICE_VERSION=2026.3.8 + +[Install] +WantedBy=default.target +`; + +// Real content from the openclaw-test.service unit file (a non-gateway openclaw service). +const TEST_SERVICE_CONTENTS = `\ +[Unit] +Description=OpenClaw test service +After=default.target + +[Service] +Type=simple +ExecStart=/bin/sh -c 'while true; do sleep 60; done' +Restart=on-failure + +[Install] +WantedBy=default.target +`; + +const CLAWDBOT_GATEWAY_CONTENTS = `\ +[Unit] +Description=Clawdbot Gateway +[Service] +ExecStart=/usr/bin/node /opt/clawdbot/dist/entry.js gateway --port 18789 +Environment=HOME=/home/clawdbot +`; + +describe("detectMarkerLineWithGateway", () => { + it("returns null for openclaw-test.service (openclaw only in description, no gateway on same line)", () => { + expect(detectMarkerLineWithGateway(TEST_SERVICE_CONTENTS)).toBeNull(); + }); + + it("returns openclaw for the canonical gateway unit (ExecStart has both openclaw and gateway)", () => { + expect(detectMarkerLineWithGateway(GATEWAY_SERVICE_CONTENTS)).toBe("openclaw"); + }); + + it("returns clawdbot for a clawdbot gateway unit", () => { + expect(detectMarkerLineWithGateway(CLAWDBOT_GATEWAY_CONTENTS)).toBe("clawdbot"); + }); + + it("handles line continuations — marker and gateway split across physical lines", () => { + const contents = `[Service]\nExecStart=/usr/bin/node /opt/openclaw/dist/entry.js \\\n gateway --port 18789\n`; + expect(detectMarkerLineWithGateway(contents)).toBe("openclaw"); + }); +}); + +describe("findExtraGatewayServices (linux / scanSystemdDir) — real filesystem", () => { + // These tests write real .service files to a temp dir and call findExtraGatewayServices + // with that dir as HOME. No platform mocking or fs mocking needed. + // Only runs on Linux/macOS where the linux branch of findExtraGatewayServices is active. + const isLinux = process.platform === "linux"; + + it.skipIf(!isLinux)("does not report openclaw-test.service as a gateway service", async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); + try { + await fs.mkdir(systemdDir, { recursive: true }); + await fs.writeFile(path.join(systemdDir, "openclaw-test.service"), TEST_SERVICE_CONTENTS); + const result = await findExtraGatewayServices({ HOME: tmpHome }); + expect(result).toEqual([]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }); + + it.skipIf(!isLinux)( + "does not report the canonical openclaw-gateway.service as an extra service", + async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); + try { + await fs.mkdir(systemdDir, { recursive: true }); + await fs.writeFile( + path.join(systemdDir, "openclaw-gateway.service"), + GATEWAY_SERVICE_CONTENTS, + ); + const result = await findExtraGatewayServices({ HOME: tmpHome }); + expect(result).toEqual([]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(!isLinux)( + "reports a legacy clawdbot-gateway service as an extra gateway service", + async () => { + const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); + const systemdDir = path.join(tmpHome, ".config", "systemd", "user"); + const unitPath = path.join(systemdDir, "clawdbot-gateway.service"); + try { + await fs.mkdir(systemdDir, { recursive: true }); + await fs.writeFile(unitPath, CLAWDBOT_GATEWAY_CONTENTS); + const result = await findExtraGatewayServices({ HOME: tmpHome }); + expect(result).toEqual([ + { + platform: "linux", + label: "clawdbot-gateway.service", + detail: `unit: ${unitPath}`, + scope: "user", + marker: "clawdbot", + legacy: true, + }, + ]); + } finally { + await fs.rm(tmpHome, { recursive: true, force: true }); + } + }, + ); +}); + describe("findExtraGatewayServices (win32)", () => { const originalPlatform = process.platform; diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index c3025ae8b8a..d26f78d0f8c 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -62,6 +62,22 @@ function detectMarker(content: string): Marker | null { return null; } +export function detectMarkerLineWithGateway(contents: string): Marker | null { + // Join line continuations (trailing backslash) into single lines + const lower = contents.replace(/\\\r?\n\s*/g, " ").toLowerCase(); + for (const line of lower.split(/\r?\n/)) { + if (!line.includes("gateway")) { + continue; + } + for (const marker of EXTRA_MARKERS) { + if (line.includes(marker)) { + return marker; + } + } + } + return null; +} + function hasGatewayServiceMarker(content: string): boolean { const lower = content.toLowerCase(); const markerKeys = ["openclaw_service_marker"]; @@ -237,7 +253,7 @@ async function scanSystemdDir(params: { }); for (const { entry, name, fullPath, contents } of candidates) { - const marker = detectMarker(contents); + const marker = detectMarkerLineWithGateway(contents); if (!marker) { continue; }