From daf471c45087fc43aab67d97d9f3737ca930dd81 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Thu, 15 Jan 2026 11:34:27 +0100 Subject: [PATCH 1/3] fix: unify daemon service label resolution with env --- src/cli/daemon-cli/install.ts | 7 +- src/cli/daemon-cli/lifecycle.ts | 15 +- src/cli/daemon-cli/status.gather.ts | 2 +- src/cli/daemon-cli/status.print.ts | 4 +- src/cli/gateway-cli/shared.ts | 2 +- src/commands/configure.daemon.ts | 4 +- src/commands/doctor-gateway-daemon-flow.ts | 6 +- src/commands/doctor-gateway-services.ts | 6 +- src/commands/doctor.ts | 2 +- src/commands/gateway-status.ts | 2 +- src/commands/reset.ts | 5 +- src/commands/status-all.ts | 2 +- src/commands/status.daemon.ts | 2 +- src/commands/uninstall.ts | 5 +- src/daemon/constants.test.ts | 10 + src/daemon/launchd.test.ts | 78 ++++++- src/daemon/launchd.ts | 22 +- src/daemon/schtasks.test.ts | 224 ++++++++++++++++++++- src/daemon/schtasks.ts | 20 +- src/daemon/service.ts | 21 +- src/daemon/systemd.test.ts | 77 ++++++- src/daemon/systemd.ts | 21 +- src/infra/ports-format.ts | 4 +- src/wizard/onboarding.finalize.ts | 9 +- 24 files changed, 450 insertions(+), 100 deletions(-) diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 1d4957c32..04e497fab 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -46,10 +46,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { } const service = resolveGatewayService(); - const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -85,7 +84,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { port, token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN, launchdLabel: - process.platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined, + process.platform === "darwin" + ? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE) + : undefined, }); try { diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index bbe1ed9c4..39ae3267e 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -21,10 +21,9 @@ 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({ profile }); + loaded = await service.isLoaded({ env: process.env }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -38,7 +37,7 @@ export async function runDaemonStart() { return; } try { - await service.restart({ profile, stdout: process.stdout }); + await service.restart({ env: process.env, stdout: process.stdout }); } catch (err) { defaultRuntime.error(`Gateway start failed: ${String(err)}`); for (const hint of renderGatewayServiceStartHints()) { @@ -50,10 +49,9 @@ 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({ profile }); + loaded = await service.isLoaded({ env: process.env }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -64,7 +62,7 @@ export async function runDaemonStop() { return; } try { - await service.stop({ profile, stdout: process.stdout }); + await service.stop({ env: process.env, stdout: process.stdout }); } catch (err) { defaultRuntime.error(`Gateway stop failed: ${String(err)}`); defaultRuntime.exit(1); @@ -78,10 +76,9 @@ export async function runDaemonStop() { */ export async function runDaemonRestart(): Promise { const service = resolveGatewayService(); - const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -95,7 +92,7 @@ export async function runDaemonRestart(): Promise { return false; } try { - await service.restart({ profile, stdout: process.stdout }); + await service.restart({ env: process.env, stdout: process.stdout }); return true; } catch (err) { defaultRuntime.error(`Gateway restart failed: ${String(err)}`); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index ad5223bed..73567f583 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -112,7 +112,7 @@ export async function gatherDaemonStatus( ): Promise { const service = resolveGatewayService(); const [loaded, command, runtime] = await Promise.all([ - service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false), + service.isLoaded({ env: process.env }).catch(() => false), service.readCommand(process.env).catch(() => null), service.readRuntime(process.env).catch(() => undefined), ]); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index d5d3a8969..687b9a9ad 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -262,12 +262,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) if (legacyServices.length > 0 || extraServices.length > 0) { defaultRuntime.error( errorText( - "Recommendation: run a single gateway per machine. One gateway supports multiple agents.", + "Recommendation: run a single gateway per machine for most setups. One gateway supports multiple agents (see docs: /gateway#multiple-gateways-same-host).", ), ); defaultRuntime.error( errorText( - "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", + "If you need multiple gateways (e.g., a recovery bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", ), ); spacer(); diff --git a/src/cli/gateway-cli/shared.ts b/src/cli/gateway-cli/shared.ts index 3af93d337..9694e1cde 100644 --- a/src/cli/gateway-cli/shared.ts +++ b/src/cli/gateway-cli/shared.ts @@ -89,7 +89,7 @@ export async function maybeExplainGatewayServiceStop() { const service = resolveGatewayService(); let loaded: boolean | null = null; try { - loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); + loaded = await service.isLoaded({ env: process.env }); } catch { loaded = null; } diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index a06e533c6..465ee3731 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -27,7 +27,7 @@ export async function maybeInstallDaemon(params: { daemonRuntime?: GatewayDaemonRuntime; }) { const service = resolveGatewayService(); - const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); + const loaded = await service.isLoaded({ env: process.env }); let shouldCheckLinger = false; let shouldInstall = true; let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME; @@ -49,7 +49,7 @@ export async function maybeInstallDaemon(params: { async (progress) => { progress.setLabel("Restarting Gateway daemon…"); await service.restart({ - profile: process.env.CLAWDBOT_PROFILE, + env: process.env, stdout: process.stdout, }); progress.setLabel("Gateway daemon restarted."); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 7a6186054..b898a7cca 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -37,7 +37,7 @@ export async function maybeRepairGatewayDaemon(params: { if (params.healthOk) return; const service = resolveGatewayService(); - const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); + const loaded = await service.isLoaded({ env: process.env }); let serviceRuntime: Awaited> | undefined; if (loaded) { serviceRuntime = await service.readRuntime(process.env).catch(() => undefined); @@ -129,7 +129,7 @@ export async function maybeRepairGatewayDaemon(params: { }); if (start) { await service.restart({ - profile: process.env.CLAWDBOT_PROFILE, + env: process.env, stdout: process.stdout, }); await sleep(1500); @@ -151,7 +151,7 @@ export async function maybeRepairGatewayDaemon(params: { }); if (restart) { await service.restart({ - profile: process.env.CLAWDBOT_PROFILE, + env: process.env, stdout: process.stdout, }); await sleep(1500); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 6c206162f..bf9c93747 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -89,7 +89,7 @@ export async function maybeMigrateLegacyGatewayService( } const service = resolveGatewayService(); - const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); + const loaded = await service.isLoaded({ env: process.env }); if (loaded) { note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway"); return; @@ -280,9 +280,9 @@ export async function maybeScanExtraGatewayServices(options: DoctorOptions) { note( [ - "Recommendation: run a single gateway per machine.", + "Recommendation: run a single gateway per machine for most setups.", "One gateway supports multiple agents.", - "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", + "If you need multiple gateways (e.g., a recovery bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", ].join("\n"), "Gateway recommendation", ); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 53a6d3af9..31439e310 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -209,7 +209,7 @@ export async function doctorCommand( const service = resolveGatewayService(); let loaded = false; try { - loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); + loaded = await service.isLoaded({ env: process.env }); } catch { loaded = false; } diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index edfa92d83..a36347b67 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -183,7 +183,7 @@ export async function gatewayStatusCommand( warnings.push({ code: "multiple_gateways", message: - "Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.", + "Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a recovery bot (see docs: /gateway#multiple-gateways-same-host).", targetIds: reachable.map((p) => p.target.id), }); } diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 25e6bf428..c5aef6845 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -38,17 +38,16 @@ const selectStyled = (params: Parameters>[0]) => async function stopGatewayIfRunning(runtime: RuntimeEnv) { if (isNixMode) return; const service = resolveGatewayService(); - const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env }); } catch (err) { runtime.error(`Gateway service check failed: ${String(err)}`); return; } if (!loaded) return; try { - await service.stop({ profile, stdout: process.stdout }); + await service.stop({ env: process.env, stdout: process.stdout }); } catch (err) { runtime.error(`Gateway stop failed: ${String(err)}`); } diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 68610bccf..0cbee54db 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -133,7 +133,7 @@ export async function statusAllCommand( try { const service = resolveGatewayService(); const [loaded, runtimeInfo, command] = await Promise.all([ - service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false), + service.isLoaded({ env: process.env }).catch(() => false), service.readRuntime(process.env).catch(() => undefined), service.readCommand(process.env).catch(() => null), ]); diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts index 2fe3c6801..ecd9711c4 100644 --- a/src/commands/status.daemon.ts +++ b/src/commands/status.daemon.ts @@ -10,7 +10,7 @@ export async function getDaemonStatusSummary(): Promise<{ try { const service = resolveGatewayService(); const [loaded, runtime, command] = await Promise.all([ - service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false), + service.isLoaded({ env: process.env }).catch(() => false), service.readRuntime(process.env).catch(() => undefined), service.readCommand(process.env).catch(() => null), ]); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 33831c913..8cc1aff68 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -55,10 +55,9 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise { return false; } const service = resolveGatewayService(); - const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env }); } catch (err) { runtime.error(`Gateway service check failed: ${String(err)}`); return false; @@ -68,7 +67,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise { return true; } try { - await service.stop({ profile, stdout: process.stdout }); + await service.stop({ env: process.env, stdout: process.stdout }); } catch (err) { runtime.error(`Gateway stop failed: ${String(err)}`); } diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index 928f62ccc..a8fa6a24b 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -98,6 +98,11 @@ describe("resolveGatewaySystemdServiceName", () => { const result = resolveGatewaySystemdServiceName(""); expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME); }); + + it("returns default service name for whitespace-only profile", () => { + const result = resolveGatewaySystemdServiceName(" "); + expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME); + }); }); describe("resolveGatewayWindowsTaskName", () => { @@ -141,6 +146,11 @@ describe("resolveGatewayWindowsTaskName", () => { const result = resolveGatewayWindowsTaskName(""); expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME); }); + + it("returns default task name for whitespace-only profile", () => { + const result = resolveGatewayWindowsTaskName(" "); + expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME); + }); }); describe("formatGatewayServiceDescription", () => { diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index fec37a6fa..8d2c0d52b 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -5,7 +5,7 @@ import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; -import { installLaunchAgent, parseLaunchctlPrint } from "./launchd.js"; +import { installLaunchAgent, parseLaunchctlPrint, resolveLaunchAgentPlistPath } from "./launchd.js"; describe("launchd runtime parsing", () => { it("parses state, pid, and exit status", () => { @@ -108,3 +108,79 @@ describe("launchd install", () => { } }); }); + +describe("resolveLaunchAgentPlistPath", () => { + it("uses default label when CLAWDBOT_PROFILE is default", () => { + const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "default" }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + ); + }); + + it("uses default label when CLAWDBOT_PROFILE is unset", () => { + const env = { HOME: "/Users/test" }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + ); + }); + + it("uses profile-specific label when CLAWDBOT_PROFILE is set to a custom value", () => { + const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "jbphoenix" }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.clawdbot.jbphoenix.plist", + ); + }); + + it("prefers CLAWDBOT_LAUNCHD_LABEL over CLAWDBOT_PROFILE", () => { + const env = { + HOME: "/Users/test", + CLAWDBOT_PROFILE: "jbphoenix", + CLAWDBOT_LAUNCHD_LABEL: "com.custom.label", + }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.custom.label.plist", + ); + }); + + it("trims whitespace from CLAWDBOT_LAUNCHD_LABEL", () => { + const env = { + HOME: "/Users/test", + CLAWDBOT_LAUNCHD_LABEL: " com.custom.label ", + }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.custom.label.plist", + ); + }); + + it("ignores empty CLAWDBOT_LAUNCHD_LABEL and falls back to profile", () => { + const env = { + HOME: "/Users/test", + CLAWDBOT_PROFILE: "myprofile", + CLAWDBOT_LAUNCHD_LABEL: " ", + }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist", + ); + }); + + it("handles case-insensitive 'Default' profile", () => { + const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + ); + }); + + it("handles case-insensitive 'DEFAULT' profile", () => { + const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "DEFAULT" }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist", + ); + }); + + it("trims whitespace from CLAWDBOT_PROFILE", () => { + const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: " myprofile " }; + expect(resolveLaunchAgentPlistPath(env)).toBe( + "/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist", + ); + }); +}); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 492344a3a..36a9ffced 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -24,13 +24,10 @@ const formatLine = (label: string, value: string) => { return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; }; -function resolveLaunchAgentLabel(params?: { - env?: Record; - profile?: string; -}): string { - const envLabel = params?.env?.CLAWDBOT_LAUNCHD_LABEL?.trim(); +function resolveLaunchAgentLabel(args?: { env?: Record }): string { + const envLabel = args?.env?.CLAWDBOT_LAUNCHD_LABEL?.trim(); if (envLabel) return envLabel; - return resolveGatewayLaunchAgentLabel(params?.profile); + return resolveGatewayLaunchAgentLabel(args?.env?.CLAWDBOT_PROFILE); } function resolveHomeDir(env: Record): string { const home = env.HOME?.trim() || env.USERPROFILE?.trim(); @@ -181,12 +178,11 @@ export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo { return info; } -export async function isLaunchAgentLoaded(params?: { +export async function isLaunchAgentLoaded(args: { env?: Record; - profile?: string; }): Promise { const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel(params); + const label = resolveLaunchAgentLabel({ env: args.env }); const res = await execLaunchctl(["print", `${domain}/${label}`]); return res.code === 0; } @@ -343,14 +339,12 @@ function isLaunchctlNotLoaded(res: { stdout: string; stderr: string; code: numbe export async function stopLaunchAgent({ stdout, env, - profile, }: { stdout: NodeJS.WritableStream; env?: Record; - profile?: string; }): Promise { const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel({ env, profile }); + const label = resolveLaunchAgentLabel({ env }); const res = await execLaunchctl(["bootout", `${domain}/${label}`]); if (res.code !== 0 && !isLaunchctlNotLoaded(res)) { throw new Error(`launchctl bootout failed: ${res.stderr || res.stdout}`.trim()); @@ -425,14 +419,12 @@ export async function installLaunchAgent({ export async function restartLaunchAgent({ stdout, env, - profile, }: { stdout: NodeJS.WritableStream; env?: Record; - profile?: string; }): Promise { const domain = resolveGuiDomain(); - const label = resolveLaunchAgentLabel({ env, profile }); + const label = resolveLaunchAgentLabel({ env }); const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); if (res.code !== 0) { throw new Error(`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim()); diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index e1af12976..28556439d 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -1,6 +1,10 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + import { describe, expect, it } from "vitest"; -import { parseSchtasksQuery } from "./schtasks.js"; +import { parseSchtasksQuery, readScheduledTaskCommand, resolveTaskScriptPath } from "./schtasks.js"; describe("schtasks runtime parsing", () => { it("parses status and last run info", () => { @@ -16,4 +20,222 @@ describe("schtasks runtime parsing", () => { lastRunResult: "0x0", }); }); + + it("parses running status", () => { + const output = [ + "TaskName: \\Clawdbot Gateway", + "Status: Running", + "Last Run Time: 1/8/2026 1:23:45 AM", + "Last Run Result: 0x0", + ].join("\r\n"); + expect(parseSchtasksQuery(output)).toEqual({ + status: "Running", + lastRunTime: "1/8/2026 1:23:45 AM", + lastRunResult: "0x0", + }); + }); +}); + +describe("resolveTaskScriptPath", () => { + it("uses default path when CLAWDBOT_PROFILE is default", () => { + const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "default" }; + expect(resolveTaskScriptPath(env)).toBe( + path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"), + ); + }); + + it("uses default path when CLAWDBOT_PROFILE is unset", () => { + const env = { USERPROFILE: "C:\\Users\\test" }; + expect(resolveTaskScriptPath(env)).toBe( + path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"), + ); + }); + + it("uses profile-specific path when CLAWDBOT_PROFILE is set to a custom value", () => { + const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "jbphoenix" }; + expect(resolveTaskScriptPath(env)).toBe( + path.join("C:\\Users\\test", ".clawdbot-jbphoenix", "gateway.cmd"), + ); + }); + + it("handles case-insensitive 'Default' profile", () => { + const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "Default" }; + expect(resolveTaskScriptPath(env)).toBe( + path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"), + ); + }); + + it("handles case-insensitive 'DEFAULT' profile", () => { + const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "DEFAULT" }; + expect(resolveTaskScriptPath(env)).toBe( + path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"), + ); + }); + + it("trims whitespace from CLAWDBOT_PROFILE", () => { + const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: " myprofile " }; + expect(resolveTaskScriptPath(env)).toBe( + path.join("C:\\Users\\test", ".clawdbot-myprofile", "gateway.cmd"), + ); + }); + + it("falls back to HOME when USERPROFILE is not set", () => { + const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "default" }; + expect(resolveTaskScriptPath(env)).toBe(path.join("/home/test", ".clawdbot", "gateway.cmd")); + }); +}); + +describe("readScheduledTaskCommand", () => { + it("parses basic command script", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-")); + try { + const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile( + scriptPath, + ["@echo off", "node gateway.js --port 18789"].join("\r\n"), + "utf8", + ); + + const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" }; + const result = await readScheduledTaskCommand(env); + expect(result).toEqual({ + programArguments: ["node", "gateway.js", "--port", "18789"], + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("parses script with working directory", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-")); + try { + const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile( + scriptPath, + ["@echo off", "cd /d C:\\Projects\\clawdbot", "node gateway.js"].join("\r\n"), + "utf8", + ); + + const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" }; + const result = await readScheduledTaskCommand(env); + expect(result).toEqual({ + programArguments: ["node", "gateway.js"], + workingDirectory: "C:\\Projects\\clawdbot", + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("parses script with environment variables", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-")); + try { + const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile( + scriptPath, + ["@echo off", "set NODE_ENV=production", "set PORT=18789", "node gateway.js"].join("\r\n"), + "utf8", + ); + + const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" }; + const result = await readScheduledTaskCommand(env); + expect(result).toEqual({ + programArguments: ["node", "gateway.js"], + environment: { + NODE_ENV: "production", + PORT: "18789", + }, + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("parses script with quoted arguments containing spaces", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-")); + try { + const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + // Use forward slashes which work in Windows cmd and avoid escape parsing issues + await fs.writeFile( + scriptPath, + ["@echo off", '"C:/Program Files/Node/node.exe" gateway.js'].join("\r\n"), + "utf8", + ); + + const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" }; + const result = await readScheduledTaskCommand(env); + expect(result).toEqual({ + programArguments: ["C:/Program Files/Node/node.exe", "gateway.js"], + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns null when script does not exist", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-")); + try { + const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" }; + const result = await readScheduledTaskCommand(env); + expect(result).toBeNull(); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns null when script has no command", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-")); + try { + const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile( + scriptPath, + ["@echo off", "rem This is just a comment"].join("\r\n"), + "utf8", + ); + + const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" }; + const result = await readScheduledTaskCommand(env); + expect(result).toBeNull(); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("parses full script with all components", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-")); + try { + const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd"); + await fs.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.writeFile( + scriptPath, + [ + "@echo off", + "rem Clawdbot Gateway", + "cd /d C:\\Projects\\clawdbot", + "set NODE_ENV=production", + "set CLAWDBOT_PORT=18789", + "node gateway.js --verbose", + ].join("\r\n"), + "utf8", + ); + + const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" }; + const result = await readScheduledTaskCommand(env); + expect(result).toEqual({ + programArguments: ["node", "gateway.js", "--verbose"], + workingDirectory: "C:\\Projects\\clawdbot", + environment: { + NODE_ENV: "production", + CLAWDBOT_PORT: "18789", + }, + }); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index ea008b14b..1f749a812 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -21,7 +21,7 @@ function resolveHomeDir(env: Record): string { return home; } -function resolveTaskScriptPath(env: Record): string { +export function resolveTaskScriptPath(env: Record): string { const home = resolveHomeDir(env); const profile = env.CLAWDBOT_PROFILE?.trim(); const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : ""; @@ -274,13 +274,13 @@ function isTaskNotRunning(res: { stdout: string; stderr: string; code: number }) export async function stopScheduledTask({ stdout, - profile, + env, }: { stdout: NodeJS.WritableStream; - profile?: string; + env?: Record; }): Promise { await assertSchtasksAvailable(); - const taskName = resolveGatewayWindowsTaskName(profile); + const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_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()); @@ -290,13 +290,13 @@ export async function stopScheduledTask({ export async function restartScheduledTask({ stdout, - profile, + env, }: { stdout: NodeJS.WritableStream; - profile?: string; + env?: Record; }): Promise { await assertSchtasksAvailable(); - const taskName = resolveGatewayWindowsTaskName(profile); + const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE); await execSchtasks(["/End", "/TN", taskName]); const res = await execSchtasks(["/Run", "/TN", taskName]); if (res.code !== 0) { @@ -305,9 +305,11 @@ export async function restartScheduledTask({ stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`); } -export async function isScheduledTaskInstalled(profile?: string): Promise { +export async function isScheduledTaskInstalled(args: { + env?: Record; +}): Promise { await assertSchtasksAvailable(); - const taskName = resolveGatewayWindowsTaskName(profile); + const taskName = resolveGatewayWindowsTaskName(args.env?.CLAWDBOT_PROFILE); const res = await execSchtasks(["/Query", "/TN", taskName]); return res.code === 0; } diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 60a9c7358..8edfda83e 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -46,18 +46,13 @@ export type GatewayService = { }) => Promise; stop: (args: { env?: Record; - profile?: string; stdout: NodeJS.WritableStream; }) => Promise; restart: (args: { env?: Record; - profile?: string; stdout: NodeJS.WritableStream; }) => Promise; - isLoaded: (args: { - env?: Record; - profile?: string; - }) => Promise; + isLoaded: (args: { env?: Record }) => Promise; readCommand: (env: Record) => Promise<{ programArguments: string[]; workingDirectory?: string; @@ -82,18 +77,16 @@ export function resolveGatewayService(): GatewayService { stop: async (args) => { await stopLaunchAgent({ stdout: args.stdout, - profile: args.profile, env: args.env, }); }, restart: async (args) => { await restartLaunchAgent({ stdout: args.stdout, - profile: args.profile, env: args.env, }); }, - isLoaded: async (args) => isLaunchAgentLoaded({ profile: args.profile, env: args.env }), + isLoaded: async (args) => isLaunchAgentLoaded(args), readCommand: readLaunchAgentProgramArguments, readRuntime: readLaunchAgentRuntime, }; @@ -113,18 +106,16 @@ export function resolveGatewayService(): GatewayService { stop: async (args) => { await stopSystemdService({ stdout: args.stdout, - profile: args.profile, env: args.env, }); }, restart: async (args) => { await restartSystemdService({ stdout: args.stdout, - profile: args.profile, env: args.env, }); }, - isLoaded: async (args) => isSystemdServiceEnabled({ profile: args.profile, env: args.env }), + isLoaded: async (args) => isSystemdServiceEnabled(args), readCommand: readSystemdServiceExecStart, readRuntime: async (env) => await readSystemdServiceRuntime(env), }; @@ -144,16 +135,16 @@ export function resolveGatewayService(): GatewayService { stop: async (args) => { await stopScheduledTask({ stdout: args.stdout, - profile: args.profile, + env: args.env, }); }, restart: async (args) => { await restartScheduledTask({ stdout: args.stdout, - profile: args.profile, + env: args.env, }); }, - isLoaded: async (args) => isScheduledTaskInstalled(args.profile), + isLoaded: async (args) => isScheduledTaskInstalled(args), readCommand: readScheduledTaskCommand, readRuntime: async (env) => await readScheduledTaskRuntime(env), }; diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index b6fe145a1..e6240f981 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseSystemdShow } from "./systemd.js"; +import { parseSystemdShow, resolveSystemdUserUnitPath } from "./systemd.js"; describe("systemd runtime parsing", () => { it("parses active state details", () => { @@ -19,3 +19,78 @@ describe("systemd runtime parsing", () => { }); }); }); + +describe("resolveSystemdUserUnitPath", () => { + it("uses default service name when CLAWDBOT_PROFILE is default", () => { + const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "default" }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/clawdbot-gateway.service", + ); + }); + + it("uses default service name when CLAWDBOT_PROFILE is unset", () => { + const env = { HOME: "/home/test" }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/clawdbot-gateway.service", + ); + }); + + it("uses profile-specific service name when CLAWDBOT_PROFILE is set to a custom value", () => { + const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "jbphoenix" }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/clawdbot-gateway-jbphoenix.service", + ); + }); + + it("prefers CLAWDBOT_SYSTEMD_UNIT over CLAWDBOT_PROFILE", () => { + const env = { + HOME: "/home/test", + CLAWDBOT_PROFILE: "jbphoenix", + CLAWDBOT_SYSTEMD_UNIT: "custom-unit", + }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/custom-unit.service", + ); + }); + + it("handles CLAWDBOT_SYSTEMD_UNIT with .service suffix", () => { + const env = { + HOME: "/home/test", + CLAWDBOT_SYSTEMD_UNIT: "custom-unit.service", + }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/custom-unit.service", + ); + }); + + it("trims whitespace from CLAWDBOT_SYSTEMD_UNIT", () => { + const env = { + HOME: "/home/test", + CLAWDBOT_SYSTEMD_UNIT: " custom-unit ", + }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/custom-unit.service", + ); + }); + + it("handles case-insensitive 'Default' profile", () => { + const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "Default" }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/clawdbot-gateway.service", + ); + }); + + it("handles case-insensitive 'DEFAULT' profile", () => { + const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "DEFAULT" }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/clawdbot-gateway.service", + ); + }); + + it("trims whitespace from CLAWDBOT_PROFILE", () => { + const env = { HOME: "/home/test", CLAWDBOT_PROFILE: " myprofile " }; + expect(resolveSystemdUserUnitPath(env)).toBe( + "/home/test/.config/systemd/user/clawdbot-gateway-myprofile.service", + ); + }); +}); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index f5f2fbbd2..e191d323e 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -50,14 +50,6 @@ function resolveSystemdServiceName(env: Record): str return resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE); } -function resolveSystemdServiceNameFromParams(params?: { - env?: Record; - profile?: string; -}): string { - if (params?.env) return resolveSystemdServiceName(params.env); - return resolveGatewaySystemdServiceName(params?.profile); -} - function resolveSystemdUnitPath(env: Record): string { return resolveSystemdUnitPathForName(env, resolveSystemdServiceName(env)); } @@ -268,14 +260,12 @@ export async function uninstallSystemdService({ export async function stopSystemdService({ stdout, env, - profile, }: { stdout: NodeJS.WritableStream; env?: Record; - profile?: string; }): Promise { await assertSystemdAvailable(); - const serviceName = resolveSystemdServiceNameFromParams({ env, profile }); + const serviceName = resolveSystemdServiceName(env ?? {}); const unitName = `${serviceName}.service`; const res = await execSystemctl(["--user", "stop", unitName]); if (res.code !== 0) { @@ -287,14 +277,12 @@ export async function stopSystemdService({ export async function restartSystemdService({ stdout, env, - profile, }: { stdout: NodeJS.WritableStream; env?: Record; - profile?: string; }): Promise { await assertSystemdAvailable(); - const serviceName = resolveSystemdServiceNameFromParams({ env, profile }); + const serviceName = resolveSystemdServiceName(env ?? {}); const unitName = `${serviceName}.service`; const res = await execSystemctl(["--user", "restart", unitName]); if (res.code !== 0) { @@ -303,12 +291,11 @@ export async function restartSystemdService({ stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`); } -export async function isSystemdServiceEnabled(params?: { +export async function isSystemdServiceEnabled(args: { env?: Record; - profile?: string; }): Promise { await assertSystemdAvailable(); - const serviceName = resolveSystemdServiceNameFromParams(params); + const serviceName = resolveSystemdServiceName(args.env ?? {}); const unitName = `${serviceName}.service`; const res = await execSystemctl(["--user", "is-enabled", unitName]); return res.code === 0; diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index d9afb7fc1..3b97c2368 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -32,7 +32,9 @@ export function buildPortHints(listeners: PortListener[], port: number): string[ hints.push("Another process is listening on this port."); } if (listeners.length > 1) { - hints.push("Multiple listeners detected; ensure only one gateway/tunnel."); + hints.push( + "Multiple listeners detected; ensure only one gateway/tunnel per port unless intentionally running isolated profiles.", + ); } return hints; } diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index b49d81786..1c071aced 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -126,7 +126,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption ); } const service = resolveGatewayService(); - const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }); + const loaded = await service.isLoaded({ env: process.env }); if (loaded) { const action = (await prompter.select({ message: "Gateway service already installed", @@ -143,7 +143,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption async (progress) => { progress.update("Restarting Gateway daemon…"); await service.restart({ - profile: process.env.CLAWDBOT_PROFILE, + env: process.env, stdout: process.stdout, }); }, @@ -160,10 +160,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption } } - if ( - !loaded || - (loaded && (await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })) === false) - ) { + if (!loaded || (loaded && (await service.isLoaded({ env: process.env })) === false)) { const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); await withWizardProgress( From 7f6a288bd31fd6c2c54f3098ecc36c877683f3a7 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Thu, 15 Jan 2026 18:08:20 +0100 Subject: [PATCH 2/3] docs: clarify multi-gateway rescue bot guidance --- docs/cli/gateway.md | 2 +- docs/gateway/configuration.md | 2 +- docs/gateway/discovery.md | 2 +- docs/gateway/gateway-lock.md | 2 +- docs/gateway/index.md | 4 ++- docs/gateway/multiple-gateways.md | 34 ++++++++++++++++++++++++- docs/gateway/remote.md | 2 +- docs/index.md | 2 +- docs/start/faq.md | 2 +- src/cli/daemon-cli/status.print.ts | 2 +- src/commands/doctor-gateway-services.ts | 2 +- src/commands/gateway-status.ts | 2 +- 12 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index b7140c4c6..5971491e7 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -78,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789 - your configured remote gateway (if set), and - localhost (loopback) **even if remote is configured**. -If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway. +If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway. ```bash clawdbot gateway status diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 72fad3653..5e3d280ed 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2512,7 +2512,7 @@ Requires full Gateway restart: ### Multi-instance isolation -To run multiple gateways on one host, isolate per-instance state + config and use unique ports: +To run multiple gateways on one host (for redundancy or a rescue bot), isolate per-instance state + config and use unique ports: - `CLAWDBOT_CONFIG_PATH` (per-instance config) - `CLAWDBOT_STATE_DIR` (sessions/creds) - `agents.defaults.workspace` (memories) diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md index 7ae5baf37..ebfc1d630 100644 --- a/docs/gateway/discovery.md +++ b/docs/gateway/discovery.md @@ -16,7 +16,7 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew ## Terms -- **Gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. +- **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible. - **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`. - **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only. - **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH. diff --git a/docs/gateway/gateway-lock.md b/docs/gateway/gateway-lock.md index af3bf9e0c..a8af6afc5 100644 --- a/docs/gateway/gateway-lock.md +++ b/docs/gateway/gateway-lock.md @@ -9,7 +9,7 @@ read_when: Last updated: 2025-12-11 ## Why -- Ensure only one gateway instance runs per host. +- Ensure only one gateway instance runs per base port on the same host; additional gateways must use isolated profiles and unique ports. - Survive crashes/SIGKILL without leaving stale lock files. - Fail fast with a clear error when the control port is already occupied. diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 29fbb6a72..1f60db40b 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -63,6 +63,8 @@ Install metadata is embedded in the service config: - `CLAWDBOT_SERVICE_KIND=gateway` - `CLAWDBOT_SERVICE_VERSION=` +Rescue-Bot Pattern: keep a second Gateway isolated with its own profile, state dir, workspace, and base port spacing. Full guide: [Rescue-bot guide](/gateway/multiple-gateways#rescue-bot-guide). + ### Dev profile (`--dev`) Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup. @@ -205,7 +207,7 @@ Notes: - `daemon status` includes the last gateway error line when the service looks running but the port is closed. - `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed). - If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services. - We still recommend **one gateway per machine** unless you need redundant profiles. + We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways). - Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations). - `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes). diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md index 27f596250..ffb72a639 100644 --- a/docs/gateway/multiple-gateways.md +++ b/docs/gateway/multiple-gateways.md @@ -6,7 +6,7 @@ read_when: --- # Multiple Gateways (same host) -Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy, run separate Gateways. Both are supported. +Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy (e.g., a rescue bot), run separate Gateways with isolated profiles/ports. ## Isolation checklist (required) - `CLAWDBOT_CONFIG_PATH` — per-instance config file @@ -37,6 +37,38 @@ clawdbot --profile main daemon install clawdbot --profile rescue daemon install ``` +## Rescue-bot guide + +Run a second Gateway on the same host with its own: +- profile/config +- state dir +- workspace +- base port (plus derived ports) + +This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down. + +Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide. + +### How to install (rescue bot) + +```bash +# Main bot (existing or fresh, without --profile param) +# Runs on port 18789 + Chrome CDC/Canvas/... Ports +clawdbot onboard +clawdbot daemon install + +# Rescue bot (isolated profile + ports) +clawdbot --profile rescue onboard +# Notes: +# - workspace name will be postfixed with -rescue per default +# - Port should be at least 18789 + 20 Ports, +# better choose completely different base port, like 19789, +# - rest of the onboarding is the same as normal + +# To install the daemon (if not happened automatically during onboarding) +clawdbot --profile rescue daemon install +``` + ## Port mapping (derived) Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`). diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index 93f64ccb3..c549abd68 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -26,7 +26,7 @@ Flow example (Telegram → node): - Node returns the result; Gateway replies back out to Telegram. Notes: -- **Nodes do not run the gateway daemon.** Only one gateway should run per host. +- **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). - macOS app “node mode” is just a node client over the Bridge. ## SSH tunnel (CLI + tools) diff --git a/docs/index.md b/docs/index.md index f2e7c8b2e..cf991bb0b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,7 +66,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long ## Network model -- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session. +- **One Gateway per host (recommended)**: it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see [Multiple gateways](/gateway/multiple-gateways). - **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`. - The wizard now generates a gateway token by default (even for loopback). - For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds). diff --git a/docs/start/faq.md b/docs/start/faq.md index d92daf367..c92a2c236 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -665,7 +665,7 @@ Nodes don’t see inbound provider traffic; they only receive bridge RPC calls. ### Do nodes run a gateway daemon? -No. Only **one gateway** should run per host. Nodes are peripherals that connect +No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app). A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes. diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 687b9a9ad..832d02e58 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -267,7 +267,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) ); defaultRuntime.error( errorText( - "If you need multiple gateways (e.g., a recovery bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", + "If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", ), ); spacer(); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index bf9c93747..db9610055 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -282,7 +282,7 @@ export async function maybeScanExtraGatewayServices(options: DoctorOptions) { [ "Recommendation: run a single gateway per machine for most setups.", "One gateway supports multiple agents.", - "If you need multiple gateways (e.g., a recovery bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", + "If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", ].join("\n"), "Gateway recommendation", ); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index a36347b67..fede2a836 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -183,7 +183,7 @@ export async function gatewayStatusCommand( warnings.push({ code: "multiple_gateways", message: - "Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a recovery bot (see docs: /gateway#multiple-gateways-same-host).", + "Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).", targetIds: reachable.map((p) => p.target.id), }); } From 2b113c4d6ccdc369c2e0f18b0e1f2e13f91dee11 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 22:09:45 +0000 Subject: [PATCH 3/3] chore: update changelog (#969) (thanks @bjesuiter) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ced9cc8..72e73398d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2026.1.15 (unreleased) +- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. +- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.