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

@@ -57,6 +57,12 @@ describe("control command parsing", () => {
}
});
it("respects disabled config/debug commands", () => {
const cfg = { commands: { config: false, debug: false } };
expect(hasControlCommand("/config show", cfg)).toBe(false);
expect(hasControlCommand("/debug show", cfg)).toBe(false);
});
it("requires commands to be the full message", () => {
expect(hasControlCommand("hello /status")).toBe(false);
expect(hasControlCommand("/status please")).toBe(false);

View File

@@ -1,13 +1,22 @@
import { listChatCommands, normalizeCommandBody } from "./commands-registry.js";
import type { ClawdbotConfig } from "../config/types.js";
import {
listChatCommands,
listChatCommandsForConfig,
normalizeCommandBody,
} from "./commands-registry.js";
export function hasControlCommand(text?: string): boolean {
export function hasControlCommand(
text?: string,
cfg?: ClawdbotConfig,
): boolean {
if (!text) return false;
const trimmed = text.trim();
if (!trimmed) return false;
const normalizedBody = normalizeCommandBody(trimmed);
if (!normalizedBody) return false;
const lowered = normalizedBody.toLowerCase();
for (const command of listChatCommands()) {
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
for (const command of commands) {
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;

View File

@@ -4,7 +4,9 @@ import {
buildCommandText,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "./commands-registry.js";
@@ -21,6 +23,26 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, debug: false },
});
expect(disabled.find((spec) => spec.key === "config")).toBeFalsy();
expect(disabled.find((spec) => spec.key === "debug")).toBeFalsy();
const enabled = listChatCommandsForConfig({
commands: { config: true, debug: true },
});
expect(enabled.find((spec) => spec.key === "config")).toBeTruthy();
expect(enabled.find((spec) => spec.key === "debug")).toBeTruthy();
const nativeDisabled = listNativeCommandSpecsForConfig({
commands: { config: false, debug: false, native: true },
});
expect(nativeDisabled.find((spec) => spec.name === "config")).toBeFalsy();
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -290,6 +290,21 @@ export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
}
export function isCommandEnabled(
cfg: ClawdbotConfig,
commandKey: string,
): boolean {
if (commandKey === "config") return cfg.commands?.config === true;
if (commandKey === "debug") return cfg.commands?.debug === true;
return true;
}
export function listChatCommandsForConfig(
cfg: ClawdbotConfig,
): ChatCommandDefinition[] {
return CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.filter(
(command) => command.scope !== "text" && command.nativeName,
@@ -300,6 +315,18 @@ export function listNativeCommandSpecs(): NativeCommandSpec[] {
}));
}
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}));
}
export function findCommandByNativeName(
name: string,
): ChatCommandDefinition | undefined {

View File

@@ -877,7 +877,7 @@ export async function getReplyFromConfig(
allowTextCommands &&
!commandAuthorized &&
!baseBodyTrimmedRaw &&
hasControlCommand(commandSource)
hasControlCommand(commandSource, cfg)
) {
typing.cleanup();
return undefined;

View File

@@ -602,7 +602,7 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
return { shouldContinue: false, reply: { text: buildHelpMessage(cfg) } };
}
const commandsRequested = command.commandBodyNormalized === "/commands";
@@ -613,7 +613,7 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildCommandsMessage() } };
return { shouldContinue: false, reply: { text: buildCommandsMessage(cfg) } };
}
const statusRequested =
@@ -650,6 +650,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.config !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /config is disabled. Set commands.config=true to enable.",
},
};
}
if (configCommand.action === "error") {
return {
shouldContinue: false,
@@ -774,6 +782,14 @@ export async function handleCommands(params: {
);
return { shouldContinue: false };
}
if (cfg.commands?.debug !== true) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /debug is disabled. Set commands.debug=true to enable.",
},
};
}
if (debugCommand.action === "error") {
return {
shouldContinue: false,

View File

@@ -4,7 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
import {
buildCommandsMessage,
buildHelpMessage,
buildStatusMessage,
} from "./status.js";
afterEach(() => {
vi.restoreAllMocks();
@@ -317,7 +321,9 @@ describe("buildStatusMessage", () => {
describe("buildCommandsMessage", () => {
it("lists commands with aliases and text-only hints", () => {
const text = buildCommandsMessage();
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("/commands - List all slash commands.");
expect(text).toContain(
"/think (aliases: /thinking, /t) - Set thinking level.",
@@ -325,5 +331,17 @@ describe("buildCommandsMessage", () => {
expect(text).toContain(
"/compact (text-only) - Compact the session context.",
);
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
});
describe("buildHelpMessage", () => {
it("hides config/debug when disabled", () => {
const text = buildHelpMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
});

View File

@@ -28,7 +28,10 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands } from "./commands-registry.js";
import {
listChatCommands,
listChatCommandsForConfig,
} from "./commands-registry.js";
import type {
ElevatedLevel,
ReasoningLevel,
@@ -356,18 +359,29 @@ export function buildStatusMessage(args: StatusArgs): string {
.join("\n");
}
export function buildHelpMessage(): string {
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
const options = [
"/think <level>",
"/verbose on|off",
"/reasoning on|off",
"/elevated on|off",
"/model <id>",
"/cost on|off",
];
if (cfg?.commands?.config === true) options.push("/config show");
if (cfg?.commands?.debug === true) options.push("/debug show");
return [
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /config show | /debug show",
`Options: ${options.join(" | ")}`,
"More: /commands for all slash commands",
].join("\n");
}
export function buildCommandsMessage(): string {
export function buildCommandsMessage(cfg?: ClawdbotConfig): string {
const lines = [" Slash commands"];
for (const command of listChatCommands()) {
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
for (const command of commands) {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;

View File

@@ -19,9 +19,9 @@ import type {
GatewayControlUiConfig,
} from "../config/types.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import {
@@ -309,31 +309,42 @@ function renderRuntimeHints(
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (process.platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
);
} else if (process.platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
const task = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);
}
}
return hints;
}
function renderGatewayServiceStartHints(): string[] {
function renderGatewayServiceStartHints(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const base = ["clawdbot daemon install", "clawdbot gateway"];
const profile = env.CLAWDBOT_PROFILE;
switch (process.platform) {
case "darwin":
case "darwin": {
const label = resolveGatewayLaunchAgentLabel(profile);
return [
...base,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${label}.plist`,
];
case "linux":
}
case "linux": {
const unit = resolveGatewaySystemdServiceName(profile);
return [
...base,
`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`systemctl --user start ${unit}.service`,
];
case "win32":
return [...base, `schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
}
case "win32": {
const task = resolveGatewayWindowsTaskName(profile);
return [...base, `schtasks /Run /TN "${task}"`];
}
default:
return base;
}
@@ -346,7 +357,9 @@ async function gatherDaemonStatus(opts: {
}): Promise<DaemonStatus> {
const service = resolveGatewayService();
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service
.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })
.catch(() => false),
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined),
]);
@@ -713,9 +726,11 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
spacer();
}
if (service.runtime?.cachedLabel) {
const env = (service.command?.environment ?? process.env) as NodeJS.ProcessEnv;
const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
defaultRuntime.error(
errorText(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`,
),
);
defaultRuntime.error(errorText("Then reinstall: clawdbot daemon install"));
@@ -767,9 +782,11 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
);
}
if (process.platform === "linux") {
const env = (service.command?.environment ?? process.env) as NodeJS.ProcessEnv;
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
defaultRuntime.error(
errorText(
`Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`,
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
),
);
} else if (process.platform === "darwin") {
@@ -872,9 +889,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -910,7 +928,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
cfg.gateway?.auth?.token ||
process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(profile)
: undefined,
});
try {
@@ -945,9 +965,10 @@ export async function runDaemonUninstall() {
export async function runDaemonStart() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -961,7 +982,7 @@ export async function runDaemonStart() {
return;
}
try {
await service.restart({ stdout: process.stdout });
await service.restart({ profile, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) {
@@ -973,9 +994,10 @@ export async function runDaemonStart() {
export async function runDaemonStop() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -986,7 +1008,7 @@ export async function runDaemonStop() {
return;
}
try {
await service.stop({ stdout: process.stdout });
await service.stop({ profile, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -1000,9 +1022,10 @@ export async function runDaemonStop() {
*/
export async function runDaemonRestart(): Promise<boolean> {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({ profile });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -1016,7 +1039,7 @@ export async function runDaemonRestart(): Promise<boolean> {
return false;
}
try {
await service.restart({ stdout: process.stdout });
await service.restart({ profile, stdout: process.stdout });
return true;
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);

View File

@@ -15,9 +15,9 @@ import {
writeConfigFile,
} from "../config/config.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { resolveGatewayService } from "../daemon/service.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
@@ -362,22 +362,25 @@ function extractGatewayMiskeys(parsed: unknown): {
return { hasGatewayToken, hasRemoteToken };
}
function renderGatewayServiceStopHints(): string[] {
function renderGatewayServiceStopHints(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const profile = env.CLAWDBOT_PROFILE;
switch (process.platform) {
case "darwin":
return [
"Tip: clawdbot daemon stop",
`Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`Or: launchctl bootout gui/$UID/${resolveGatewayLaunchAgentLabel(profile)}`,
];
case "linux":
return [
"Tip: clawdbot daemon stop",
`Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
`Or: systemctl --user stop ${resolveGatewaySystemdServiceName(profile)}.service`,
];
case "win32":
return [
"Tip: clawdbot daemon stop",
`Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`,
`Or: schtasks /End /TN "${resolveGatewayWindowsTaskName(profile)}"`,
];
default:
return ["Tip: clawdbot daemon stop"];
@@ -388,7 +391,7 @@ async function maybeExplainGatewayServiceStop() {
const service = resolveGatewayService();
let loaded: boolean | null = null;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
} catch {
loaded = null;
}

View File

@@ -17,7 +17,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -340,7 +340,9 @@ async function maybeInstallDaemon(params: {
daemonRuntime?: GatewayDaemonRuntime;
}) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
let shouldCheckLinger = false;
let shouldInstall = true;
let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
@@ -357,7 +359,10 @@ async function maybeInstallDaemon(params: {
params.runtime,
);
if (action === "restart") {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
shouldCheckLinger = true;
shouldInstall = false;
}
@@ -397,7 +402,9 @@ async function maybeInstallDaemon(params: {
port: params.port,
token: params.gatewayToken,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
env: process.env,

View File

@@ -1,4 +1,8 @@
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
resolveGatewayWindowsTaskName,
} from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { getResolvedLoggerSettings } from "../logging.js";
@@ -51,8 +55,9 @@ export function buildGatewayRuntimeHints(
}
})();
if (runtime.cachedLabel && platform === "darwin") {
const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
hints.push(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`,
);
hints.push("Then reinstall: clawdbot daemon install");
}
@@ -71,11 +76,13 @@ export function buildGatewayRuntimeHints(
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
} else if (platform === "linux") {
const unit = resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`,
);
} else if (platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
const task = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
hints.push(`Logs: schtasks /Query /TN "${task}" /V /FO LIST`);
}
}
return hints;

View File

@@ -4,7 +4,7 @@ import { note as clackNote } from "@clack/prompts";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import {
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
@@ -103,7 +103,9 @@ export async function maybeMigrateLegacyGatewayService(
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
if (loaded) {
note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway");
return;
@@ -143,7 +145,9 @@ export async function maybeMigrateLegacyGatewayService(
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
env: process.env,
@@ -263,7 +267,9 @@ export async function maybeRepairGatewayServiceConfig(
port,
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
try {

View File

@@ -24,7 +24,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
@@ -421,7 +421,9 @@ export async function doctorCommand(
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
} catch {
loaded = false;
}
@@ -503,7 +505,9 @@ export async function doctorCommand(
if (!healthOk) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
let serviceRuntime:
| Awaited<ReturnType<typeof service.readRuntime>>
| undefined;
@@ -562,7 +566,7 @@ export async function doctorCommand(
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
@@ -592,13 +596,19 @@ export async function doctorCommand(
initialValue: true,
});
if (start) {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
await sleep(1500);
}
}
if (process.platform === "darwin") {
const label = resolveGatewayLaunchAgentLabel(
process.env.CLAWDBOT_PROFILE,
);
note(
`LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`,
`LaunchAgent loaded; stopping requires "clawdbot daemon stop" or launchctl bootout gui/$UID/${label}.`,
"Gateway",
);
}
@@ -608,7 +618,10 @@ export async function doctorCommand(
initialValue: true,
});
if (restart) {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
await sleep(1500);
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);

View File

@@ -14,7 +14,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -505,15 +505,15 @@ export async function runNonInteractiveOnboarding(
runtime: daemonRuntimeRaw,
nodePath,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
token: gatewayToken,
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
});
const environment = buildServiceEnvironment({
env: process.env,
port,
token: gatewayToken,
launchdLabel:
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({
env: process.env,
stdout: process.stdout,

View File

@@ -119,7 +119,9 @@ export async function statusAllCommand(
try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service
.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })
.catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);

View File

@@ -257,7 +257,9 @@ async function getDaemonStatusSummary(): Promise<{
try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service
.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })
.catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);

View File

@@ -14,6 +14,8 @@ describe("config paths", () => {
error: "Invalid path. Use dot notation (e.g. foo.bar).",
});
expect(parseConfigPath("__proto__.polluted").ok).toBe(false);
expect(parseConfigPath("constructor.polluted").ok).toBe(false);
expect(parseConfigPath("prototype.polluted").ok).toBe(false);
});
it("sets, gets, and unsets nested values", () => {

View File

@@ -39,4 +39,17 @@ describe("runtime overrides", () => {
expect(removed.removed).toBe(true);
expect(Object.keys(getConfigOverrides()).length).toBe(0);
});
it("rejects prototype pollution paths", () => {
const attempts = [
"__proto__.polluted",
"constructor.polluted",
"prototype.polluted",
];
for (const path of attempts) {
const result = setConfigOverride(path, true);
expect(result.ok).toBe(false);
expect(Object.keys(getConfigOverrides()).length).toBe(0);
}
});
});

View File

@@ -115,6 +115,8 @@ const FIELD_LABELS: Record<string, string> = {
"agents.defaults.cliBackends": "CLI Backends",
"commands.native": "Native Commands",
"commands.text": "Text Commands",
"commands.config": "Allow /config",
"commands.debug": "Allow /debug",
"commands.restart": "Allow Restart",
"commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color",
@@ -203,6 +205,10 @@ const FIELD_HELP: Record<string, string> = {
"commands.native":
"Register native commands with connectors that support it (Discord/Slack/Telegram).",
"commands.text": "Allow text command parsing (slash commands only).",
"commands.config":
"Allow /config chat command to read/write config on disk (default: false).",
"commands.debug":
"Allow /debug chat command for runtime-only overrides (default: false).",
"commands.restart":
"Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups":

View File

@@ -1078,6 +1078,10 @@ export type CommandsConfig = {
native?: boolean;
/** Enable text command parsing (default: true). */
text?: boolean;
/** Allow /config command (default: false). */
config?: boolean;
/** Allow /debug command (default: false). */
debug?: boolean;
/** Allow restart commands/tools (default: false). */
restart?: boolean;
/** Enforce access-group allowlists/policies for commands (default: true). */

View File

@@ -685,6 +685,8 @@ const CommandsSchema = z
.object({
native: z.boolean().optional(),
text: z.boolean().optional(),
config: z.boolean().optional(),
debug: z.boolean().optional(),
restart: z.boolean().optional(),
useAccessGroups: z.boolean().optional(),
})

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",

View File

@@ -26,7 +26,7 @@ import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
import {
@@ -416,7 +416,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
throw new Error("Failed to resolve Discord application id");
}
const commandSpecs = nativeEnabled ? listNativeCommandSpecs() : [];
const commandSpecs = nativeEnabled
? listNativeCommandSpecsForConfig(cfg)
: [];
const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({
command: spec,
@@ -928,7 +930,7 @@ export function createDiscordMessageHandler(params: {
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(baseText);
hasControlCommand(baseText, cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
if (isGuildMessage && shouldRequireMention) {

View File

@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
GATEWAY_SYSTEMD_SERVICE_NAME,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
} from "../daemon/constants.js";
export type RestartAttempt = {
@@ -41,9 +41,11 @@ function formatSpawnDetail(result: {
return "unknown error";
}
function normalizeSystemdUnit(raw?: string): string {
function normalizeSystemdUnit(raw?: string, profile?: string): string {
const unit = raw?.trim();
if (!unit) return `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
if (!unit) {
return `${resolveGatewaySystemdServiceName(profile)}.service`;
}
return unit.endsWith(".service") ? unit : `${unit}.service`;
}
@@ -54,7 +56,10 @@ export function triggerClawdbotRestart(): RestartAttempt {
const tried: string[] = [];
if (process.platform !== "darwin") {
if (process.platform === "linux") {
const unit = normalizeSystemdUnit(process.env.CLAWDBOT_SYSTEMD_UNIT);
const unit = normalizeSystemdUnit(
process.env.CLAWDBOT_SYSTEMD_UNIT,
process.env.CLAWDBOT_PROFILE,
);
const userArgs = ["--user", "restart", unit];
tried.push(`systemctl ${userArgs.join(" ")}`);
const userRestart = spawnSync("systemctl", userArgs, {
@@ -87,7 +92,8 @@ export function triggerClawdbotRestart(): RestartAttempt {
}
const label =
process.env.CLAWDBOT_LAUNCHD_LABEL || GATEWAY_LAUNCH_AGENT_LABEL;
process.env.CLAWDBOT_LAUNCHD_LABEL ||
resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE);
const uid =
typeof process.getuid === "function" ? process.getuid() : undefined;
const target = uid !== undefined ? `gui/${uid}/${label}` : label;

View File

@@ -16,7 +16,7 @@ import {
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
import {
@@ -891,7 +891,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(message.text ?? "");
hasControlCommand(message.text ?? "", cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0;
if (
@@ -1945,7 +1945,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
};
const nativeCommands =
cfg.commands?.native === true ? listNativeCommandSpecs() : [];
cfg.commands?.native === true ? listNativeCommandSpecsForConfig(cfg) : [];
if (nativeCommands.length > 0) {
for (const command of nativeCommands) {
app.command(

View File

@@ -16,7 +16,7 @@ import {
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
} from "../auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { resolveTelegramDraftStreamingChunking } from "../auto-reply/reply/block-streaming.js";
@@ -557,7 +557,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "");
hasControlCommand(msg.text ?? msg.caption ?? "", cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
if (isGroup && requireMention && canDetectMention) {
@@ -907,7 +907,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}
};
const nativeCommands = nativeEnabled ? listNativeCommandSpecs() : [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg)
: [];
if (nativeCommands.length > 0) {
bot.api
.setMyCommands(

View File

@@ -52,7 +52,7 @@ import {
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -627,7 +627,9 @@ export async function runOnboardingWizard(
);
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
const loaded = await service.isLoaded({
profile: process.env.CLAWDBOT_PROFILE,
});
if (loaded) {
const action = (await prompter.select({
message: "Gateway service already installed",
@@ -638,7 +640,10 @@ export async function runOnboardingWizard(
],
})) as "restart" | "reinstall" | "skip";
if (action === "restart") {
await service.restart({ stdout: process.stdout });
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
stdout: process.stdout,
});
} else if (action === "reinstall") {
await service.uninstall({ env: process.env, stdout: process.stdout });
}
@@ -646,7 +651,9 @@ export async function runOnboardingWizard(
if (
!loaded ||
(loaded && (await service.isLoaded({ env: process.env })) === false)
(loaded &&
(await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })) ===
false)
) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
@@ -668,7 +675,7 @@ export async function runOnboardingWizard(
token: gatewayToken,
launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
await service.install({