fix: tighten systemd duplicate gateway detection (#45328) (thanks @gregretkowski)

* daemon: tighten systemd duplicate gateway detection (#15849)

* fix three issues from PR review

* fix windows unit tests due to posix/windows path differences
* ensure line continuations are handled in systemd units
* fix misleading test name

* attempt fix windows test due to fs path separator

* fix system_dir separator, fix platform side-effect

* change approach for mocking systemd filesystem test

* normalize systemd paths to linux style

* revert to vers that didnt impact win32 tests

* back out all systemd inspect tests

* change test approach to avoid other tests issues

* fix: tighten systemd duplicate gateway detection (#45328) (thanks @gregretkowski)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Greg Retkowski 2026-03-25 20:50:10 -07:00 committed by GitHub
parent ebad7490b4
commit 14430ade57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 147 additions and 2 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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;
}