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

577
src/cli/node-cli/daemon.ts Normal file
View File

@@ -0,0 +1,577 @@
import { buildNodeInstallPlan } from "../../commands/node-daemon-install-helpers.js";
import {
DEFAULT_NODE_DAEMON_RUNTIME,
isNodeDaemonRuntime,
} from "../../commands/node-daemon-runtime.js";
import {
resolveNodeLaunchAgentLabel,
resolveNodeSystemdServiceName,
resolveNodeWindowsTaskName,
} from "../../daemon/constants.js";
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
import { resolveNodeService } from "../../daemon/node-service.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { resolveIsNixMode } from "../../config/paths.js";
import { isWSL } from "../../infra/wsl.js";
import { loadNodeHostConfig } from "../../node-host/config.js";
import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import {
buildDaemonServiceSnapshot,
createNullWriter,
emitDaemonActionJson,
} from "../daemon-cli/response.js";
import { formatRuntimeStatus, parsePort } from "../daemon-cli/shared.js";
type NodeDaemonInstallOptions = {
host?: string;
port?: string | number;
tls?: boolean;
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
runtime?: string;
force?: boolean;
json?: boolean;
};
type NodeDaemonLifecycleOptions = {
json?: boolean;
};
type NodeDaemonStatusOptions = {
json?: boolean;
};
function renderNodeServiceStartHints(): string[] {
const base = ["clawdbot node daemon install", "clawdbot node start"];
switch (process.platform) {
case "darwin":
return [
...base,
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${resolveNodeLaunchAgentLabel()}.plist`,
];
case "linux":
return [...base, `systemctl --user start ${resolveNodeSystemdServiceName()}.service`];
case "win32":
return [...base, `schtasks /Run /TN "${resolveNodeWindowsTaskName()}"`];
default:
return base;
}
}
function buildNodeRuntimeHints(env: NodeJS.ProcessEnv = process.env): string[] {
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
return [
`Launchd stdout (if installed): ${logs.stdoutPath}`,
`Launchd stderr (if installed): ${logs.stderrPath}`,
];
}
if (process.platform === "linux") {
const unit = resolveNodeSystemdServiceName();
return [`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`];
}
if (process.platform === "win32") {
const task = resolveNodeWindowsTaskName();
return [`Logs: schtasks /Query /TN "${task}" /V /FO LIST`];
}
return [];
}
function resolveNodeDefaults(opts: NodeDaemonInstallOptions, config: Awaited<ReturnType<typeof loadNodeHostConfig>>) {
const host = opts.host?.trim() || config?.gateway?.host || "127.0.0.1";
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
return { host, port: null };
}
const port = portOverride ?? config?.gateway?.port ?? 18790;
return { host, port };
}
export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
const json = Boolean(opts.json);
const warnings: string[] = [];
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
hints?: string[];
warnings?: string[];
}) => {
if (!json) return;
emitDaemonActionJson({ action: "install", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) {
emit({
ok: false,
error: message,
hints,
warnings: warnings.length ? warnings : undefined,
});
} else {
defaultRuntime.error(message);
if (hints?.length) {
for (const hint of hints) defaultRuntime.log(`Tip: ${hint}`);
}
}
defaultRuntime.exit(1);
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon install is disabled.");
return;
}
const config = await loadNodeHostConfig();
const { host, port } = resolveNodeDefaults(opts, config);
if (!Number.isFinite(port ?? NaN) || (port ?? 0) <= 0) {
fail("Invalid port");
return;
}
const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_NODE_DAEMON_RUNTIME;
if (!isNodeDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")');
return;
}
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (loaded && !opts.force) {
emit({
ok: true,
result: "already-installed",
message: `Node service already ${service.loadedText}.`,
service: buildDaemonServiceSnapshot(service, loaded),
warnings: warnings.length ? warnings : undefined,
});
if (!json) {
defaultRuntime.log(`Node service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
}
return;
}
const tlsFingerprint = opts.tlsFingerprint?.trim() || config?.gateway?.tlsFingerprint;
const tls =
Boolean(opts.tls) ||
Boolean(tlsFingerprint) ||
Boolean(config?.gateway?.tls);
const { programArguments, workingDirectory, environment, description } =
await buildNodeInstallPlan({
env: process.env,
host,
port: port ?? 18790,
tls,
tlsFingerprint: tlsFingerprint || undefined,
nodeId: opts.nodeId,
displayName: opts.displayName,
runtime: runtimeRaw,
warn: (message) => {
if (json) warnings.push(message);
else defaultRuntime.log(message);
},
});
try {
await service.install({
env: process.env,
stdout,
programArguments,
workingDirectory,
environment,
description,
});
} catch (err) {
fail(`Node install failed: ${String(err)}`);
return;
}
let installed = true;
try {
installed = await service.isLoaded({ env: process.env });
} catch {
installed = true;
}
emit({
ok: true,
result: "installed",
service: buildDaemonServiceSnapshot(service, installed),
warnings: warnings.length ? warnings : undefined,
});
}
export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "uninstall", ...payload });
};
const fail = (message: string) => {
if (json) emit({ ok: false, error: message });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon uninstall is disabled.");
return;
}
const service = resolveNodeService();
try {
await service.uninstall({ env: process.env, stdout });
} catch (err) {
fail(`Node uninstall failed: ${String(err)}`);
return;
}
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}
emit({
ok: true,
result: "uninstalled",
service: buildDaemonServiceSnapshot(service, loaded),
});
}
export async function runNodeDaemonStart(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
hints?: string[];
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "start", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) emit({ ok: false, error: message, hints });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (!loaded) {
let hints = renderNodeServiceStartHints();
if (process.platform === "linux") {
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
if (!systemdAvailable) {
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
}
}
emit({
ok: true,
result: "not-loaded",
message: `Node service ${service.notLoadedText}.`,
hints,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
for (const hint of hints) {
defaultRuntime.log(`Start with: ${hint}`);
}
}
return;
}
try {
await service.restart({ env: process.env, stdout });
} catch (err) {
const hints = renderNodeServiceStartHints();
fail(`Node start failed: ${String(err)}`, hints);
return;
}
let started = true;
try {
started = await service.isLoaded({ env: process.env });
} catch {
started = true;
}
emit({
ok: true,
result: "started",
service: buildDaemonServiceSnapshot(service, started),
});
}
export async function runNodeDaemonRestart(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
hints?: string[];
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "restart", ...payload });
};
const fail = (message: string, hints?: string[]) => {
if (json) emit({ ok: false, error: message, hints });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (!loaded) {
let hints = renderNodeServiceStartHints();
if (process.platform === "linux") {
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
if (!systemdAvailable) {
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
}
}
emit({
ok: true,
result: "not-loaded",
message: `Node service ${service.notLoadedText}.`,
hints,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
for (const hint of hints) {
defaultRuntime.log(`Start with: ${hint}`);
}
}
return;
}
try {
await service.restart({ env: process.env, stdout });
} catch (err) {
const hints = renderNodeServiceStartHints();
fail(`Node restart failed: ${String(err)}`, hints);
return;
}
let restarted = true;
try {
restarted = await service.isLoaded({ env: process.env });
} catch {
restarted = true;
}
emit({
ok: true,
result: "restarted",
service: buildDaemonServiceSnapshot(service, restarted),
});
}
export async function runNodeDaemonStop(opts: NodeDaemonLifecycleOptions = {}) {
const json = Boolean(opts.json);
const stdout = json ? createNullWriter() : process.stdout;
const emit = (payload: {
ok: boolean;
result?: string;
message?: string;
error?: string;
service?: {
label: string;
loaded: boolean;
loadedText: string;
notLoadedText: string;
};
}) => {
if (!json) return;
emitDaemonActionJson({ action: "stop", ...payload });
};
const fail = (message: string) => {
if (json) emit({ ok: false, error: message });
else defaultRuntime.error(message);
defaultRuntime.exit(1);
};
const service = resolveNodeService();
let loaded = false;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
fail(`Node service check failed: ${String(err)}`);
return;
}
if (!loaded) {
emit({
ok: true,
result: "not-loaded",
message: `Node service ${service.notLoadedText}.`,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
}
return;
}
try {
await service.stop({ env: process.env, stdout });
} catch (err) {
fail(`Node stop failed: ${String(err)}`);
return;
}
let stopped = false;
try {
stopped = await service.isLoaded({ env: process.env });
} catch {
stopped = false;
}
emit({
ok: true,
result: "stopped",
service: buildDaemonServiceSnapshot(service, stopped),
});
}
export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
const json = Boolean(opts.json);
const service = resolveNodeService();
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch((err) => ({ status: "unknown", detail: String(err) })),
]);
const payload = {
service: {
...buildDaemonServiceSnapshot(service, loaded),
command,
runtime,
},
};
if (json) {
defaultRuntime.log(JSON.stringify(payload, null, 2));
return;
}
const rich = isRich();
const label = (value: string) => colorize(rich, theme.muted, value);
const accent = (value: string) => colorize(rich, theme.accent, value);
const infoText = (value: string) => colorize(rich, theme.info, value);
const okText = (value: string) => colorize(rich, theme.success, value);
const warnText = (value: string) => colorize(rich, theme.warn, value);
const errorText = (value: string) => colorize(rich, theme.error, value);
const serviceStatus = loaded ? okText(service.loadedText) : warnText(service.notLoadedText);
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
if (command?.programArguments?.length) {
defaultRuntime.log(`${label("Command:")} ${infoText(command.programArguments.join(" "))}`);
}
if (command?.sourcePath) {
defaultRuntime.log(`${label("Service file:")} ${infoText(command.sourcePath)}`);
}
if (command?.workingDirectory) {
defaultRuntime.log(`${label("Working dir:")} ${infoText(command.workingDirectory)}`);
}
const runtimeLine = formatRuntimeStatus(runtime);
if (runtimeLine) {
const runtimeStatus = runtime?.status ?? "unknown";
const runtimeColor =
runtimeStatus === "running"
? theme.success
: runtimeStatus === "stopped"
? theme.error
: runtimeStatus === "unknown"
? theme.muted
: theme.warn;
defaultRuntime.log(`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`);
}
if (!loaded) {
defaultRuntime.log("");
for (const hint of renderNodeServiceStartHints()) {
defaultRuntime.log(`${warnText("Start with:")} ${infoText(hint)}`);
}
return;
}
const baseEnv = {
...(process.env as Record<string, string | undefined>),
...(command?.environment ?? undefined),
};
const hintEnv = {
...baseEnv,
CLAWDBOT_LOG_PREFIX: baseEnv.CLAWDBOT_LOG_PREFIX ?? "node",
} as NodeJS.ProcessEnv;
if (runtime?.missingUnit) {
defaultRuntime.error(errorText("Service unit not found."));
for (const hint of buildNodeRuntimeHints(hintEnv)) {
defaultRuntime.error(errorText(hint));
}
return;
}
if (runtime?.status === "stopped") {
defaultRuntime.error(errorText("Service is loaded but not running."));
for (const hint of buildNodeRuntimeHints(hintEnv)) {
defaultRuntime.error(errorText(hint));
}
}
}

View File

@@ -0,0 +1,116 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { loadNodeHostConfig } from "../../node-host/config.js";
import { runNodeHost } from "../../node-host/runner.js";
import {
runNodeDaemonInstall,
runNodeDaemonRestart,
runNodeDaemonStart,
runNodeDaemonStatus,
runNodeDaemonStop,
runNodeDaemonUninstall,
} from "./daemon.js";
import { parsePort } from "../daemon-cli/shared.js";
function parsePortWithFallback(value: unknown, fallback: number): number {
const parsed = parsePort(value);
return parsed ?? fallback;
}
export function registerNodeCli(program: Command) {
const node = program
.command("node")
.description("Run a headless node host (system.run/system.which)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.clawd.bot/cli/node")}\n`,
);
node
.command("start")
.description("Start the headless node host (foreground)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.action(async (opts) => {
const existing = await loadNodeHostConfig();
const host =
(opts.host as string | undefined)?.trim() ||
existing?.gateway?.host ||
"127.0.0.1";
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18790);
await runNodeHost({
gatewayHost: host,
gatewayPort: port,
gatewayTls: Boolean(opts.tls) || Boolean(opts.tlsFingerprint),
gatewayTlsFingerprint: opts.tlsFingerprint,
nodeId: opts.nodeId,
displayName: opts.displayName,
});
});
const daemon = node
.command("daemon")
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
daemon
.command("status")
.description("Show node daemon status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
daemon
.command("install")
.description("Install the node daemon service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
daemon
.command("uninstall")
.description("Uninstall the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
daemon
.command("start")
.description("Start the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
daemon
.command("stop")
.description("Stop the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
daemon
.command("restart")
.description("Restart the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
}