feat: improve gateway services and auto-reply commands

This commit is contained in:
Peter Steinberger
2026-01-11 02:17:10 +01:00
parent df55d45b6f
commit e0bf86f06c
52 changed files with 888 additions and 213 deletions

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
formatGatewayServiceDescription,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "./constants.js";
describe("resolveGatewayLaunchAgentLabel", () => {
it("returns default label when no profile is set", () => {
const result = resolveGatewayLaunchAgentLabel();
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
expect(result).toBe("com.clawdbot.gateway");
});
it("returns default label when profile is undefined", () => {
const result = resolveGatewayLaunchAgentLabel(undefined);
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns default label when profile is 'default'", () => {
const result = resolveGatewayLaunchAgentLabel("default");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns default label when profile is 'Default' (case-insensitive)", () => {
const result = resolveGatewayLaunchAgentLabel("Default");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns profile-specific label when profile is set", () => {
const result = resolveGatewayLaunchAgentLabel("dev");
expect(result).toBe("com.clawdbot.dev");
});
it("returns profile-specific label for custom profile", () => {
const result = resolveGatewayLaunchAgentLabel("work");
expect(result).toBe("com.clawdbot.work");
});
it("trims whitespace from profile", () => {
const result = resolveGatewayLaunchAgentLabel(" staging ");
expect(result).toBe("com.clawdbot.staging");
});
it("returns default label for empty string profile", () => {
const result = resolveGatewayLaunchAgentLabel("");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
it("returns default label for whitespace-only profile", () => {
const result = resolveGatewayLaunchAgentLabel(" ");
expect(result).toBe(GATEWAY_LAUNCH_AGENT_LABEL);
});
});
describe("resolveGatewaySystemdServiceName", () => {
it("returns default service name when no profile is set", () => {
const result = resolveGatewaySystemdServiceName();
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
expect(result).toBe("clawdbot-gateway");
});
it("returns default service name when profile is undefined", () => {
const result = resolveGatewaySystemdServiceName(undefined);
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns default service name when profile is 'default'", () => {
const result = resolveGatewaySystemdServiceName("default");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns default service name when profile is 'DEFAULT' (case-insensitive)", () => {
const result = resolveGatewaySystemdServiceName("DEFAULT");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns profile-specific service name when profile is set", () => {
const result = resolveGatewaySystemdServiceName("dev");
expect(result).toBe("clawdbot-gateway-dev");
});
it("returns profile-specific service name for custom profile", () => {
const result = resolveGatewaySystemdServiceName("production");
expect(result).toBe("clawdbot-gateway-production");
});
it("trims whitespace from profile", () => {
const result = resolveGatewaySystemdServiceName(" test ");
expect(result).toBe("clawdbot-gateway-test");
});
it("returns default service name for empty string profile", () => {
const result = resolveGatewaySystemdServiceName("");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
});
describe("resolveGatewayWindowsTaskName", () => {
it("returns default task name when no profile is set", () => {
const result = resolveGatewayWindowsTaskName();
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
expect(result).toBe("Clawdbot Gateway");
});
it("returns default task name when profile is undefined", () => {
const result = resolveGatewayWindowsTaskName(undefined);
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns default task name when profile is 'default'", () => {
const result = resolveGatewayWindowsTaskName("default");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns default task name when profile is 'DeFaUlT' (case-insensitive)", () => {
const result = resolveGatewayWindowsTaskName("DeFaUlT");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns profile-specific task name when profile is set", () => {
const result = resolveGatewayWindowsTaskName("dev");
expect(result).toBe("Clawdbot Gateway (dev)");
});
it("returns profile-specific task name for custom profile", () => {
const result = resolveGatewayWindowsTaskName("work");
expect(result).toBe("Clawdbot Gateway (work)");
});
it("trims whitespace from profile", () => {
const result = resolveGatewayWindowsTaskName(" ci ");
expect(result).toBe("Clawdbot Gateway (ci)");
});
it("returns default task name for empty string profile", () => {
const result = resolveGatewayWindowsTaskName("");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
});
describe("formatGatewayServiceDescription", () => {
it("returns default description when no profile/version", () => {
expect(formatGatewayServiceDescription()).toBe("Clawdbot Gateway");
});
it("includes profile when set", () => {
expect(
formatGatewayServiceDescription({ profile: "work" }),
).toBe("Clawdbot Gateway (profile: work)");
});
it("includes version when set", () => {
expect(
formatGatewayServiceDescription({ version: "2026.1.10" }),
).toBe("Clawdbot Gateway (v2026.1.10)");
});
it("includes profile and version when set", () => {
expect(
formatGatewayServiceDescription({ profile: "dev", version: "1.2.3" }),
).toBe("Clawdbot Gateway (profile: dev, v1.2.3)");
});
});

View File

@@ -1,6 +1,8 @@
export const GATEWAY_LAUNCH_AGENT_LABEL = "com.clawdbot.gateway";
export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdbot-gateway";
export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway";
export const GATEWAY_SERVICE_MARKER = "clawdbot";
export const GATEWAY_SERVICE_KIND = "gateway";
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = [
"com.steipete.clawdbot.gateway",
"com.steipete.clawdis.gateway",
@@ -8,3 +10,46 @@ export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = [
];
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES = ["clawdis-gateway"];
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES = ["Clawdis Gateway"];
export function resolveGatewayLaunchAgentLabel(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_LAUNCH_AGENT_LABEL;
}
return `com.clawdbot.${trimmed}`;
}
function normalizeGatewayProfile(profile?: string): string | null {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") return null;
return trimmed;
}
export function resolveGatewaySystemdServiceName(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_SYSTEMD_SERVICE_NAME;
}
return `clawdbot-gateway-${trimmed}`;
}
export function resolveGatewayWindowsTaskName(profile?: string): string {
const trimmed = profile?.trim();
if (!trimmed || trimmed.toLowerCase() === "default") {
return GATEWAY_WINDOWS_TASK_NAME;
}
return `Clawdbot Gateway (${trimmed})`;
}
export function formatGatewayServiceDescription(params?: {
profile?: string;
version?: string;
}): string {
const profile = normalizeGatewayProfile(params?.profile);
const version = params?.version?.trim();
const parts: string[] = [];
if (profile) parts.push(`profile: ${profile}`);
if (version) parts.push(`v${version}`);
if (parts.length === 0) return "Clawdbot Gateway";
return `Clawdbot Gateway (${parts.join(", ")})`;
}

View File

@@ -4,12 +4,14 @@ import path from "node:path";
import { promisify } from "node:util";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "./constants.js";
export type ExtraGatewayService = {
@@ -26,20 +28,32 @@ export type FindExtraGatewayServicesOptions = {
const EXTRA_MARKERS = ["clawdbot", "clawdis"];
const execFileAsync = promisify(execFile);
export function renderGatewayServiceCleanupHints(): string[] {
export function renderGatewayServiceCleanupHints(
env: Record<string, string | undefined> = process.env as Record<
string,
string | undefined
>,
): string[] {
const profile = env.CLAWDBOT_PROFILE;
switch (process.platform) {
case "darwin":
case "darwin": {
const label = resolveGatewayLaunchAgentLabel(profile);
return [
`launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`rm ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
`launchctl bootout gui/$UID/${label}`,
`rm ~/Library/LaunchAgents/${label}.plist`,
];
case "linux":
}
case "linux": {
const unit = resolveGatewaySystemdServiceName(profile);
return [
`systemctl --user disable --now ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`rm ~/.config/systemd/user/${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`systemctl --user disable --now ${unit}.service`,
`rm ~/.config/systemd/user/${unit}.service`,
];
case "win32":
return [`schtasks /Delete /TN "${GATEWAY_WINDOWS_TASK_NAME}" /F`];
}
case "win32": {
const task = resolveGatewayWindowsTaskName(profile);
return [`schtasks /Delete /TN "${task}" /F`];
}
default:
return [];
}
@@ -56,6 +70,42 @@ function containsMarker(content: string): boolean {
return EXTRA_MARKERS.some((marker) => lower.includes(marker));
}
function hasGatewayServiceMarker(content: string): boolean {
const lower = content.toLowerCase();
return (
lower.includes("clawdbot_service_marker") &&
lower.includes(GATEWAY_SERVICE_MARKER.toLowerCase()) &&
lower.includes("clawdbot_service_kind") &&
lower.includes(GATEWAY_SERVICE_KIND.toLowerCase())
);
}
function isClawdbotGatewayLaunchdService(
label: string,
contents: string,
): boolean {
if (hasGatewayServiceMarker(contents)) return true;
const lowerContents = contents.toLowerCase();
if (!lowerContents.includes("gateway")) return false;
return label.startsWith("com.clawdbot.");
}
function isClawdbotGatewaySystemdService(
name: string,
contents: string,
): boolean {
if (hasGatewayServiceMarker(contents)) return true;
if (!name.startsWith("clawdbot-gateway")) return false;
return contents.toLowerCase().includes("gateway");
}
function isClawdbotGatewayTaskName(name: string): boolean {
const normalized = name.trim().toLowerCase();
if (!normalized) return false;
const defaultName = resolveGatewayWindowsTaskName().toLowerCase();
return normalized === defaultName || normalized.startsWith("clawdbot gateway");
}
function tryExtractPlistLabel(contents: string): string | null {
const match = contents.match(
/<key>Label<\/key>\s*<string>([\s\S]*?)<\/string>/i,
@@ -66,14 +116,14 @@ function tryExtractPlistLabel(contents: string): string | null {
function isIgnoredLaunchdLabel(label: string): boolean {
return (
label === GATEWAY_LAUNCH_AGENT_LABEL ||
label === resolveGatewayLaunchAgentLabel() ||
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS.includes(label)
);
}
function isIgnoredSystemdName(name: string): boolean {
return (
name === GATEWAY_SYSTEMD_SERVICE_NAME ||
name === resolveGatewaySystemdServiceName() ||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES.includes(name)
);
}
@@ -104,6 +154,7 @@ async function scanLaunchdDir(params: {
if (!containsMarker(contents)) continue;
const label = tryExtractPlistLabel(contents) ?? labelFromName;
if (isIgnoredLaunchdLabel(label)) continue;
if (isClawdbotGatewayLaunchdService(label, contents)) continue;
results.push({
platform: "darwin",
label,
@@ -139,6 +190,7 @@ async function scanSystemdDir(params: {
continue;
}
if (!containsMarker(contents)) continue;
if (isClawdbotGatewaySystemdService(name, contents)) continue;
results.push({
platform: "linux",
label: entry,
@@ -302,7 +354,7 @@ export async function findExtraGatewayServices(
for (const task of tasks) {
const name = task.name.trim();
if (!name) continue;
if (name === GATEWAY_WINDOWS_TASK_NAME) continue;
if (isClawdbotGatewayTaskName(name)) continue;
if (LEGACY_GATEWAY_WINDOWS_TASK_NAMES.includes(name)) continue;
const lowerName = name.toLowerCase();
const lowerCommand = task.taskToRun?.toLowerCase() ?? "";

View File

@@ -7,6 +7,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
formatGatewayServiceDescription,
resolveGatewayLaunchAgentLabel,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -34,7 +36,10 @@ function resolveLaunchAgentPlistPathForLabel(
export function resolveLaunchAgentPlistPath(
env: Record<string, string | undefined>,
): string {
return resolveLaunchAgentPlistPathForLabel(env, GATEWAY_LAUNCH_AGENT_LABEL);
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
return resolveLaunchAgentPlistPathForLabel(env, label);
}
export function resolveGatewayLogPaths(
@@ -162,6 +167,7 @@ export async function readLaunchAgentProgramArguments(
export function buildLaunchAgentPlist({
label = GATEWAY_LAUNCH_AGENT_LABEL,
comment,
programArguments,
workingDirectory,
stdoutPath,
@@ -169,6 +175,7 @@ export function buildLaunchAgentPlist({
environment,
}: {
label?: string;
comment?: string;
programArguments: string[];
workingDirectory?: string;
stdoutPath: string;
@@ -183,6 +190,12 @@ export function buildLaunchAgentPlist({
<key>WorkingDirectory</key>
<string>${plistEscape(workingDirectory)}</string>`
: "";
const commentXml =
comment && comment.trim()
? `
<key>Comment</key>
<string>${plistEscape(comment.trim())}</string>`
: "";
const envXml = renderEnvDict(environment);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -190,6 +203,7 @@ export function buildLaunchAgentPlist({
<dict>
<key>Label</key>
<string>${plistEscape(label)}</string>
${commentXml}
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
@@ -271,9 +285,9 @@ export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
return info;
}
export async function isLaunchAgentLoaded(): Promise<boolean> {
export async function isLaunchAgentLoaded(profile?: string): Promise<boolean> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label = resolveGatewayLaunchAgentLabel(profile);
const res = await execLaunchctl(["print", `${domain}/${label}`]);
return res.code === 0;
}
@@ -294,7 +308,9 @@ export async function readLaunchAgentRuntime(
env: Record<string, string | undefined>,
): Promise<GatewayServiceRuntime> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
const res = await execLaunchctl(["print", `${domain}/${label}`]);
if (res.code !== 0) {
return {
@@ -418,7 +434,10 @@ export async function uninstallLaunchAgent({
const home = resolveHomeDir(env);
const trashDir = path.join(home, ".Trash");
const dest = path.join(trashDir, `${GATEWAY_LAUNCH_AGENT_LABEL}.plist`);
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
const dest = path.join(trashDir, `${label}.plist`);
try {
await fs.mkdir(trashDir, { recursive: true });
await fs.rename(plistPath, dest);
@@ -443,11 +462,13 @@ function isLaunchctlNotLoaded(res: {
export async function stopLaunchAgent({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label = resolveGatewayLaunchAgentLabel(profile);
const res = await execLaunchctl(["bootout", `${domain}/${label}`]);
if (res.code !== 0 && !isLaunchctlNotLoaded(res)) {
throw new Error(
@@ -474,6 +495,9 @@ export async function installLaunchAgent({
await fs.mkdir(logDir, { recursive: true });
const domain = resolveGuiDomain();
const label =
env.CLAWDBOT_LAUNCHD_LABEL?.trim() ||
resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
for (const legacyLabel of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) {
const legacyPlistPath = resolveLaunchAgentPlistPathForLabel(
env,
@@ -488,10 +512,17 @@ export async function installLaunchAgent({
}
}
const plistPath = resolveLaunchAgentPlistPath(env);
const plistPath = resolveLaunchAgentPlistPathForLabel(env, label);
await fs.mkdir(path.dirname(plistPath), { recursive: true });
const description = formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version:
environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const plist = buildLaunchAgentPlist({
label,
comment: description,
programArguments,
workingDirectory,
stdoutPath,
@@ -508,12 +539,8 @@ export async function installLaunchAgent({
`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim(),
);
}
await execLaunchctl(["enable", `${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`]);
await execLaunchctl([
"kickstart",
"-k",
`${domain}/${GATEWAY_LAUNCH_AGENT_LABEL}`,
]);
await execLaunchctl(["enable", `${domain}/${label}`]);
await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`);
stdout.write(`${formatLine("Logs", stdoutPath)}\n`);
@@ -522,11 +549,13 @@ export async function installLaunchAgent({
export async function restartLaunchAgent({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const label = resolveGatewayLaunchAgentLabel(profile);
const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (res.code !== 0) {
throw new Error(

View File

@@ -5,8 +5,9 @@ import { promisify } from "node:util";
import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_WINDOWS_TASK_NAME,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
formatGatewayServiceDescription,
resolveGatewayWindowsTaskName,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -28,7 +29,10 @@ function resolveTaskScriptPath(
env: Record<string, string | undefined>,
): string {
const home = resolveHomeDir(env);
return path.join(home, ".clawdbot", "gateway.cmd");
const profile = env.CLAWDBOT_PROFILE?.trim();
const suffix =
profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
return path.join(home, `.clawdbot${suffix}`, "gateway.cmd");
}
function resolveLegacyTaskScriptPath(
@@ -78,18 +82,32 @@ function parseCommandLine(value: string): string[] {
export async function readScheduledTaskCommand(
env: Record<string, string | undefined>,
): Promise<{ programArguments: string[]; workingDirectory?: string } | null> {
): Promise<{
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
} | null> {
const scriptPath = resolveTaskScriptPath(env);
try {
const content = await fs.readFile(scriptPath, "utf8");
let workingDirectory = "";
let commandLine = "";
const environment: Record<string, string> = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith("@echo")) continue;
if (line.toLowerCase().startsWith("rem ")) continue;
if (line.toLowerCase().startsWith("set ")) continue;
if (line.toLowerCase().startsWith("set ")) {
const assignment = line.slice(4).trim();
const index = assignment.indexOf("=");
if (index > 0) {
const key = assignment.slice(0, index).trim();
const value = assignment.slice(index + 1).trim();
if (key) environment[key] = value;
}
continue;
}
if (line.toLowerCase().startsWith("cd /d ")) {
workingDirectory = line
.slice("cd /d ".length)
@@ -104,6 +122,7 @@ export async function readScheduledTaskCommand(
return {
programArguments: parseCommandLine(commandLine),
...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
};
} catch {
return null;
@@ -129,15 +148,20 @@ export function parseSchtasksQuery(output: string): ScheduledTaskInfo {
}
function buildTaskScript({
description,
programArguments,
workingDirectory,
environment,
}: {
description?: string;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): string {
const lines: string[] = ["@echo off"];
if (description?.trim()) {
lines.push(`rem ${description.trim()}`);
}
if (workingDirectory) {
lines.push(`cd /d ${quoteCmdArg(workingDirectory)}`);
}
@@ -208,13 +232,20 @@ export async function installScheduledTask({
await assertSchtasksAvailable();
const scriptPath = resolveTaskScriptPath(env);
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
const description = formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version:
environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const script = buildTaskScript({
description,
programArguments,
workingDirectory,
environment,
});
await fs.writeFile(scriptPath, script, "utf8");
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
const quotedScript = quoteCmdArg(scriptPath);
const create = await execSchtasks([
"/Create",
@@ -224,7 +255,7 @@ export async function installScheduledTask({
"/RL",
"LIMITED",
"/TN",
GATEWAY_WINDOWS_TASK_NAME,
taskName,
"/TR",
quotedScript,
]);
@@ -234,10 +265,8 @@ export async function installScheduledTask({
);
}
await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
stdout.write(
`${formatLine("Installed Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
await execSchtasks(["/Run", "/TN", taskName]);
stdout.write(`${formatLine("Installed Scheduled Task", taskName)}\n`);
stdout.write(`${formatLine("Task script", scriptPath)}\n`);
return { scriptPath };
}
@@ -250,7 +279,8 @@ export async function uninstallScheduledTask({
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSchtasksAvailable();
await execSchtasks(["/Delete", "/F", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
await execSchtasks(["/Delete", "/F", "/TN", taskName]);
const scriptPath = resolveTaskScriptPath(env);
try {
@@ -272,42 +302,52 @@ function isTaskNotRunning(res: {
export async function stopScheduledTask({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSchtasksAvailable();
const res = await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(profile);
const res = await execSchtasks(["/End", "/TN", taskName]);
if (res.code !== 0 && !isTaskNotRunning(res)) {
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(
`${formatLine("Stopped Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
stdout.write(`${formatLine("Stopped Scheduled Task", taskName)}\n`);
}
export async function restartScheduledTask({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSchtasksAvailable();
await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const res = await execSchtasks(["/Run", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(profile);
await execSchtasks(["/End", "/TN", taskName]);
const res = await execSchtasks(["/Run", "/TN", taskName]);
if (res.code !== 0) {
throw new Error(`schtasks run failed: ${res.stderr || res.stdout}`.trim());
}
stdout.write(
`${formatLine("Restarted Scheduled Task", GATEWAY_WINDOWS_TASK_NAME)}\n`,
);
stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`);
}
export async function isScheduledTaskInstalled(): Promise<boolean> {
export async function isScheduledTaskInstalled(
profile?: string,
): Promise<boolean> {
await assertSchtasksAvailable();
const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
const taskName = resolveGatewayWindowsTaskName(profile);
const res = await execSchtasks(["/Query", "/TN", taskName]);
return res.code === 0;
}
export async function readScheduledTaskRuntime(): Promise<GatewayServiceRuntime> {
export async function readScheduledTaskRuntime(
env: Record<string, string | undefined> = process.env as Record<
string,
string | undefined
>,
): Promise<GatewayServiceRuntime> {
try {
await assertSchtasksAvailable();
} catch (err) {
@@ -316,10 +356,11 @@ export async function readScheduledTaskRuntime(): Promise<GatewayServiceRuntime>
detail: String(err),
};
}
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
const res = await execSchtasks([
"/Query",
"/TN",
GATEWAY_WINDOWS_TASK_NAME,
taskName,
"/V",
"/FO",
"LIST",

View File

@@ -58,5 +58,23 @@ describe("buildServiceEnvironment", () => {
}
expect(env.CLAWDBOT_GATEWAY_PORT).toBe("18789");
expect(env.CLAWDBOT_GATEWAY_TOKEN).toBe("secret");
expect(env.CLAWDBOT_SERVICE_MARKER).toBe("clawdbot");
expect(env.CLAWDBOT_SERVICE_KIND).toBe("gateway");
expect(typeof env.CLAWDBOT_SERVICE_VERSION).toBe("string");
expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("clawdbot-gateway.service");
if (process.platform === "darwin") {
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.gateway");
}
});
it("uses profile-specific unit and label", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user", CLAWDBOT_PROFILE: "work" },
port: 18789,
});
expect(env.CLAWDBOT_SYSTEMD_UNIT).toBe("clawdbot-gateway-work.service");
if (process.platform === "darwin") {
expect(env.CLAWDBOT_LAUNCHD_LABEL).toBe("com.clawdbot.work");
}
});
});

View File

@@ -1,5 +1,13 @@
import path from "node:path";
import { VERSION } from "../version.js";
import {
GATEWAY_SERVICE_KIND,
GATEWAY_SERVICE_MARKER,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
} from "./constants.js";
export type MinimalServicePathOptions = {
platform?: NodeJS.Platform;
extraDirs?: string[];
@@ -59,13 +67,24 @@ export function buildServiceEnvironment(params: {
launchdLabel?: string;
}): Record<string, string | undefined> {
const { env, port, token, launchdLabel } = params;
const profile = env.CLAWDBOT_PROFILE;
const resolvedLaunchdLabel =
launchdLabel ||
(process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(profile)
: undefined);
const systemdUnit = `${resolveGatewaySystemdServiceName(profile)}.service`;
return {
PATH: buildMinimalServicePath({ env }),
CLAWDBOT_PROFILE: env.CLAWDBOT_PROFILE,
CLAWDBOT_PROFILE: profile,
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN: token,
CLAWDBOT_LAUNCHD_LABEL: launchdLabel,
CLAWDBOT_LAUNCHD_LABEL: resolvedLaunchdLabel,
CLAWDBOT_SYSTEMD_UNIT: systemdUnit,
CLAWDBOT_SERVICE_MARKER: GATEWAY_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: GATEWAY_SERVICE_KIND,
CLAWDBOT_SERVICE_VERSION: VERSION,
};
}

View File

@@ -44,11 +44,15 @@ export type GatewayService = {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
stop: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
restart: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
isLoaded: (args: {
env: Record<string, string | undefined>;
}) => Promise<boolean>;
stop: (args: {
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
restart: (args: {
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
isLoaded: (args: { profile?: string }) => Promise<boolean>;
readCommand: (env: Record<string, string | undefined>) => Promise<{
programArguments: string[];
workingDirectory?: string;
@@ -73,12 +77,15 @@ export function resolveGatewayService(): GatewayService {
await uninstallLaunchAgent(args);
},
stop: async (args) => {
await stopLaunchAgent(args);
await stopLaunchAgent({ stdout: args.stdout, profile: args.profile });
},
restart: async (args) => {
await restartLaunchAgent(args);
await restartLaunchAgent({
stdout: args.stdout,
profile: args.profile,
});
},
isLoaded: async () => isLaunchAgentLoaded(),
isLoaded: async (args) => isLaunchAgentLoaded(args.profile),
readCommand: readLaunchAgentProgramArguments,
readRuntime: readLaunchAgentRuntime,
};
@@ -96,14 +103,17 @@ export function resolveGatewayService(): GatewayService {
await uninstallSystemdService(args);
},
stop: async (args) => {
await stopSystemdService(args);
await stopSystemdService({ stdout: args.stdout, profile: args.profile });
},
restart: async (args) => {
await restartSystemdService(args);
await restartSystemdService({
stdout: args.stdout,
profile: args.profile,
});
},
isLoaded: async () => isSystemdServiceEnabled(),
isLoaded: async (args) => isSystemdServiceEnabled(args.profile),
readCommand: readSystemdServiceExecStart,
readRuntime: async () => await readSystemdServiceRuntime(),
readRuntime: async (env) => await readSystemdServiceRuntime(env),
};
}
@@ -119,14 +129,17 @@ export function resolveGatewayService(): GatewayService {
await uninstallScheduledTask(args);
},
stop: async (args) => {
await stopScheduledTask(args);
await stopScheduledTask({ stdout: args.stdout, profile: args.profile });
},
restart: async (args) => {
await restartScheduledTask(args);
await restartScheduledTask({
stdout: args.stdout,
profile: args.profile,
});
},
isLoaded: async () => isScheduledTaskInstalled(),
isLoaded: async (args) => isScheduledTaskInstalled(args.profile),
readCommand: readScheduledTaskCommand,
readRuntime: async () => await readScheduledTaskRuntime(),
readRuntime: async (env) => await readScheduledTaskRuntime(env),
};
}

View File

@@ -6,8 +6,9 @@ import { promisify } from "node:util";
import { runCommandWithTimeout, runExec } from "../process/exec.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import {
GATEWAY_SYSTEMD_SERVICE_NAME,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
formatGatewayServiceDescription,
resolveGatewaySystemdServiceName,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
@@ -33,10 +34,22 @@ function resolveSystemdUnitPathForName(
return path.join(home, ".config", "systemd", "user", `${name}.service`);
}
function resolveSystemdServiceName(
env: Record<string, string | undefined>,
): string {
const override = env.CLAWDBOT_SYSTEMD_UNIT?.trim();
if (override) {
return override.endsWith(".service")
? override.slice(0, -".service".length)
: override;
}
return resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
}
function resolveSystemdUnitPath(
env: Record<string, string | undefined>,
): string {
return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME);
return resolveSystemdUnitPathForName(env, resolveSystemdServiceName(env));
}
export function resolveSystemdUserUnitPath(
@@ -137,22 +150,25 @@ function renderEnvLines(
}
function buildSystemdUnit({
description,
programArguments,
workingDirectory,
environment,
}: {
description?: string;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
}): string {
const execStart = programArguments.map(systemdEscapeArg).join(" ");
const descriptionLine = `Description=${description?.trim() || "Clawdbot Gateway"}`;
const workingDirLine = workingDirectory
? `WorkingDirectory=${systemdEscapeArg(workingDirectory)}`
: null;
const envLines = renderEnvLines(environment);
return [
"[Unit]",
"Description=Clawdbot Gateway",
descriptionLine,
"After=network-online.target",
"Wants=network-online.target",
"",
@@ -387,14 +403,21 @@ export async function installSystemdService({
const unitPath = resolveSystemdUnitPath(env);
await fs.mkdir(path.dirname(unitPath), { recursive: true });
const description = formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version:
environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const unit = buildSystemdUnit({
description,
programArguments,
workingDirectory,
environment,
});
await fs.writeFile(unitPath, unit, "utf8");
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
const unitName = `${serviceName}.service`;
const reload = await execSystemctl(["--user", "daemon-reload"]);
if (reload.code !== 0) {
throw new Error(
@@ -428,7 +451,8 @@ export async function uninstallSystemdService({
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
const unitName = `${serviceName}.service`;
await execSystemctl(["--user", "disable", "--now", unitName]);
const unitPath = resolveSystemdUnitPath(env);
@@ -442,11 +466,14 @@ export async function uninstallSystemdService({
export async function stopSystemdService({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(profile);
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "stop", unitName]);
if (res.code !== 0) {
throw new Error(
@@ -458,11 +485,14 @@ export async function stopSystemdService({
export async function restartSystemdService({
stdout,
profile,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(profile);
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "restart", unitName]);
if (res.code !== 0) {
throw new Error(
@@ -472,14 +502,22 @@ export async function restartSystemdService({
stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`);
}
export async function isSystemdServiceEnabled(): Promise<boolean> {
export async function isSystemdServiceEnabled(
profile?: string,
): Promise<boolean> {
await assertSystemdAvailable();
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveGatewaySystemdServiceName(profile);
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "is-enabled", unitName]);
return res.code === 0;
}
export async function readSystemdServiceRuntime(): Promise<GatewayServiceRuntime> {
export async function readSystemdServiceRuntime(
env: Record<string, string | undefined> = process.env as Record<
string,
string | undefined
>,
): Promise<GatewayServiceRuntime> {
try {
await assertSystemdAvailable();
} catch (err) {
@@ -488,7 +526,8 @@ export async function readSystemdServiceRuntime(): Promise<GatewayServiceRuntime
detail: String(err),
};
}
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const serviceName = resolveSystemdServiceName(env);
const unitName = `${serviceName}.service`;
const res = await execSystemctl([
"--user",
"show",