diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d767ec2..7eda031b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. +- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. - Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter. - Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24). - Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields. diff --git a/src/daemon/constants.test.ts b/src/daemon/constants.test.ts index a8fa6a24b..a2ddc00e5 100644 --- a/src/daemon/constants.test.ts +++ b/src/daemon/constants.test.ts @@ -5,6 +5,7 @@ import { GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_WINDOWS_TASK_NAME, resolveGatewayLaunchAgentLabel, + resolveGatewayProfileSuffix, resolveGatewaySystemdServiceName, resolveGatewayWindowsTaskName, } from "./constants.js"; @@ -153,6 +154,25 @@ describe("resolveGatewayWindowsTaskName", () => { }); }); +describe("resolveGatewayProfileSuffix", () => { + it("returns empty string when no profile is set", () => { + expect(resolveGatewayProfileSuffix()).toBe(""); + }); + + it("returns empty string for default profiles", () => { + expect(resolveGatewayProfileSuffix("default")).toBe(""); + expect(resolveGatewayProfileSuffix(" Default ")).toBe(""); + }); + + it("returns a hyphenated suffix for custom profiles", () => { + expect(resolveGatewayProfileSuffix("dev")).toBe("-dev"); + }); + + it("trims whitespace from profiles", () => { + expect(resolveGatewayProfileSuffix(" staging ")).toBe("-staging"); + }); +}); + describe("formatGatewayServiceDescription", () => { it("returns default description when no profile/version", () => { expect(formatGatewayServiceDescription()).toBe("Clawdbot Gateway"); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 909d69d07..6890881f6 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -8,34 +8,35 @@ export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = ["com.steipete.clawdbot.gatewa export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES: string[] = []; export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES: string[] = []; -export function resolveGatewayLaunchAgentLabel(profile?: string): string { - const trimmed = profile?.trim(); - if (!trimmed || trimmed.toLowerCase() === "default") { - return GATEWAY_LAUNCH_AGENT_LABEL; - } - return `com.clawdbot.${trimmed}`; -} - -function normalizeGatewayProfile(profile?: string): string | null { +export function normalizeGatewayProfile(profile?: string): string | null { const trimmed = profile?.trim(); if (!trimmed || trimmed.toLowerCase() === "default") return null; return trimmed; } -export function resolveGatewaySystemdServiceName(profile?: string): string { - const trimmed = profile?.trim(); - if (!trimmed || trimmed.toLowerCase() === "default") { - return GATEWAY_SYSTEMD_SERVICE_NAME; +export function resolveGatewayProfileSuffix(profile?: string): string { + const normalized = normalizeGatewayProfile(profile); + return normalized ? `-${normalized}` : ""; +} + +export function resolveGatewayLaunchAgentLabel(profile?: string): string { + const normalized = normalizeGatewayProfile(profile); + if (!normalized) { + return GATEWAY_LAUNCH_AGENT_LABEL; } - return `clawdbot-gateway-${trimmed}`; + return `com.clawdbot.${normalized}`; +} + +export function resolveGatewaySystemdServiceName(profile?: string): string { + const suffix = resolveGatewayProfileSuffix(profile); + if (!suffix) return GATEWAY_SYSTEMD_SERVICE_NAME; + return `clawdbot-gateway${suffix}`; } export function resolveGatewayWindowsTaskName(profile?: string): string { - const trimmed = profile?.trim(); - if (!trimmed || trimmed.toLowerCase() === "default") { - return GATEWAY_WINDOWS_TASK_NAME; - } - return `Clawdbot Gateway (${trimmed})`; + const normalized = normalizeGatewayProfile(profile); + if (!normalized) return GATEWAY_WINDOWS_TASK_NAME; + return `Clawdbot Gateway (${normalized})`; } export function formatGatewayServiceDescription(params?: { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 36a9ffced..676f9101a 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -16,6 +16,7 @@ import { } from "./launchd-plist.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; +import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js"; const execFileAsync = promisify(execFile); @@ -29,11 +30,6 @@ function resolveLaunchAgentLabel(args?: { env?: Record): string { - const home = env.HOME?.trim() || env.USERPROFILE?.trim(); - if (!home) throw new Error("Missing HOME"); - return home; -} function resolveLaunchAgentPlistPathForLabel( env: Record, @@ -53,12 +49,7 @@ export function resolveGatewayLogPaths(env: Record): stdoutPath: string; stderrPath: string; } { - const home = resolveHomeDir(env); - const stateOverride = env.CLAWDBOT_STATE_DIR?.trim(); - const profile = env.CLAWDBOT_PROFILE?.trim(); - const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : ""; - const defaultStateDir = path.join(home, `.clawdbot${suffix}`); - const stateDir = stateOverride ? resolveUserPathWithHome(stateOverride, home) : defaultStateDir; + const stateDir = resolveGatewayStateDir(env); const logDir = path.join(stateDir, "logs"); return { logDir, @@ -67,16 +58,6 @@ export function resolveGatewayLogPaths(env: Record): }; } -function resolveUserPathWithHome(input: string, home: string): string { - const trimmed = input.trim(); - if (!trimmed) return trimmed; - if (trimmed.startsWith("~")) { - const expanded = trimmed.replace(/^~(?=$|[\\/])/, home); - return path.resolve(expanded); - } - return path.resolve(trimmed); -} - export async function readLaunchAgentProgramArguments( env: Record, ): Promise<{ diff --git a/src/daemon/paths.test.ts b/src/daemon/paths.test.ts new file mode 100644 index 000000000..ab66a6f12 --- /dev/null +++ b/src/daemon/paths.test.ts @@ -0,0 +1,37 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { resolveGatewayStateDir } from "./paths.js"; + +describe("resolveGatewayStateDir", () => { + it("uses the default state dir when no overrides are set", () => { + const env = { HOME: "/Users/test" }; + expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot")); + }); + + it("appends the profile suffix when set", () => { + const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "rescue" }; + expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot-rescue")); + }); + + it("treats default profiles as the base state dir", () => { + const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" }; + expect(resolveGatewayStateDir(env)).toBe(path.join("/Users/test", ".clawdbot")); + }); + + it("uses CLAWDBOT_STATE_DIR when provided", () => { + const env = { HOME: "/Users/test", CLAWDBOT_STATE_DIR: "/var/lib/clawdbot" }; + expect(resolveGatewayStateDir(env)).toBe(path.resolve("/var/lib/clawdbot")); + }); + + it("expands ~ in CLAWDBOT_STATE_DIR", () => { + const env = { HOME: "/Users/test", CLAWDBOT_STATE_DIR: "~/clawdbot-state" }; + expect(resolveGatewayStateDir(env)).toBe(path.resolve("/Users/test/clawdbot-state")); + }); + + it("preserves Windows absolute paths without HOME", () => { + const env = { CLAWDBOT_STATE_DIR: "C:\\State\\clawdbot" }; + expect(resolveGatewayStateDir(env)).toBe("C:\\State\\clawdbot"); + }); +}); diff --git a/src/daemon/paths.ts b/src/daemon/paths.ts new file mode 100644 index 000000000..61913fe3c --- /dev/null +++ b/src/daemon/paths.ts @@ -0,0 +1,37 @@ +import path from "node:path"; + +import { resolveGatewayProfileSuffix } from "./constants.js"; + +const windowsAbsolutePath = /^[a-zA-Z]:[\\/]/; +const windowsUncPath = /^\\\\/; + +export function resolveHomeDir(env: Record): string { + const home = env.HOME?.trim() || env.USERPROFILE?.trim(); + if (!home) throw new Error("Missing HOME"); + return home; +} + +export function resolveUserPathWithHome(input: string, home?: string): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("~")) { + if (!home) throw new Error("Missing HOME"); + const expanded = trimmed.replace(/^~(?=$|[\\/])/, home); + return path.resolve(expanded); + } + if (windowsAbsolutePath.test(trimmed) || windowsUncPath.test(trimmed)) { + return trimmed; + } + return path.resolve(trimmed); +} + +export function resolveGatewayStateDir(env: Record): string { + const override = env.CLAWDBOT_STATE_DIR?.trim(); + if (override) { + const home = override.startsWith("~") ? resolveHomeDir(env) : undefined; + return resolveUserPathWithHome(override, home); + } + const home = resolveHomeDir(env); + const suffix = resolveGatewayProfileSuffix(env.CLAWDBOT_PROFILE); + return path.join(home, `.clawdbot${suffix}`); +} diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts index 28556439d..c75b291ba 100644 --- a/src/daemon/schtasks.test.ts +++ b/src/daemon/schtasks.test.ts @@ -58,6 +58,15 @@ describe("resolveTaskScriptPath", () => { ); }); + it("prefers CLAWDBOT_STATE_DIR over profile-derived defaults", () => { + const env = { + USERPROFILE: "C:\\Users\\test", + CLAWDBOT_PROFILE: "rescue", + CLAWDBOT_STATE_DIR: "C:\\State\\clawdbot", + }; + expect(resolveTaskScriptPath(env)).toBe(path.join("C:\\State\\clawdbot", "gateway.cmd")); + }); + it("handles case-insensitive 'Default' profile", () => { const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "Default" }; expect(resolveTaskScriptPath(env)).toBe( diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 1f749a812..a260f078a 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -5,6 +5,7 @@ import { promisify } from "node:util"; import { colorize, isRich, theme } from "../terminal/theme.js"; import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js"; +import { resolveGatewayStateDir } from "./paths.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; @@ -15,17 +16,9 @@ const formatLine = (label: string, value: string) => { return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; }; -function resolveHomeDir(env: Record): string { - const home = env.USERPROFILE?.trim() || env.HOME?.trim(); - if (!home) throw new Error("Missing HOME"); - return home; -} - export function resolveTaskScriptPath(env: Record): string { - const home = resolveHomeDir(env); - const profile = env.CLAWDBOT_PROFILE?.trim(); - const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : ""; - return path.join(home, `.clawdbot${suffix}`, "gateway.cmd"); + const stateDir = resolveGatewayStateDir(env); + return path.join(stateDir, "gateway.cmd"); } function quoteCmdArg(value: string): string { diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index e191d323e..7566295d6 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -10,6 +10,7 @@ import { } from "./constants.js"; import { parseKeyValueOutput } from "./runtime-parse.js"; import type { GatewayServiceRuntime } from "./service-runtime.js"; +import { resolveHomeDir } from "./paths.js"; import { enableSystemdUserLinger, readSystemdUserLingerStatus, @@ -28,12 +29,6 @@ const formatLine = (label: string, value: string) => { return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`; }; -function resolveHomeDir(env: Record): string { - const home = env.HOME?.trim() || env.USERPROFILE?.trim(); - if (!home) throw new Error("Missing HOME"); - return home; -} - function resolveSystemdUnitPathForName( env: Record, name: string,