mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
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:
@@ -104,6 +104,7 @@ vi.mock("../../config/mutate.js", () => ({
|
||||
|
||||
vi.mock("../../config/paths.js", () => ({
|
||||
isDefaultInstallIdentity: isDefaultInstallIdentityMock,
|
||||
resolveNativeServiceProfileConflict: () => null,
|
||||
resolveGatewayPort: resolveGatewayPortMock,
|
||||
resolveIsNixMode: resolveIsNixModeMock,
|
||||
}));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user