fix(cli): replace stale service selectors on profile switch (#124790)

* fix(cli): replace stale service profile selectors

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* refactor(cli): own service identity checks with daemon constants

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* fix(update): preserve managed home selector ownership

Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae

* test(update): keep selector coverage within limits

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-16 23:00:08 -07:00
committed by GitHub
parent e84a45e28e
commit 67ea9c2281
6 changed files with 152 additions and 30 deletions
+93
View File
@@ -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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {
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",
+28 -14
View File
@@ -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];
@@ -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 {
+2 -10
View File
@@ -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<string, string | undefined> = 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: {
@@ -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();
+18
View File
@@ -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<string, string | undefined>): 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";