fix: allow gateway service commands for named profiles (#116314)

* fix: gateway service commands refuse a named profile or relocated OPENCLAW_HOME

- Resolve the default install identity against the canonical state directory
  for the active OpenClaw home and profile instead of the unprofiled OS
  account default.
- `--profile <name>` / `--dev` project `.openclaw-<profile>` state and config
  paths, so every named profile was classified as isolated state and refused
  `install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair,
  and self-update service handling.
- `OPENCLAW_HOME` relocates all OpenClaw path defaults and is documented for
  running as a dedicated service user; a relocated home is now an install
  identity. `HOME` alone still is not.
- An `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` pointing outside those
  canonical paths is still treated as isolated state.
- Recovery guidance in the refusal message now names the paths that must match.

Verified: focused vitest shards for the changed suites plus the daemon, CLI,
and doctor suites that consume the identity check; tsgo core and core-test
lanes; oxlint; docs format, MDX, link, and map checks.

* fix(gateway): keep relocated homes isolated

* fix(config): validate service profile identity

* fix(daemon): enforce named-profile service ownership

* fix(update): reject drifted service selectors before probes

* test(windows): prove scheduled task lifecycle

* test(windows): harden scheduled task proof cleanup

* test(windows): bind lifecycle proof to checkout

* test(windows): normalize cleanup exit status

* test(windows): verify effective task privilege

* test(windows): protect scheduled task proof roots

* test(windows): prove listener-owned task lifecycle

* test(windows): fix scheduled task proof contracts

* test(windows): remove redundant mock coercions

* test(windows): measure fallback before task probes

* test(windows): prove scheduled task process origin

* fix(gateway): preserve unmanaged restart fallback

* test(gateway): cover denied restart ownership

* test(gateway): keep restart helper types private

* test(gateway): classify lifecycle helpers as test code

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Sasan
2026-07-31 23:28:39 -04:00
committed by GitHub
parent 4376387791
commit 6938f7dddb
31 changed files with 2520 additions and 117 deletions
+116 -1
View File
@@ -44,6 +44,11 @@ const resolveGlobalManager = vi.fn();
const serviceLoaded = vi.fn();
const serviceStop = vi.fn();
const serviceRestart = vi.fn();
const isDefaultInstallIdentity = vi.hoisted(() =>
vi.fn<(env?: NodeJS.ProcessEnv, homedir?: () => string, platform?: NodeJS.Platform) => boolean>(
() => true,
),
);
const suspendScheduledTaskAutoStartForUpdate = vi.fn();
const resumeScheduledTaskAutoStartAfterUpdate = vi.fn();
const prepareRestartScript = vi.fn();
@@ -322,7 +327,12 @@ vi.mock("../config/backup-rotation.js", () => ({
}));
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: async () => {
readGatewayServiceState: async (
_service: unknown,
args?: {
validateEnvBeforeStatusRead?: (env: NodeJS.ProcessEnv) => void;
},
) => {
const command = await serviceReadCommand();
const env = {
...process.env,
@@ -330,6 +340,7 @@ vi.mock("../daemon/service.js", () => ({
? (command.environment as NodeJS.ProcessEnv | undefined)
: undefined),
};
args?.validateEnvBeforeStatusRead?.(env);
const [loaded, runtime] = await Promise.all([
serviceLoaded({ env }).catch(() => false),
serviceReadRuntime(env).catch(() => undefined),
@@ -365,6 +376,15 @@ vi.mock("../daemon/schtasks.js", () => ({
resumeScheduledTaskAutoStartAfterUpdate(...args),
}));
vi.mock("../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/paths.js")>()),
isDefaultInstallIdentity: (
env?: NodeJS.ProcessEnv,
homedir?: () => string,
platform?: NodeJS.Platform,
) => isDefaultInstallIdentity(env, homedir, platform),
}));
vi.mock("../infra/ports.js", () => ({
inspectPortUsage: (...args: unknown[]) => inspectPortUsage(...args),
classifyPortListener: (...args: unknown[]) => classifyPortListener(...args),
@@ -1364,6 +1384,8 @@ describe("update-cli", () => {
resolveGlobalManager.mockResolvedValue("npm");
serviceStop.mockResolvedValue(undefined);
serviceRestart.mockResolvedValue({ outcome: "completed" });
isDefaultInstallIdentity.mockReset();
isDefaultInstallIdentity.mockReturnValue(true);
suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(false);
resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(false);
serviceLoaded.mockResolvedValue(false);
@@ -4206,6 +4228,99 @@ describe("update-cli", () => {
processOffSpy.mockRestore();
});
it("does not inspect or mutate a Windows host service from an isolated install", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const tempDir = await createTrackedTempDir("openclaw-update-isolated-service-");
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
mockRunningManagedGateway();
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
isDefaultInstallIdentity.mockReturnValue(false);
await withEnvAsync({ OPENCLAW_HOME: path.join(tempDir, "relocated-home") }, async () => {
await updateCommand({ yes: true });
});
platformSpy.mockRestore();
expect(isDefaultInstallIdentity).toHaveBeenCalled();
expect(serviceReadCommand).not.toHaveBeenCalled();
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
expect(serviceStop).not.toHaveBeenCalled();
expect(prepareRestartScript).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeDefined();
});
it.each([
{
platform: "darwin" as const,
envKey: "OPENCLAW_LAUNCHD_LABEL",
value: "ai.openclaw.gateway",
},
{
platform: "linux" as const,
envKey: "OPENCLAW_SYSTEMD_UNIT",
value: "openclaw-gateway.service",
},
{
platform: "win32" as const,
envKey: "OPENCLAW_WINDOWS_TASK_NAME",
value: "OpenClaw Gateway",
},
])(
"does not reuse a conflicting $envKey selector from the managed service on $platform",
async ({ platform, envKey, value }) => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform);
const tempDir = await createTrackedTempDir(`openclaw-update-${platform}-selector-`);
const home = path.join(tempDir, "home");
const stateDir = path.join(home, ".openclaw-work");
const { nodeModules } = await setupInstalledPackageRoot(tempDir);
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_PROFILE: "work",
[envKey]: value,
},
});
serviceLoaded.mockResolvedValue(true);
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
try {
await withEnvAsync(
{
HOME: home,
USERPROFILE: undefined,
OPENCLAW_HOME: undefined,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
[envKey]: undefined,
},
async () => {
await updateCommand({ yes: true });
},
);
} finally {
platformSpy.mockRestore();
}
expect(isDefaultInstallIdentity).toHaveBeenCalled();
expect(serviceReadRuntime).not.toHaveBeenCalled();
expect(suspendScheduledTaskAutoStartForUpdate).not.toHaveBeenCalled();
expect(serviceStop).not.toHaveBeenCalled();
expect(serviceRestart).not.toHaveBeenCalled();
expect(prepareRestartScript).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(packageInstallCommandCall()).toBeUndefined();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(getErrorOutput()).toContain(envKey);
},
);
it("restores Windows Scheduled Task autostart when service stop fails", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
mockPackageInstallStatus(createCaseDir("openclaw-update-stop-failure"));