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
+1
View File
@@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({
vi.mock("../../config/paths.js", () => ({
isDefaultInstallIdentity: isDefaultInstallIdentityMock,
resolveNativeServiceProfileConflict: () => null,
resolveGatewayPort: resolveGatewayPortMock,
resolveIsNixMode: resolveIsNixModeMock,
}));
+49
View File
@@ -254,6 +254,55 @@ describe("runServiceRestart token drift", () => {
);
});
it("runs the service mutation guard before restarting a loaded service", async () => {
const beforeServiceMutation = vi.fn();
await runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation,
});
expect(beforeServiceMutation).toHaveBeenCalledTimes(1);
expect(beforeServiceMutation.mock.invocationCallOrder[0]).toBeLessThan(
service.restart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
it("aborts loaded-service mutation when the service guard rejects", async () => {
const repairLoadedService = vi.fn();
await expect(
runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation: () => {
throw new Error("service mutation denied");
},
repairLoadedService,
}),
).rejects.toThrow("service mutation denied");
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(repairLoadedService).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
});
it("does not run the service mutation guard before not-loaded recovery", async () => {
service.isLoaded.mockResolvedValue(false);
const beforeServiceMutation = vi.fn();
await runServiceRestart({
...createServiceRunArgs(),
beforeServiceMutation,
onNotLoaded: async () => ({
result: "restarted",
message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.",
}),
});
expect(beforeServiceMutation).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
});
it("repairs managed port drift before restarting", async () => {
service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 });
service.readCommand.mockResolvedValue({
+7
View File
@@ -459,6 +459,7 @@ export async function runServiceRestart(params: {
opts?: DaemonLifecycleOptions;
checkTokenDrift?: boolean;
expectedPort?: number;
beforeServiceMutation?: () => void;
repairLoadedService?: (
ctx: ServiceStartRepairContext,
) => Promise<ServiceRecoveryResult<"restarted"> | null>;
@@ -533,6 +534,12 @@ export async function runServiceRestart(params: {
}
}
// Loaded services cross the native mutation boundary here. Not-loaded recovery
// may still target a separately verified unmanaged listener.
if (loaded) {
params.beforeServiceMutation?.();
}
if (!loaded) {
try {
handledRecovery = (await params.onNotLoaded?.({ json, stdout, warn, fail })) ?? null;
@@ -0,0 +1,41 @@
type RestartPostCheckContext = {
json: boolean;
stdout: NodeJS.WritableStream;
warnings: string[];
fail: (message: string, hints?: string[]) => void;
};
export type RestartParams = {
opts?: { json?: boolean };
beforeServiceMutation?: () => void;
repairLoadedService?: (ctx: {
json: boolean;
stdout: NodeJS.WritableStream;
state: unknown;
issues: unknown[];
}) => Promise<unknown>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
};
export function requireMockCallArg(
mockFn: { mock: { calls: unknown[][] } },
label: string,
index = 0,
): Record<string, unknown> {
const arg = mockFn.mock.calls[index]?.[0] as Record<string, unknown> | undefined;
if (!arg) {
throw new Error(`expected ${label} call #${index + 1}`);
}
return arg;
}
export async function expectRestartError(
promise: Promise<unknown>,
): Promise<Error & { hints?: string[] }> {
try {
await promise;
} catch (error) {
return error as Error & { hints?: string[] };
}
throw new Error("expected restart to fail");
}
+49 -68
View File
@@ -1,6 +1,11 @@
// Daemon lifecycle tests cover CLI service lifecycle orchestration and cleanup.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import {
expectRestartError,
requireMockCallArg,
type RestartParams,
} from "./lifecycle.test-helpers.js";
type RestartHealthSnapshot = {
healthy: boolean;
@@ -11,30 +16,13 @@ type RestartHealthSnapshot = {
elapsedMs?: number;
};
type RestartPostCheckContext = {
json: boolean;
stdout: NodeJS.WritableStream;
warnings: string[];
fail: (message: string, hints?: string[]) => void;
};
type RestartParams = {
opts?: { json?: boolean };
repairLoadedService?: (ctx: {
json: boolean;
stdout: NodeJS.WritableStream;
state: unknown;
issues: unknown[];
}) => Promise<unknown>;
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
};
const service = {
readCommand: vi.fn(),
readRuntime: vi.fn(),
restart: vi.fn(),
stop: vi.fn(),
};
const isDefaultInstallIdentity = vi.hoisted(() => vi.fn(() => true));
const runServiceStart = vi.fn();
const runServiceRestart = vi.fn();
@@ -99,29 +87,6 @@ const createGatewayLifecycleMutationAudit = vi.fn(
}),
);
function requireMockCallArg(
mockFn: { mock: { calls: unknown[][] } },
label: string,
index = 0,
): Record<string, unknown> {
const arg = mockFn.mock.calls[index]?.[0] as Record<string, unknown> | undefined;
if (!arg) {
throw new Error(`expected ${label} call #${index + 1}`);
}
return arg;
}
async function expectRestartError(
promise: Promise<unknown>,
): Promise<Error & { hints?: string[] }> {
try {
await promise;
} catch (error) {
return error as Error & { hints?: string[] };
}
throw new Error("expected restart to fail");
}
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: () => loadConfig(),
loadConfig: () => loadConfig(),
@@ -129,7 +94,10 @@ vi.mock("../../config/config.js", () => ({
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
}));
vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: () => true }));
vi.mock("../../config/paths.js", () => ({
isDefaultInstallIdentity: () => isDefaultInstallIdentity(),
resolveNativeServiceProfileConflict: () => null,
}));
vi.mock("../../infra/gateway-processes.js", () => ({
findVerifiedGatewayListenerPidsOnPortSync,
@@ -276,13 +244,12 @@ describe("runDaemonRestart health checks", () => {
]);
delete process.env.OPENCLAW_CONTAINER_HINT;
service.readCommand.mockReset();
service.readRuntime.mockReset();
service.readRuntime.mockResolvedValue({ status: "stopped" });
service.restart.mockReset();
service.readRuntime.mockReset().mockResolvedValue({ status: "stopped" });
service.restart.mockReset().mockResolvedValue({ outcome: "completed" });
service.stop.mockReset();
runServiceStart.mockReset();
runServiceStart.mockReset().mockResolvedValue(undefined);
runServiceRestart.mockReset();
runServiceStop.mockReset();
runServiceStop.mockReset().mockResolvedValue(undefined);
waitForGatewayHealthyListener.mockReset();
waitForGatewayHealthyRestart.mockReset();
terminateStaleGatewayPids.mockReset();
@@ -290,43 +257,36 @@ describe("runDaemonRestart health checks", () => {
renderRestartDiagnostics.mockReset();
resolveGatewayPort.mockReset();
findVerifiedGatewayListenerPidsOnPortSync.mockReset();
signalVerifiedGatewayPidSync.mockReset();
writeGatewayRestartIntentSync.mockReset();
signalVerifiedGatewayPidSync.mockReset().mockImplementation(() => {});
writeGatewayRestartIntentSync.mockReset().mockReturnValue(true);
clearGatewayRestartIntentSync.mockReset();
formatGatewayPidList.mockReset();
formatGatewayPidList.mockReset().mockImplementation((pids) => pids.join(", "));
probeGateway.mockReset();
callGatewayCli.mockReset();
isRestartEnabled.mockReset();
loadConfig.mockReset();
readActiveGatewayLockPort.mockReset();
readActiveGatewayLockPort.mockReset().mockResolvedValue(undefined);
readActiveGatewayLockIdentity.mockReset();
recoverInstalledLaunchAgent.mockReset();
recoverInstalledLaunchAgent.mockReset().mockResolvedValue(null);
repairLoadedGatewayServiceForStart.mockReset();
isTerminalInteractive.mockReset();
isTerminalInteractive.mockReturnValue(true);
isTerminalInteractive.mockReset().mockReturnValue(true);
appendGatewayLifecycleAudit.mockClear();
createGatewayLifecycleMutationAudit.mockClear();
isDefaultInstallIdentity.mockReset().mockReturnValue(true);
service.readCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "--port", "18789"],
environment: {},
});
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceStart.mockResolvedValue(undefined);
recoverInstalledLaunchAgent.mockResolvedValue(null);
readActiveGatewayLockPort.mockResolvedValue(undefined);
readActiveGatewayLockIdentity.mockResolvedValue({
pid: 4200,
ownerId: "gateway-owner-old",
createdAt: "2026-07-16T12:00:00.000Z",
port: 18_789,
});
findInstalledSystemdGatewayScope.mockReset();
findInstalledSystemdGatewayScope.mockResolvedValue(null);
restartSystemdService.mockReset();
restartSystemdService.mockResolvedValue({ outcome: "completed" });
stopSystemdService.mockReset();
stopSystemdService.mockResolvedValue(undefined);
findInstalledSystemdGatewayScope.mockReset().mockResolvedValue(null);
restartSystemdService.mockReset().mockResolvedValue({ outcome: "completed" });
stopSystemdService.mockReset().mockResolvedValue(undefined);
runServiceRestart.mockImplementation(async (params: RestartParams) => {
const fail = (message: string, hints?: string[]) => {
@@ -342,7 +302,6 @@ describe("runDaemonRestart health checks", () => {
});
return true;
});
runServiceStop.mockResolvedValue(undefined);
waitForGatewayHealthyListener.mockResolvedValue({
healthy: true,
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
@@ -383,9 +342,6 @@ describe("runDaemonRestart health checks", () => {
},
});
isRestartEnabled.mockReturnValue(true);
signalVerifiedGatewayPidSync.mockImplementation(() => {});
writeGatewayRestartIntentSync.mockReturnValue(true);
formatGatewayPidList.mockImplementation((pids) => pids.join(", "));
});
afterEach(() => {
@@ -417,6 +373,16 @@ describe("runDaemonRestart health checks", () => {
expect(requireMockCallArg(runServiceRestart, "runServiceRestart").expectedPort).toBeUndefined();
});
it("guards loaded service restart at the native mutation boundary", async () => {
await runDaemonRestart({ json: true });
const restartParams = requireMockCallArg(runServiceRestart, "runServiceRestart");
isDefaultInstallIdentity.mockReturnValue(false);
expect(() => (restartParams.beforeServiceMutation as () => void)()).toThrow(
/non-default state dir/,
);
});
it("uses the installed service environment for managed restart health", async () => {
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-caller-state";
process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway-maintenance.service";
@@ -840,12 +806,15 @@ describe("runDaemonRestart health checks", () => {
});
it("signals a single unmanaged gateway process on restart", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
isDefaultInstallIdentity.mockReturnValue(false);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
mockUnmanagedRestart({ runPostRestartCheck: true });
await runDaemonRestart({ json: true });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
expect(findInstalledSystemdGatewayScope).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({
action: "restart",
@@ -860,6 +829,17 @@ describe("runDaemonRestart health checks", () => {
expect(service.restart).not.toHaveBeenCalled();
});
it("rejects denied Darwin recovery when no unmanaged listener exists", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
isDefaultInstallIdentity.mockReturnValue(false);
mockUnmanagedRestart();
await expect(runDaemonRestart({ json: true })).rejects.toThrow(/non-default state dir/);
expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
});
it("uses targeted RPC for an unmanaged Windows gateway restart", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
@@ -1025,6 +1005,7 @@ describe("runDaemonRestart health checks", () => {
});
it("fails unmanaged restart when multiple gateway listeners are present", async () => {
isDefaultInstallIdentity.mockReturnValue(false);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4300]);
mockUnmanagedRestart();
+12 -9
View File
@@ -28,6 +28,7 @@ import {
assertGatewayServiceMutationAllowed,
formatExternalSupervisorActionRequired,
isGatewayExternallySupervised,
resolveGatewayServiceMutationError,
} from "../../infra/gateway-supervision.js";
import {
clearGatewayRestartIntentSync,
@@ -383,16 +384,13 @@ async function signalGatewayRestart(
};
}
async function restartGatewayWithoutServiceManager(
port: number,
restartIntent?: GatewayRestartIntent,
) {
const managed = await handleSystemScopeSystemdGateway("restart");
async function restartUnmanaged(port: number, intent?: GatewayRestartIntent, allowSystem = true) {
const managed = allowSystem ? await handleSystemScopeSystemdGateway("restart") : null;
if (managed) {
return managed;
}
return await signalGatewayRestart(port, {
restartIntent,
restartIntent: intent,
enforceRestartConfig: true,
processLabel: "unmanaged",
auditSource: "cli",
@@ -402,7 +400,7 @@ async function restartGatewayWithoutServiceManager(
type GatewaySignalRestartResult = NonNullable<Awaited<ReturnType<typeof signalGatewayRestart>>>;
function isGatewaySignalRestartResult(
result: Awaited<ReturnType<typeof restartGatewayWithoutServiceManager>>,
result: Awaited<ReturnType<typeof restartUnmanaged>>,
): result is GatewaySignalRestartResult {
return result !== null && "pid" in result && typeof result.pid === "number";
}
@@ -595,6 +593,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
},
checkTokenDrift: true,
expectedPort: configuredPort,
beforeServiceMutation: () => assertGatewayServiceMutationAllowed("restart the gateway"),
repairLoadedService: async ({ json, stdout, warn, state, issues }) => {
const result = await repairLoadedGatewayServiceForStart({
action: "restart",
@@ -612,7 +611,8 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
return result;
},
onNotLoaded: async () => {
if (process.platform === "darwin") {
const mutationError = resolveGatewayServiceMutationError("restart the gateway");
if (process.platform === "darwin" && !mutationError) {
const recovered = await recoverInstalledLaunchAgent({ result: "restarted" });
if (recovered) {
appendGatewayLifecycleAudit({
@@ -623,7 +623,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
return recovered;
}
}
const handled = await restartGatewayWithoutServiceManager(unmanagedPort, restartIntent);
const handled = await restartUnmanaged(unmanagedPort, restartIntent, !mutationError);
if (handled) {
restartedWithoutServiceManager = true;
if (isGatewaySignalRestartResult(handled) && handled.previousLockIdentity) {
@@ -635,6 +635,9 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
return handled;
}
if (mutationError) {
throw mutationError;
}
return null;
},
postRestartCheck: async ({ warnings, fail, stdout, warn }) => {