diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts index 1e2d82e5354c..78e6e74bafdc 100644 --- a/src/cli/profile.test.ts +++ b/src/cli/profile.test.ts @@ -1,6 +1,7 @@ // Profile CLI tests cover profile selection, persistence, and command wiring. import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolveGatewayPort } from "../config/paths.js"; import { formatCliCommand } from "./command-format.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; @@ -306,6 +307,98 @@ describe("applyCliProfileEnv", () => { expect(env.OPENCLAW_CONFIG_PATH).toBe(path.join("/custom", "openclaw.json")); }); + it.each([ + { name: "default service to named profile", inheritedProfile: undefined, selected: "work" }, + { name: "named service to different profile", inheritedProfile: "main", selected: "work" }, + { name: "named service to dev", inheritedProfile: "main", selected: "dev" }, + ])("replaces the complete service selector bundle: $name", ({ inheritedProfile, selected }) => { + const inheritedStateDir = inheritedProfile + ? `/home/peter/.openclaw-${inheritedProfile}` + : "/home/peter/.openclaw"; + const env: Record = { + OPENCLAW_PROFILE: inheritedProfile, + OPENCLAW_STATE_DIR: inheritedStateDir, + OPENCLAW_CONFIG_PATH: path.join(inheritedStateDir, "openclaw.json"), + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_LAUNCHD_LABEL: inheritedProfile + ? `ai.openclaw.${inheritedProfile}` + : "ai.openclaw.gateway", + OPENCLAW_SYSTEMD_UNIT: inheritedProfile + ? `openclaw-gateway-${inheritedProfile}.service` + : "openclaw-gateway.service", + OPENCLAW_WINDOWS_TASK_NAME: inheritedProfile + ? `OpenClaw Gateway (${inheritedProfile})` + : "OpenClaw Gateway", + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }; + + applyCliProfileEnv({ profile: selected, env, homedir: () => "/home/peter" }); + + expect(env.OPENCLAW_PROFILE).toBe(selected); + expect(env.OPENCLAW_STATE_DIR).toBe(`/home/peter/.openclaw-${selected}`); + expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined(); + expect(env.OPENCLAW_GATEWAY_PORT).toBe(selected === "dev" ? "19001" : undefined); + expect(env.OPENCLAW_LAUNCHD_LABEL).toBeUndefined(); + expect(env.OPENCLAW_SYSTEMD_UNIT).toBeUndefined(); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBeUndefined(); + }); + + it("lets selected config or profile derivation resolve the port after stale service removal", () => { + const env: Record = { + OPENCLAW_PROFILE: "main", + OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main", + OPENCLAW_CONFIG_PATH: "/home/peter/.openclaw-main/openclaw.json", + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.main", + OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-main.service", + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (main)", + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }; + + applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" }); + + expect(resolveGatewayPort({ gateway: { port: 21999 } }, env)).toBe(21999); + expect(resolveGatewayPort(undefined, env)).not.toBe(18789); + }); + + it("supports legacy gateway services without a service kind", () => { + const env: Record = { + OPENCLAW_PROFILE: "main", + OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main", + OPENCLAW_CONFIG_PATH: "/home/peter/.openclaw-main/openclaw.json", + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_SERVICE_MARKER: "openclaw", + }; + + applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" }); + + expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined(); + expect(env.OPENCLAW_GATEWAY_PORT).toBeUndefined(); + }); + + it("preserves node service selectors when selecting a CLI profile", () => { + const env: Record = { + OPENCLAW_PROFILE: "main", + OPENCLAW_STATE_DIR: "/home/peter/.openclaw-main", + OPENCLAW_CONFIG_PATH: "/home/peter/.openclaw-main/openclaw.json", + OPENCLAW_GATEWAY_PORT: "19999", + OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node", + OPENCLAW_SYSTEMD_UNIT: "openclaw-node.service", + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Node", + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "node", + }; + + applyCliProfileEnv({ profile: "work", env, homedir: () => "/home/peter" }); + + expect(env.OPENCLAW_GATEWAY_PORT).toBe("19999"); + expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.node"); + expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-node.service"); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Node"); + }); + it.each([ { name: "the default profile without a profile marker", diff --git a/src/cli/profile.ts b/src/cli/profile.ts index 3c7a9f1ddcc5..806dc0abe939 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { + GATEWAY_SERVICE_SELECTOR_ENV_KEYS, + isGatewayServiceEnv, resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, resolveGatewayWindowsTaskName, @@ -91,6 +93,12 @@ export function applyCliProfileEnv(params: { const inheritedProfileStateDir = resolveProfileStateDir(inheritedProfile, profileEnv, homedir); const selectedProfileStateDir = resolveProfileStateDir(profile, profileEnv, homedir); const switchesInheritedProfile = inheritedProfileStateDir !== selectedProfileStateDir; + const inheritedSystemdServiceName = resolveGatewaySystemdServiceName(inheritedProfile); + const inheritedServiceSelectors = { + OPENCLAW_LAUNCHD_LABEL: [resolveGatewayLaunchAgentLabel(inheritedProfile)], + OPENCLAW_SYSTEMD_UNIT: [inheritedSystemdServiceName, `${inheritedSystemdServiceName}.service`], + OPENCLAW_WINDOWS_TASK_NAME: [resolveGatewayWindowsTaskName(inheritedProfile)], + }; const switchesInheritedProfileState = Boolean( existingStateDir && switchesInheritedProfile && @@ -108,32 +116,38 @@ export function applyCliProfileEnv(params: { homedir, }) === path.join(inheritedProfileStateDir, "openclaw.json"), ); + const inheritedManagedServiceSelectors = + switchesInheritedProfile && + isGatewayServiceEnv(env) && + switchesInheritedProfileState && + replacesInheritedProfileConfig; + + if (inheritedManagedServiceSelectors) { + for (const key of GATEWAY_SERVICE_SELECTOR_ENV_KEYS) { + delete env[key]; + } + } // A service's canonical profile paths are inherited defaults, not custom overrides. // Switch them together so an explicit profile cannot mutate the service's profile. env.OPENCLAW_PROFILE = profile; + const retainedStateDir = inheritedManagedServiceSelectors ? undefined : existingStateDir; const stateDir = - existingStateDir && !switchesInheritedProfileState ? existingStateDir : selectedProfileStateDir; - if (!existingStateDir || switchesInheritedProfileState) { + retainedStateDir && !switchesInheritedProfileState ? retainedStateDir : selectedProfileStateDir; + if (!retainedStateDir || switchesInheritedProfileState) { env.OPENCLAW_STATE_DIR = stateDir; } - if (!existingConfigPath || replacesInheritedProfileConfig) { + if ( + !inheritedManagedServiceSelectors && + (!existingConfigPath || replacesInheritedProfileConfig) + ) { env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); } - if (switchesInheritedProfile) { - const inheritedSystemdServiceName = resolveGatewaySystemdServiceName(inheritedProfile); - const inheritedServiceIdentities = { - OPENCLAW_LAUNCHD_LABEL: [resolveGatewayLaunchAgentLabel(inheritedProfile)], - OPENCLAW_SYSTEMD_UNIT: [ - inheritedSystemdServiceName, - `${inheritedSystemdServiceName}.service`, - ], - OPENCLAW_WINDOWS_TASK_NAME: [resolveGatewayWindowsTaskName(inheritedProfile)], - }; - for (const [key, inheritedValues] of Object.entries(inheritedServiceIdentities)) { + if (switchesInheritedProfile && !inheritedManagedServiceSelectors) { + for (const [key, inheritedValues] of Object.entries(inheritedServiceSelectors)) { const activeValue = normalizeOptionalString(env[key]); if (activeValue && inheritedValues.includes(activeValue)) { delete env[key]; diff --git a/src/cli/update-cli/update-command-service-env.ts b/src/cli/update-cli/update-command-service-env.ts index 8633db7c8de4..718b8fb29c73 100644 --- a/src/cli/update-cli/update-command-service-env.ts +++ b/src/cli/update-cli/update-command-service-env.ts @@ -1,15 +1,14 @@ import path from "node:path"; +import { GATEWAY_SERVICE_SELECTOR_ENV_KEYS } from "../../daemon/constants.js"; const SERVICE_REFRESH_PATH_ENV_KEYS = [ "OPENCLAW_HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", ] as const; - -const MANAGED_SERVICE_SELECTOR_ENV_KEYS = [ - ...SERVICE_REFRESH_PATH_ENV_KEYS, - "OPENCLAW_PROFILE", - "OPENCLAW_GATEWAY_PORT", +const MANAGED_UPDATE_SELECTOR_ENV_KEYS = [ + "OPENCLAW_HOME", + ...GATEWAY_SERVICE_SELECTOR_ENV_KEYS, ] as const; function applyManagedServiceSelectorEnv(params: { @@ -19,7 +18,7 @@ function applyManagedServiceSelectorEnv(params: { }): NodeJS.ProcessEnv { const resolved = { ...params.baseEnv }; const selectorEnv = params.selectorEnv ?? params.serviceEnv; - for (const key of MANAGED_SERVICE_SELECTOR_ENV_KEYS) { + for (const key of MANAGED_UPDATE_SELECTOR_ENV_KEYS) { if (selectorEnv[key]?.trim()) { resolved[key] = params.serviceEnv[key]; } else { diff --git a/src/cli/update-cli/update-command-service.ts b/src/cli/update-cli/update-command-service.ts index f12f2e3a73a8..10191499fbc6 100644 --- a/src/cli/update-cli/update-command-service.ts +++ b/src/cli/update-cli/update-command-service.ts @@ -16,11 +16,7 @@ import { doctorCommand } from "../../commands/doctor.js"; import { UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV } from "../../commands/doctor/shared/update-phase.js"; import { resolveGatewayPort } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - GATEWAY_SERVICE_KIND, - GATEWAY_SERVICE_MARKER, - GATEWAY_SERVICE_RUNTIME_PID_ENV, -} from "../../daemon/constants.js"; +import { GATEWAY_SERVICE_RUNTIME_PID_ENV, isGatewayServiceEnv } from "../../daemon/constants.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js"; import { @@ -601,11 +597,7 @@ export async function maybeRestartServiceAfterFailedMutableUpdate(params: { function isRunningInsideGatewayService( env: Record = process.env, ): boolean { - if (env.OPENCLAW_SERVICE_MARKER?.trim() !== GATEWAY_SERVICE_MARKER) { - return false; - } - const serviceKind = env.OPENCLAW_SERVICE_KIND?.trim(); - return !serviceKind || serviceKind === GATEWAY_SERVICE_KIND; + return isGatewayServiceEnv(env); } export function shouldBlockMutableUpdateFromGatewayServiceEnv(params: { diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 90a80dd3d938..1b66c66449a9 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -263,11 +263,13 @@ describe("resolvePostInstallDoctorEnv", () => { OPENCLAW_STATE_DIR: "/wrong/state", OPENCLAW_CONFIG_PATH: "/wrong/openclaw.json", OPENCLAW_PROFILE: "wrong", + OPENCLAW_SYSTEMD_UNIT: "wrong.service", }, serviceEnv: { OPENCLAW_STATE_DIR: "daemon-state", OPENCLAW_CONFIG_PATH: "daemon-state/openclaw.json", OPENCLAW_PROFILE: "work", + OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway-work.service", }, }); @@ -278,6 +280,7 @@ describe("resolvePostInstallDoctorEnv", () => { path.join("/srv/openclaw", "daemon-state", "openclaw.json"), ); expect(env.OPENCLAW_PROFILE).toBe("work"); + expect(env.OPENCLAW_SYSTEMD_UNIT).toBe("openclaw-gateway-work.service"); }); it("keeps the caller env when no managed service env is available", () => { @@ -321,6 +324,7 @@ describe("resolveUpdatedInstallCommandEnv", () => { const env = resolveOwnedManagedUpdateEnv({ processEnv: { HOME: "/home/operator", + OPENCLAW_HOME: "/home/operator/openclaw-home", OPENCLAW_PROFILE: "personal", OPENCLAW_STATE_DIR: "/home/operator/.openclaw-personal", OPENCLAW_CONFIG_PATH: "/home/operator/.openclaw-personal/openclaw.json", @@ -328,6 +332,7 @@ describe("resolveUpdatedInstallCommandEnv", () => { }, serviceEnv: { HOME: "/home/operator", + OPENCLAW_HOME: "/home/operator/openclaw-home", OPENCLAW_PROFILE: "personal", OPENCLAW_STATE_DIR: "/home/operator/.openclaw-personal", OPENCLAW_CONFIG_PATH: "/home/operator/.openclaw-personal/openclaw.json", @@ -337,6 +342,7 @@ describe("resolveUpdatedInstallCommandEnv", () => { }); expect(env.HOME).toBe("/home/operator"); + expect(env.OPENCLAW_HOME).toBeUndefined(); expect(env.OPENCLAW_PROFILE).toBeUndefined(); expect(env.OPENCLAW_STATE_DIR).toBeUndefined(); expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined(); diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 8d0a20ec8ca5..892e850c8915 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -8,6 +8,24 @@ const GATEWAY_WINDOWS_TASK_NAME = "OpenClaw Gateway"; export const GATEWAY_SERVICE_MARKER = "openclaw"; export const GATEWAY_SERVICE_KIND = "gateway"; export const GATEWAY_SERVICE_RUNTIME_PID_ENV = "OPENCLAW_GATEWAY_SERVICE_PID"; +export const GATEWAY_SERVICE_SELECTOR_ENV_KEYS = [ + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_PROFILE", + "OPENCLAW_GATEWAY_PORT", + "OPENCLAW_LAUNCHD_LABEL", + "OPENCLAW_SYSTEMD_UNIT", + "OPENCLAW_WINDOWS_TASK_NAME", +] as const; + +export function isGatewayServiceEnv(env: Record): boolean { + if (env.OPENCLAW_SERVICE_MARKER?.trim() !== GATEWAY_SERVICE_MARKER) { + return false; + } + const serviceKind = env.OPENCLAW_SERVICE_KIND?.trim(); + return !serviceKind || serviceKind === GATEWAY_SERVICE_KIND; +} + const NODE_LAUNCH_AGENT_LABEL = "ai.openclaw.node"; const NODE_SYSTEMD_SERVICE_NAME = "openclaw-node"; const NODE_WINDOWS_TASK_NAME = "OpenClaw Node";