From 5918def440c0b089c3025fb34baf3e18f00ab4eb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 05:58:46 +0000 Subject: [PATCH] fix: honor gateway service override labels --- CHANGELOG.md | 3 +++ docs/gateway/index.md | 8 ++++++++ docs/start/faq.md | 11 +++++++--- src/cli/daemon-cli.ts | 27 +++++++++++++++++-------- src/cli/gateway-cli.ts | 5 ++++- src/commands/configure.ts | 2 ++ src/commands/doctor-gateway-services.ts | 1 + src/commands/doctor.ts | 4 ++++ src/commands/reset.ts | 4 ++-- src/commands/status-all.ts | 5 ++++- src/commands/status.ts | 5 ++++- src/commands/uninstall.ts | 4 ++-- src/daemon/launchd.ts | 24 ++++++++++++++++++---- src/daemon/service.ts | 27 ++++++++++++++++++++----- src/daemon/systemd.ts | 25 +++++++++++++++++------ src/wizard/onboarding.ts | 8 ++++++-- 16 files changed, 128 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb9c220c..06abafdd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ - Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor. - Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior) +### Fixes +- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon. + ## 2026.1.12-4 ### Changes diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 0ac74a0ac..85bda83f6 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -49,6 +49,8 @@ pnpm gateway:watch ## Multiple gateways (same host) +Usually unnecessary: one Gateway can serve multiple messaging channels and agents. Use multiple Gateways only for redundancy or strict isolation (ex: rescue bot). + Supported if you isolate state + config and use unique ports. Service names are profile-aware: @@ -96,6 +98,12 @@ Checklist per instance: - unique `agents.defaults.workspace` - separate WhatsApp numbers (if using WA) +Daemon install per profile: +```bash +clawdbot --profile main daemon install +clawdbot --profile rescue daemon install +``` + Example: ```bash CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json CLAWDBOT_STATE_DIR=~/.clawdbot-a clawdbot gateway --port 19001 diff --git a/docs/start/faq.md b/docs/start/faq.md index d713df6b1..795b358f2 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -829,6 +829,8 @@ Fix: ### Can I run multiple Gateways on the same host? +Usually no — one Gateway can run multiple messaging channels and agents. Use multiple Gateways only when you need redundancy (ex: rescue bot) or hard isolation. + Yes, but you must isolate: - `CLAWDBOT_CONFIG_PATH` (per‑instance config) @@ -836,9 +838,12 @@ Yes, but you must isolate: - `agents.defaults.workspace` (workspace isolation) - `gateway.port` (unique ports) -There are convenience CLI flags like `--dev` and `--profile ` that shift state dirs and ports. -When using profiles, service names are suffixed (`com.clawdbot.`, `clawdbot-gateway-.service`, -`Clawdbot Gateway ()`). +Quick setup (recommended): +- Use `clawdbot --profile …` per instance (auto-creates `~/.clawdbot-`). +- Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs). +- Install a per-profile daemon: `clawdbot --profile daemon install`. + +Profiles also suffix service names (`com.clawdbot.`, `clawdbot-gateway-.service`, `Clawdbot Gateway ()`). ## Logging and debugging diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index bb9edeb6d..202c9186a 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -367,7 +367,10 @@ async function gatherDaemonStatus(opts: { const service = resolveGatewayService(); const [loaded, command, runtime] = await Promise.all([ service - .isLoaded({ profile: process.env.CLAWDBOT_PROFILE }) + .isLoaded({ + env: process.env, + profile: process.env.CLAWDBOT_PROFILE, + }) .catch(() => false), service.readCommand(process.env).catch(() => null), service.readRuntime(process.env).catch(() => undefined), @@ -899,7 +902,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env, profile }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -980,7 +983,7 @@ export async function runDaemonStart() { const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env, profile }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -994,7 +997,11 @@ export async function runDaemonStart() { return; } try { - await service.restart({ profile, stdout: process.stdout }); + await service.restart({ + env: process.env, + profile, + stdout: process.stdout, + }); } catch (err) { defaultRuntime.error(`Gateway start failed: ${String(err)}`); for (const hint of renderGatewayServiceStartHints()) { @@ -1009,7 +1016,7 @@ export async function runDaemonStop() { const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env, profile }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -1020,7 +1027,7 @@ export async function runDaemonStop() { return; } try { - await service.stop({ profile, stdout: process.stdout }); + await service.stop({ env: process.env, profile, stdout: process.stdout }); } catch (err) { defaultRuntime.error(`Gateway stop failed: ${String(err)}`); defaultRuntime.exit(1); @@ -1037,7 +1044,7 @@ export async function runDaemonRestart(): Promise { const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env, profile }); } catch (err) { defaultRuntime.error(`Gateway service check failed: ${String(err)}`); defaultRuntime.exit(1); @@ -1051,7 +1058,11 @@ export async function runDaemonRestart(): Promise { return false; } try { - await service.restart({ profile, stdout: process.stdout }); + await service.restart({ + env: process.env, + profile, + stdout: process.stdout, + }); return true; } catch (err) { defaultRuntime.error(`Gateway restart failed: ${String(err)}`); diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 767c70715..bb89a2ba1 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -399,7 +399,10 @@ 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, + profile: process.env.CLAWDBOT_PROFILE, + }); } catch { loaded = null; } diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 7f608e7d2..1cc095792 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -473,6 +473,7 @@ async function maybeInstallDaemon(params: { }) { const service = resolveGatewayService(); const loaded = await service.isLoaded({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, }); let shouldCheckLinger = false; @@ -492,6 +493,7 @@ async function maybeInstallDaemon(params: { ); if (action === "restart") { await service.restart({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, stdout: process.stdout, }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 06f14111e..06d24faa6 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -100,6 +100,7 @@ export async function maybeMigrateLegacyGatewayService( const service = resolveGatewayService(); const loaded = await service.isLoaded({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, }); if (loaded) { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 6eead0dce..085dbd50b 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -450,6 +450,7 @@ export async function doctorCommand( let loaded = false; try { loaded = await service.isLoaded({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, }); } catch { @@ -575,6 +576,7 @@ export async function doctorCommand( if (!healthOk) { const service = resolveGatewayService(); const loaded = await service.isLoaded({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, }); let serviceRuntime: @@ -676,6 +678,7 @@ export async function doctorCommand( }); if (start) { await service.restart({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, stdout: process.stdout, }); @@ -698,6 +701,7 @@ export async function doctorCommand( }); if (restart) { await service.restart({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, stdout: process.stdout, }); diff --git a/src/commands/reset.ts b/src/commands/reset.ts index d2f9055d1..d55ee3f27 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -47,14 +47,14 @@ async function stopGatewayIfRunning(runtime: RuntimeEnv) { const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env, profile }); } 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, profile, 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 caf22958a..653b29388 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -167,7 +167,10 @@ export async function statusAllCommand( const service = resolveGatewayService(); const [loaded, runtimeInfo, command] = await Promise.all([ service - .isLoaded({ profile: process.env.CLAWDBOT_PROFILE }) + .isLoaded({ + env: process.env, + profile: process.env.CLAWDBOT_PROFILE, + }) .catch(() => false), service.readRuntime(process.env).catch(() => undefined), service.readCommand(process.env).catch(() => null), diff --git a/src/commands/status.ts b/src/commands/status.ts index b1ede586f..04565e64f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -336,7 +336,10 @@ async function getDaemonStatusSummary(): Promise<{ const service = resolveGatewayService(); const [loaded, runtime, command] = await Promise.all([ service - .isLoaded({ profile: process.env.CLAWDBOT_PROFILE }) + .isLoaded({ + env: process.env, + profile: process.env.CLAWDBOT_PROFILE, + }) .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 523a1972e..91f89c5cb 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -70,7 +70,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise { const profile = process.env.CLAWDBOT_PROFILE; let loaded = false; try { - loaded = await service.isLoaded({ profile }); + loaded = await service.isLoaded({ env: process.env, profile }); } catch (err) { runtime.error(`Gateway service check failed: ${String(err)}`); return false; @@ -80,7 +80,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise { return true; } try { - await service.stop({ profile, stdout: process.stdout }); + await service.stop({ env: process.env, profile, stdout: process.stdout }); } catch (err) { runtime.error(`Gateway stop failed: ${String(err)}`); } diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index ad57d1f4e..46126b1b6 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -19,6 +19,15 @@ const formatLine = (label: string, value: string) => { const rich = isRich(); 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(); + if (envLabel) return envLabel; + return resolveGatewayLaunchAgentLabel(params?.profile); +} function resolveHomeDir(env: Record): string { const home = env.HOME?.trim() || env.USERPROFILE?.trim(); if (!home) throw new Error("Missing HOME"); @@ -284,9 +293,12 @@ export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo { return info; } -export async function isLaunchAgentLoaded(profile?: string): Promise { +export async function isLaunchAgentLoaded(params?: { + env?: Record; + profile?: string; +}): Promise { const domain = resolveGuiDomain(); - const label = resolveGatewayLaunchAgentLabel(profile); + const label = resolveLaunchAgentLabel(params); const res = await execLaunchctl(["print", `${domain}/${label}`]); return res.code === 0; } @@ -461,13 +473,15 @@ function isLaunchctlNotLoaded(res: { export async function stopLaunchAgent({ stdout, + env, profile, }: { stdout: NodeJS.WritableStream; + env?: Record; profile?: string; }): Promise { const domain = resolveGuiDomain(); - const label = resolveGatewayLaunchAgentLabel(profile); + const label = resolveLaunchAgentLabel({ env, profile }); const res = await execLaunchctl(["bootout", `${domain}/${label}`]); if (res.code !== 0 && !isLaunchctlNotLoaded(res)) { throw new Error( @@ -548,13 +562,15 @@ export async function installLaunchAgent({ export async function restartLaunchAgent({ stdout, + env, profile, }: { stdout: NodeJS.WritableStream; + env?: Record; profile?: string; }): Promise { const domain = resolveGuiDomain(); - const label = resolveGatewayLaunchAgentLabel(profile); + const label = resolveLaunchAgentLabel({ env, profile }); const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); if (res.code !== 0) { throw new Error( diff --git a/src/daemon/service.ts b/src/daemon/service.ts index e1722618d..81975344f 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -45,14 +45,19 @@ export type GatewayService = { stdout: NodeJS.WritableStream; }) => Promise; stop: (args: { + env?: Record; profile?: string; stdout: NodeJS.WritableStream; }) => Promise; restart: (args: { + env?: Record; profile?: string; stdout: NodeJS.WritableStream; }) => Promise; - isLoaded: (args: { profile?: string }) => Promise; + isLoaded: (args: { + env?: Record; + profile?: string; + }) => Promise; readCommand: (env: Record) => Promise<{ programArguments: string[]; workingDirectory?: string; @@ -77,15 +82,21 @@ export function resolveGatewayService(): GatewayService { await uninstallLaunchAgent(args); }, stop: async (args) => { - await stopLaunchAgent({ stdout: args.stdout, profile: args.profile }); + 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(args.profile), + isLoaded: async (args) => + isLaunchAgentLoaded({ profile: args.profile, env: args.env }), readCommand: readLaunchAgentProgramArguments, readRuntime: readLaunchAgentRuntime, }; @@ -106,15 +117,18 @@ export function resolveGatewayService(): GatewayService { 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(args.profile), + isLoaded: async (args) => + isSystemdServiceEnabled({ profile: args.profile, env: args.env }), readCommand: readSystemdServiceExecStart, readRuntime: async (env) => await readSystemdServiceRuntime(env), }; @@ -132,7 +146,10 @@ export function resolveGatewayService(): GatewayService { await uninstallScheduledTask(args); }, stop: async (args) => { - await stopScheduledTask({ stdout: args.stdout, profile: args.profile }); + await stopScheduledTask({ + stdout: args.stdout, + profile: args.profile, + }); }, restart: async (args) => { await restartScheduledTask({ diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 5d0d35f87..059e57af2 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -46,6 +46,14 @@ function resolveSystemdServiceName( 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 { @@ -466,13 +474,15 @@ export async function uninstallSystemdService({ export async function stopSystemdService({ stdout, + env, profile, }: { stdout: NodeJS.WritableStream; + env?: Record; profile?: string; }): Promise { await assertSystemdAvailable(); - const serviceName = resolveGatewaySystemdServiceName(profile); + const serviceName = resolveSystemdServiceNameFromParams({ env, profile }); const unitName = `${serviceName}.service`; const res = await execSystemctl(["--user", "stop", unitName]); if (res.code !== 0) { @@ -485,13 +495,15 @@ export async function stopSystemdService({ export async function restartSystemdService({ stdout, + env, profile, }: { stdout: NodeJS.WritableStream; + env?: Record; profile?: string; }): Promise { await assertSystemdAvailable(); - const serviceName = resolveGatewaySystemdServiceName(profile); + const serviceName = resolveSystemdServiceNameFromParams({ env, profile }); const unitName = `${serviceName}.service`; const res = await execSystemctl(["--user", "restart", unitName]); if (res.code !== 0) { @@ -502,11 +514,12 @@ export async function restartSystemdService({ stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`); } -export async function isSystemdServiceEnabled( - profile?: string, -): Promise { +export async function isSystemdServiceEnabled(params?: { + env?: Record; + profile?: string; +}): Promise { await assertSystemdAvailable(); - const serviceName = resolveGatewaySystemdServiceName(profile); + const serviceName = resolveSystemdServiceNameFromParams(params); const unitName = `${serviceName}.service`; const res = await execSystemctl(["--user", "is-enabled", unitName]); return res.code === 0; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 6b730b5f6..e10b1a464 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -697,6 +697,7 @@ export async function runOnboardingWizard( } const service = resolveGatewayService(); const loaded = await service.isLoaded({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, }); if (loaded) { @@ -710,6 +711,7 @@ export async function runOnboardingWizard( })) as "restart" | "reinstall" | "skip"; if (action === "restart") { await service.restart({ + env: process.env, profile: process.env.CLAWDBOT_PROFILE, stdout: process.stdout, }); @@ -721,8 +723,10 @@ export async function runOnboardingWizard( if ( !loaded || (loaded && - (await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE })) === - false) + (await service.isLoaded({ + env: process.env, + profile: process.env.CLAWDBOT_PROFILE, + })) === false) ) { const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&