mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 21:16:07 +00:00
Normalize Windows schtasks default gateway names without hiding similarly prefixed duplicate tasks. Verified with focused daemon tests, type proof, autoreview, and clean CI.
557 lines
15 KiB
TypeScript
557 lines
15 KiB
TypeScript
/** Inspects installed platform services for extra OpenClaw or legacy gateway jobs. */
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
|
import {
|
|
GATEWAY_SERVICE_KIND,
|
|
GATEWAY_SERVICE_MARKER,
|
|
resolveGatewayLaunchAgentLabel,
|
|
resolveGatewaySystemdServiceName,
|
|
resolveGatewayWindowsTaskName,
|
|
} from "./constants.js";
|
|
import { resolveHomeDir } from "./paths.js";
|
|
import { execSchtasks } from "./schtasks-exec.js";
|
|
import { parseSystemdExecStart } from "./systemd-unit.js";
|
|
|
|
export type ExtraGatewayService = {
|
|
platform: "darwin" | "linux" | "win32";
|
|
label: string;
|
|
detail: string;
|
|
scope: "user" | "system";
|
|
marker?: "openclaw" | "clawdbot";
|
|
legacy?: boolean;
|
|
};
|
|
|
|
export type FindExtraGatewayServicesOptions = {
|
|
deep?: boolean;
|
|
};
|
|
|
|
const EXTRA_MARKERS = ["openclaw", "clawdbot"] as const;
|
|
const SYSTEMD_REFERENCE_ONLY_KEYS = new Set([
|
|
"after",
|
|
"before",
|
|
"bindsto",
|
|
"conflicts",
|
|
"partof",
|
|
"propagatesreloadto",
|
|
"reloadpropagatedfrom",
|
|
"requisite",
|
|
"requires",
|
|
"upholds",
|
|
"wants",
|
|
]);
|
|
|
|
export function renderGatewayServiceCleanupHints(
|
|
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
): string[] {
|
|
const profile = env.OPENCLAW_PROFILE;
|
|
switch (process.platform) {
|
|
case "darwin": {
|
|
const label = resolveGatewayLaunchAgentLabel(profile);
|
|
return [`launchctl bootout gui/$UID/${label}`, `rm ~/Library/LaunchAgents/${label}.plist`];
|
|
}
|
|
case "linux": {
|
|
const unit = resolveGatewaySystemdServiceName(profile);
|
|
return [
|
|
`systemctl --user disable --now ${unit}.service`,
|
|
`rm ~/.config/systemd/user/${unit}.service`,
|
|
];
|
|
}
|
|
case "win32": {
|
|
const task = resolveGatewayWindowsTaskName(profile);
|
|
return [`schtasks /Delete /TN "${task}" /F`];
|
|
}
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
type Marker = (typeof EXTRA_MARKERS)[number];
|
|
|
|
function hasGatewaySubcommandArg(args: string[]): boolean {
|
|
return args.some((arg) => {
|
|
const normalized = normalizeLowercaseStringOrEmpty(arg);
|
|
return normalized === "gateway" || /(^|\s)gateway(\s|$)/.test(normalized);
|
|
});
|
|
}
|
|
|
|
export function detectMarkerLineWithGateway(contents: string): Marker | null {
|
|
// Join line continuations before scanning systemd ExecStart commands; marker
|
|
// detection must ignore relationship-only unit keys.
|
|
const lower = normalizeLowercaseStringOrEmpty(contents.replace(/\\\r?\n\s*/g, " "));
|
|
for (const line of lower.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) {
|
|
continue;
|
|
}
|
|
const assignment = trimmed.indexOf("=");
|
|
if (assignment > 0) {
|
|
const key = trimmed.slice(0, assignment).trim();
|
|
if (SYSTEMD_REFERENCE_ONLY_KEYS.has(key)) {
|
|
continue;
|
|
}
|
|
if (
|
|
key === "execstart" &&
|
|
!hasGatewaySubcommandArg(parseSystemdExecStart(trimmed.slice(assignment + 1).trim()))
|
|
) {
|
|
continue;
|
|
}
|
|
if (key !== "execstart") {
|
|
continue;
|
|
}
|
|
}
|
|
if (!trimmed.includes("gateway")) {
|
|
continue;
|
|
}
|
|
for (const marker of EXTRA_MARKERS) {
|
|
if (trimmed.includes(marker)) {
|
|
return marker;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasGatewayServiceMarker(content: string): boolean {
|
|
const lower = normalizeLowercaseStringOrEmpty(content);
|
|
const markerKeys = ["openclaw_service_marker"];
|
|
const kindKeys = ["openclaw_service_kind"];
|
|
const markerValues = [normalizeLowercaseStringOrEmpty(GATEWAY_SERVICE_MARKER)];
|
|
const hasMarkerKey = markerKeys.some((key) => lower.includes(key));
|
|
const hasKindKey = kindKeys.some((key) => lower.includes(key));
|
|
const hasMarkerValue = markerValues.some((value) => lower.includes(value));
|
|
return (
|
|
hasMarkerKey &&
|
|
hasKindKey &&
|
|
hasMarkerValue &&
|
|
lower.includes(normalizeLowercaseStringOrEmpty(GATEWAY_SERVICE_KIND))
|
|
);
|
|
}
|
|
|
|
function extractPlistKeyBlock(
|
|
contents: string,
|
|
key: string,
|
|
tag: "array" | "string",
|
|
): string | null {
|
|
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const pattern = new RegExp(
|
|
`<key>${escapedKey}<\\/key>\\s*<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
|
|
"i",
|
|
);
|
|
const match = contents.match(pattern);
|
|
return match?.[1]?.trim() || null;
|
|
}
|
|
|
|
function extractPlistStringValues(
|
|
contents: string,
|
|
key: string,
|
|
tag: "array" | "string",
|
|
): string[] {
|
|
const block = extractPlistKeyBlock(contents, key, tag);
|
|
if (!block) {
|
|
return [];
|
|
}
|
|
if (tag === "string") {
|
|
return [block];
|
|
}
|
|
return Array.from(block.matchAll(/<string>([\s\S]*?)<\/string>/gi))
|
|
.map((match) => match[1]?.trim() ?? "")
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function detectLaunchdGatewayExecutionMarker(contents: string): Marker | null {
|
|
const program = extractPlistStringValues(contents, "Program", "string");
|
|
const programArguments = extractPlistStringValues(contents, "ProgramArguments", "array");
|
|
if (!hasGatewaySubcommandArg(programArguments)) {
|
|
return null;
|
|
}
|
|
// Only execution command fields identify gateway jobs; labels alone catch too
|
|
// many unrelated helper jobs.
|
|
const launchCommand = normalizeLowercaseStringOrEmpty(
|
|
[...program, ...programArguments].filter(Boolean).join("\n"),
|
|
);
|
|
for (const marker of EXTRA_MARKERS) {
|
|
if (launchCommand.includes(marker)) {
|
|
return marker;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isOpenClawGatewayLaunchdService(label: string, contents: string): boolean {
|
|
if (hasGatewayServiceMarker(contents)) {
|
|
return true;
|
|
}
|
|
if (detectLaunchdGatewayExecutionMarker(contents) !== "openclaw") {
|
|
return false;
|
|
}
|
|
return label.startsWith("ai.openclaw.");
|
|
}
|
|
|
|
function isOpenClawGatewaySystemdService(name: string, contents: string): boolean {
|
|
if (hasGatewayServiceMarker(contents)) {
|
|
return true;
|
|
}
|
|
if (!name.startsWith("openclaw-gateway")) {
|
|
return false;
|
|
}
|
|
return normalizeLowercaseStringOrEmpty(contents).includes("gateway");
|
|
}
|
|
|
|
function isOpenClawGatewayTaskName(name: string): boolean {
|
|
const normalized = normalizeLowercaseStringOrEmpty(name);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
// Windows schtasks /Query returns task names prefixed with \ (e.g.
|
|
// \OpenClaw Gateway for root-folder tasks). Strip the leading
|
|
// backslash so the configured name matches correctly and the live
|
|
// gateway task is not misidentified as an extra gateway service.
|
|
const stripped = normalized.replace(/^\\+/, "");
|
|
const defaultName = normalizeLowercaseStringOrEmpty(resolveGatewayWindowsTaskName());
|
|
return stripped === defaultName || /^openclaw gateway \(.+\)$/.test(stripped);
|
|
}
|
|
|
|
function tryExtractPlistLabel(contents: string): string | null {
|
|
const match = contents.match(/<key>Label<\/key>\s*<string>([\s\S]*?)<\/string>/i);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return match[1]?.trim() || null;
|
|
}
|
|
|
|
function isIgnoredLaunchdLabel(label: string): boolean {
|
|
return label === resolveGatewayLaunchAgentLabel();
|
|
}
|
|
|
|
function isIgnoredSystemdName(name: string): boolean {
|
|
return name === resolveGatewaySystemdServiceName();
|
|
}
|
|
|
|
function isLegacyLabel(label: string): boolean {
|
|
const lower = normalizeLowercaseStringOrEmpty(label);
|
|
return lower.includes("clawdbot");
|
|
}
|
|
|
|
async function readDirEntries(dir: string): Promise<string[]> {
|
|
try {
|
|
return await fs.readdir(dir);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function readUtf8File(filePath: string): Promise<string | null> {
|
|
try {
|
|
return await fs.readFile(filePath, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
type ServiceFileEntry = {
|
|
entry: string;
|
|
name: string;
|
|
fullPath: string;
|
|
contents: string;
|
|
};
|
|
|
|
async function collectServiceFiles(params: {
|
|
dir: string;
|
|
extension: string;
|
|
isIgnoredName: (name: string) => boolean;
|
|
}): Promise<ServiceFileEntry[]> {
|
|
const out: ServiceFileEntry[] = [];
|
|
const entries = await readDirEntries(params.dir);
|
|
for (const entry of entries) {
|
|
if (!entry.endsWith(params.extension)) {
|
|
continue;
|
|
}
|
|
const name = entry.slice(0, -params.extension.length);
|
|
if (params.isIgnoredName(name)) {
|
|
continue;
|
|
}
|
|
const fullPath = path.join(params.dir, entry);
|
|
const contents = await readUtf8File(fullPath);
|
|
if (contents === null) {
|
|
continue;
|
|
}
|
|
out.push({ entry, name, fullPath, contents });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function scanLaunchdDir(params: {
|
|
dir: string;
|
|
scope: "user" | "system";
|
|
}): Promise<ExtraGatewayService[]> {
|
|
const results: ExtraGatewayService[] = [];
|
|
const candidates = await collectServiceFiles({
|
|
dir: params.dir,
|
|
extension: ".plist",
|
|
isIgnoredName: isIgnoredLaunchdLabel,
|
|
});
|
|
|
|
for (const { name: labelFromName, fullPath, contents } of candidates) {
|
|
const label = tryExtractPlistLabel(contents) ?? labelFromName;
|
|
const legacyLabel = isLegacyLabel(labelFromName) || isLegacyLabel(label);
|
|
const executionMarker = detectLaunchdGatewayExecutionMarker(contents);
|
|
const marker =
|
|
hasGatewayServiceMarker(contents) || executionMarker === "openclaw"
|
|
? "openclaw"
|
|
: executionMarker === "clawdbot" || legacyLabel
|
|
? "clawdbot"
|
|
: null;
|
|
if (!marker) {
|
|
continue;
|
|
}
|
|
// Managed current services are expected; this scan reports extra jobs that
|
|
// can compete for ports or survive old installs.
|
|
if (isIgnoredLaunchdLabel(label)) {
|
|
continue;
|
|
}
|
|
if (marker === "openclaw" && isOpenClawGatewayLaunchdService(label, contents)) {
|
|
continue;
|
|
}
|
|
results.push({
|
|
platform: "darwin",
|
|
label,
|
|
detail: `plist: ${fullPath}`,
|
|
scope: params.scope,
|
|
marker,
|
|
legacy: marker !== "openclaw" || isLegacyLabel(label),
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function scanSystemdDir(params: {
|
|
dir: string;
|
|
scope: "user" | "system";
|
|
includeManagedOpenClaw?: boolean;
|
|
}): Promise<ExtraGatewayService[]> {
|
|
const results: ExtraGatewayService[] = [];
|
|
const candidates = await collectServiceFiles({
|
|
dir: params.dir,
|
|
extension: ".service",
|
|
isIgnoredName: params.includeManagedOpenClaw ? () => false : isIgnoredSystemdName,
|
|
});
|
|
|
|
for (const { entry, name, fullPath, contents } of candidates) {
|
|
const marker = hasGatewayServiceMarker(contents)
|
|
? "openclaw"
|
|
: detectMarkerLineWithGateway(contents);
|
|
if (!marker) {
|
|
continue;
|
|
}
|
|
if (
|
|
!params.includeManagedOpenClaw &&
|
|
marker === "openclaw" &&
|
|
isOpenClawGatewaySystemdService(name, contents)
|
|
) {
|
|
continue;
|
|
}
|
|
results.push({
|
|
platform: "linux",
|
|
label: entry,
|
|
detail: `unit: ${fullPath}`,
|
|
scope: params.scope,
|
|
marker,
|
|
legacy: marker !== "openclaw",
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
export async function findSystemGatewayServices(): Promise<ExtraGatewayService[]> {
|
|
if (process.platform !== "linux") {
|
|
return [];
|
|
}
|
|
|
|
const results: ExtraGatewayService[] = [];
|
|
try {
|
|
for (const dir of ["/etc/systemd/system", "/usr/lib/systemd/system", "/lib/systemd/system"]) {
|
|
results.push(
|
|
...(await scanSystemdDir({
|
|
dir,
|
|
scope: "system",
|
|
includeManagedOpenClaw: true,
|
|
})),
|
|
);
|
|
}
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
type ScheduledTaskInfo = {
|
|
name: string;
|
|
taskToRun?: string;
|
|
};
|
|
|
|
function parseSchtasksList(output: string): ScheduledTaskInfo[] {
|
|
const tasks: ScheduledTaskInfo[] = [];
|
|
let current: ScheduledTaskInfo | null = null;
|
|
|
|
for (const rawLine of output.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line) {
|
|
if (current) {
|
|
tasks.push(current);
|
|
current = null;
|
|
}
|
|
continue;
|
|
}
|
|
const idx = line.indexOf(":");
|
|
if (idx <= 0) {
|
|
continue;
|
|
}
|
|
const key = normalizeLowercaseStringOrEmpty(line.slice(0, idx));
|
|
const value = line.slice(idx + 1).trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
if (key === "taskname") {
|
|
if (current) {
|
|
tasks.push(current);
|
|
}
|
|
current = { name: value };
|
|
continue;
|
|
}
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
if (key === "task to run") {
|
|
current.taskToRun = value;
|
|
}
|
|
}
|
|
|
|
if (current) {
|
|
tasks.push(current);
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
export async function findExtraGatewayServices(
|
|
env: Record<string, string | undefined>,
|
|
opts: FindExtraGatewayServicesOptions = {},
|
|
): Promise<ExtraGatewayService[]> {
|
|
const results: ExtraGatewayService[] = [];
|
|
const seen = new Set<string>();
|
|
const push = (svc: ExtraGatewayService) => {
|
|
const key = `${svc.platform}:${svc.label}:${svc.detail}:${svc.scope}`;
|
|
if (seen.has(key)) {
|
|
return;
|
|
}
|
|
seen.add(key);
|
|
results.push(svc);
|
|
};
|
|
|
|
if (process.platform === "darwin") {
|
|
try {
|
|
const home = resolveHomeDir(env);
|
|
const userDir = path.join(home, "Library", "LaunchAgents");
|
|
for (const svc of await scanLaunchdDir({
|
|
dir: userDir,
|
|
scope: "user",
|
|
})) {
|
|
push(svc);
|
|
}
|
|
if (opts.deep) {
|
|
for (const svc of await scanLaunchdDir({
|
|
dir: path.join(path.sep, "Library", "LaunchAgents"),
|
|
scope: "system",
|
|
})) {
|
|
push(svc);
|
|
}
|
|
for (const svc of await scanLaunchdDir({
|
|
dir: path.join(path.sep, "Library", "LaunchDaemons"),
|
|
scope: "system",
|
|
})) {
|
|
push(svc);
|
|
}
|
|
}
|
|
} catch {
|
|
return results;
|
|
}
|
|
return results;
|
|
}
|
|
|
|
if (process.platform === "linux") {
|
|
try {
|
|
const home = resolveHomeDir(env);
|
|
const userDir = path.join(home, ".config", "systemd", "user");
|
|
for (const svc of await scanSystemdDir({
|
|
dir: userDir,
|
|
scope: "user",
|
|
})) {
|
|
push(svc);
|
|
}
|
|
if (opts.deep) {
|
|
for (const dir of [
|
|
"/etc/systemd/system",
|
|
"/usr/lib/systemd/system",
|
|
"/lib/systemd/system",
|
|
]) {
|
|
for (const svc of await scanSystemdDir({
|
|
dir,
|
|
scope: "system",
|
|
})) {
|
|
push(svc);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
return results;
|
|
}
|
|
return results;
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
if (!opts.deep) {
|
|
return results;
|
|
}
|
|
const res = await execSchtasks(["/Query", "/FO", "LIST", "/V"]);
|
|
if (res.code !== 0) {
|
|
return results;
|
|
}
|
|
const tasks = parseSchtasksList(res.stdout);
|
|
for (const task of tasks) {
|
|
const name = task.name.trim();
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
if (isOpenClawGatewayTaskName(name)) {
|
|
continue;
|
|
}
|
|
const lowerName = normalizeLowercaseStringOrEmpty(name);
|
|
const lowerCommand = normalizeLowercaseStringOrEmpty(task.taskToRun ?? "");
|
|
let marker: Marker | null = null;
|
|
for (const candidate of EXTRA_MARKERS) {
|
|
if (lowerName.includes(candidate) || lowerCommand.includes(candidate)) {
|
|
marker = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!marker) {
|
|
continue;
|
|
}
|
|
push({
|
|
platform: "win32",
|
|
label: name,
|
|
detail: task.taskToRun ? `task: ${name}, run: ${task.taskToRun}` : name,
|
|
scope: "system",
|
|
marker,
|
|
legacy: marker !== "openclaw",
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
return results;
|
|
}
|