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(