feat: add exec host routing + node daemon

This commit is contained in:
Peter Steinberger
2026-01-18 07:44:28 +00:00
parent 49bd2d96fa
commit ae0b4c4990
38 changed files with 2370 additions and 117 deletions

View File

@@ -4,6 +4,12 @@ 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 NODE_LAUNCH_AGENT_LABEL = "com.clawdbot.node";
export const NODE_SYSTEMD_SERVICE_NAME = "clawdbot-node";
export const NODE_WINDOWS_TASK_NAME = "Clawdbot Node";
export const NODE_SERVICE_MARKER = "clawdbot";
export const NODE_SERVICE_KIND = "node";
export const NODE_WINDOWS_TASK_SCRIPT_NAME = "node.cmd";
export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gateway"];
export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = [];
export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = [];
@@ -51,3 +57,21 @@ export function formatGatewayServiceDescription(params?: {
if (parts.length === 0) return "Clawdbot Gateway";
return `Clawdbot Gateway (${parts.join(", ")})`;
}
export function resolveNodeLaunchAgentLabel(): string {
return NODE_LAUNCH_AGENT_LABEL;
}
export function resolveNodeSystemdServiceName(): string {
return NODE_SYSTEMD_SERVICE_NAME;
}
export function resolveNodeWindowsTaskName(): string {
return NODE_WINDOWS_TASK_NAME;
}
export function formatNodeServiceDescription(params?: { version?: string }): string {
const version = params?.version?.trim();
if (!version) return "Clawdbot Node Host";
return `Clawdbot Node Host (v${version})`;
}

View File

@@ -52,10 +52,11 @@ export function resolveGatewayLogPaths(env: Record<string, string | undefined>):
} {
const stateDir = resolveGatewayStateDir(env);
const logDir = path.join(stateDir, "logs");
const prefix = env.CLAWDBOT_LOG_PREFIX?.trim() || "gateway";
return {
logDir,
stdoutPath: path.join(logDir, "gateway.log"),
stderrPath: path.join(logDir, "gateway.err.log"),
stdoutPath: path.join(logDir, `${prefix}.log`),
stderrPath: path.join(logDir, `${prefix}.err.log`),
};
}
@@ -340,12 +341,14 @@ export async function installLaunchAgent({
programArguments,
workingDirectory,
environment,
description,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
description?: string;
}): Promise<{ plistPath: string }> {
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
await fs.mkdir(logDir, { recursive: true });
@@ -366,13 +369,15 @@ export async function installLaunchAgent({
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 serviceDescription =
description ??
formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const plist = buildLaunchAgentPlist({
label,
comment: description,
comment: serviceDescription,
programArguments,
workingDirectory,
stdoutPath,

View File

@@ -0,0 +1,66 @@
import type { GatewayService, GatewayServiceInstallArgs } from "./service.js";
import { resolveGatewayService } from "./service.js";
import {
NODE_SERVICE_KIND,
NODE_SERVICE_MARKER,
NODE_WINDOWS_TASK_SCRIPT_NAME,
resolveNodeLaunchAgentLabel,
resolveNodeSystemdServiceName,
resolveNodeWindowsTaskName,
} from "./constants.js";
function withNodeServiceEnv(
env: Record<string, string | undefined>,
): Record<string, string | undefined> {
return {
...env,
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
CLAWDBOT_LOG_PREFIX: "node",
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
};
}
function withNodeInstallEnv(args: GatewayServiceInstallArgs): GatewayServiceInstallArgs {
return {
...args,
env: withNodeServiceEnv(args.env),
environment: {
...args.environment,
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
CLAWDBOT_LOG_PREFIX: "node",
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
},
};
}
export function resolveNodeService(): GatewayService {
const base = resolveGatewayService();
return {
...base,
install: async (args) => {
return base.install(withNodeInstallEnv(args));
},
uninstall: async (args) => {
return base.uninstall({ ...args, env: withNodeServiceEnv(args.env) });
},
stop: async (args) => {
return base.stop({ ...args, env: withNodeServiceEnv(args.env ?? {}) });
},
restart: async (args) => {
return base.restart({ ...args, env: withNodeServiceEnv(args.env ?? {}) });
},
isLoaded: async (args) => {
return base.isLoaded({ env: withNodeServiceEnv(args.env ?? {}) });
},
readCommand: (env) => base.readCommand(withNodeServiceEnv(env)),
readRuntime: (env) => base.readRuntime(withNodeServiceEnv(env)),
};
}

View File

@@ -138,13 +138,12 @@ async function resolveBinaryPath(binary: string): Promise<string> {
}
}
export async function resolveGatewayProgramArguments(params: {
port: number;
async function resolveCliProgramArguments(params: {
args: string[];
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway", "--port", String(params.port)];
const execPath = process.execPath;
const runtime = params.runtime ?? "auto";
@@ -153,7 +152,7 @@ export async function resolveGatewayProgramArguments(params: {
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
programArguments: [nodePath, cliEntrypointPath, ...params.args],
};
}
@@ -164,7 +163,7 @@ export async function resolveGatewayProgramArguments(params: {
await fs.access(devCliPath);
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
return {
programArguments: [bunPath, devCliPath, ...gatewayArgs],
programArguments: [bunPath, devCliPath, ...params.args],
workingDirectory: repoRoot,
};
}
@@ -172,7 +171,7 @@ export async function resolveGatewayProgramArguments(params: {
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [bunPath, cliEntrypointPath, ...gatewayArgs],
programArguments: [bunPath, cliEntrypointPath, ...params.args],
};
}
@@ -180,12 +179,12 @@ export async function resolveGatewayProgramArguments(params: {
try {
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [execPath, cliEntrypointPath, ...gatewayArgs],
programArguments: [execPath, cliEntrypointPath, ...params.args],
};
} catch (error) {
// If running under bun or another runtime that can execute TS directly
if (!isNodeRuntime(execPath)) {
return { programArguments: [execPath, ...gatewayArgs] };
return { programArguments: [execPath, ...params.args] };
}
throw error;
}
@@ -199,7 +198,7 @@ export async function resolveGatewayProgramArguments(params: {
// If already running under bun, use current execPath
if (isBunRuntime(execPath)) {
return {
programArguments: [execPath, devCliPath, ...gatewayArgs],
programArguments: [execPath, devCliPath, ...params.args],
workingDirectory: repoRoot,
};
}
@@ -207,7 +206,46 @@ export async function resolveGatewayProgramArguments(params: {
// Otherwise resolve bun from PATH
const bunPath = await resolveBunPath();
return {
programArguments: [bunPath, devCliPath, ...gatewayArgs],
programArguments: [bunPath, devCliPath, ...params.args],
workingDirectory: repoRoot,
};
}
export async function resolveGatewayProgramArguments(params: {
port: number;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway", "--port", String(params.port)];
return resolveCliProgramArguments({
args: gatewayArgs,
dev: params.dev,
runtime: params.runtime,
nodePath: params.nodePath,
});
}
export async function resolveNodeProgramArguments(params: {
host: string;
port: number;
tls?: boolean;
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId);
if (params.displayName) args.push("--display-name", params.displayName);
return resolveCliProgramArguments({
args,
dev: params.dev,
runtime: params.runtime,
nodePath: params.nodePath,
});
}

View File

@@ -16,9 +16,18 @@ const formatLine = (label: string, value: string) => {
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveTaskName(env: Record<string, string | undefined>): string {
const override = env.CLAWDBOT_WINDOWS_TASK_NAME?.trim();
if (override) return override;
return resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
}
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
const override = env.CLAWDBOT_TASK_SCRIPT?.trim();
if (override) return override;
const scriptName = env.CLAWDBOT_TASK_SCRIPT_NAME?.trim() || "gateway.cmd";
const stateDir = resolveGatewayStateDir(env);
return path.join(stateDir, "gateway.cmd");
return path.join(stateDir, scriptName);
}
function quoteCmdArg(value: string): string {
@@ -201,29 +210,33 @@ export async function installScheduledTask({
programArguments,
workingDirectory,
environment,
description,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
description?: string;
}): Promise<{ scriptPath: string }> {
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 taskDescription =
description ??
formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const script = buildTaskScript({
description,
description: taskDescription,
programArguments,
workingDirectory,
environment,
});
await fs.writeFile(scriptPath, script, "utf8");
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
const taskName = resolveTaskName(env);
const quotedScript = quoteCmdArg(scriptPath);
const baseArgs = [
"/Create",
@@ -268,7 +281,7 @@ export async function uninstallScheduledTask({
stdout: NodeJS.WritableStream;
}): Promise<void> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
const taskName = resolveTaskName(env);
await execSchtasks(["/Delete", "/F", "/TN", taskName]);
const scriptPath = resolveTaskScriptPath(env);
@@ -293,7 +306,7 @@ export async function stopScheduledTask({
env?: Record<string, string | undefined>;
}): Promise<void> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>));
const res = await execSchtasks(["/End", "/TN", taskName]);
if (res.code !== 0 && !isTaskNotRunning(res)) {
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
@@ -309,7 +322,7 @@ export async function restartScheduledTask({
env?: Record<string, string | undefined>;
}): Promise<void> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
const taskName = resolveTaskName(env ?? (process.env as Record<string, string | undefined>));
await execSchtasks(["/End", "/TN", taskName]);
const res = await execSchtasks(["/Run", "/TN", taskName]);
if (res.code !== 0) {
@@ -322,7 +335,7 @@ export async function isScheduledTaskInstalled(args: {
env?: Record<string, string | undefined>;
}): Promise<boolean> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(args.env?.CLAWDBOT_PROFILE);
const taskName = resolveTaskName(args.env ?? (process.env as Record<string, string | undefined>));
const res = await execSchtasks(["/Query", "/TN", taskName]);
return res.code === 0;
}
@@ -338,7 +351,7 @@ export async function readScheduledTaskRuntime(
detail: String(err),
};
}
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
const taskName = resolveTaskName(env);
const res = await execSchtasks(["/Query", "/TN", taskName, "/V", "/FO", "LIST"]);
if (res.code !== 0) {
const detail = (res.stderr || res.stdout).trim();

View File

@@ -6,6 +6,12 @@ import {
GATEWAY_SERVICE_MARKER,
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
NODE_SERVICE_KIND,
NODE_SERVICE_MARKER,
NODE_WINDOWS_TASK_SCRIPT_NAME,
resolveNodeLaunchAgentLabel,
resolveNodeSystemdServiceName,
resolveNodeWindowsTaskName,
} from "./constants.js";
export type MinimalServicePathOptions = {
@@ -82,3 +88,22 @@ export function buildServiceEnvironment(params: {
CLAWDBOT_SERVICE_VERSION: VERSION,
};
}
export function buildNodeServiceEnvironment(params: {
env: Record<string, string | undefined>;
}): Record<string, string | undefined> {
const { env } = params;
return {
PATH: buildMinimalServicePath({ env }),
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(),
CLAWDBOT_SYSTEMD_UNIT: resolveNodeSystemdServiceName(),
CLAWDBOT_WINDOWS_TASK_NAME: resolveNodeWindowsTaskName(),
CLAWDBOT_TASK_SCRIPT_NAME: NODE_WINDOWS_TASK_SCRIPT_NAME,
CLAWDBOT_LOG_PREFIX: "node",
CLAWDBOT_SERVICE_MARKER: NODE_SERVICE_MARKER,
CLAWDBOT_SERVICE_KIND: NODE_SERVICE_KIND,
CLAWDBOT_SERVICE_VERSION: VERSION,
};
}

View File

@@ -33,6 +33,7 @@ export type GatewayServiceInstallArgs = {
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
description?: string;
};
export type GatewayService = {

View File

@@ -186,23 +186,27 @@ export async function installSystemdService({
programArguments,
workingDirectory,
environment,
description,
}: {
env: Record<string, string | undefined>;
stdout: NodeJS.WritableStream;
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string | undefined>;
description?: string;
}): Promise<{ unitPath: string }> {
await assertSystemdAvailable();
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 serviceDescription =
description ??
formatGatewayServiceDescription({
profile: env.CLAWDBOT_PROFILE,
version: environment?.CLAWDBOT_SERVICE_VERSION ?? env.CLAWDBOT_SERVICE_VERSION,
});
const unit = buildSystemdUnit({
description,
description: serviceDescription,
programArguments,
workingDirectory,
environment,