feat: add exec host routing + node daemon
This commit is contained in:
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
66
src/daemon/node-service.ts
Normal file
66
src/daemon/node-service.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type GatewayServiceInstallArgs = {
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
environment?: Record<string, string | undefined>;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type GatewayService = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user