fix: doctor skips host services for isolated state (#115922)

* fix(doctor): isolate host service management

* fix(doctor): clarify service isolation recovery

* test(doctor): isolate service identity fixtures

* test(daemon): keep lifecycle fixtures lint-clean

* test(daemon): isolate install identity fixtures
This commit is contained in:
Peter Steinberger
2026-07-29 11:09:56 -04:00
committed by GitHub
parent 6ec3dbd92d
commit 383f8947c1
27 changed files with 442 additions and 22 deletions
@@ -22,6 +22,12 @@ const serviceMock = vi.hoisted(() => ({
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
}));
vi.mock("../../config/paths.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/paths.js")>("../../config/paths.js");
return { ...actual, isDefaultInstallIdentity: () => true };
});
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => serviceMock,
}));
+17
View File
@@ -13,6 +13,7 @@ const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn());
const loadConfigMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const isDefaultInstallIdentityMock = vi.hoisted(() => vi.fn(() => true));
const replaceConfigFileMock = vi.hoisted(() => vi.fn());
const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false));
const resolveSecretInputRefMock = vi.hoisted(() =>
@@ -102,6 +103,7 @@ vi.mock("../../config/mutate.js", () => ({
}));
vi.mock("../../config/paths.js", () => ({
isDefaultInstallIdentity: isDefaultInstallIdentityMock,
resolveGatewayPort: resolveGatewayPortMock,
resolveIsNixMode: resolveIsNixModeMock,
}));
@@ -276,6 +278,7 @@ describe("runDaemonInstall", () => {
resolveNodeStartupTlsEnvironmentMock.mockReset();
readConfigFileSnapshotMock.mockReset();
resolveGatewayPortMock.mockClear();
isDefaultInstallIdentityMock.mockReturnValue(true);
replaceConfigFileMock.mockReset();
resolveIsNixModeMock.mockReset();
resolveSecretInputRefMock.mockReset();
@@ -362,6 +365,20 @@ describe("runDaemonInstall", () => {
expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled();
});
it("blocks non-default install identities before inspecting host services", async () => {
isDefaultInstallIdentityMock.mockReturnValue(false);
await runDaemonInstall({ json: true });
expect(actionState.failed[0]?.message).toContain(
"service management skipped: non-default state dir or config path",
);
expect(readConfigFileSnapshotMock).not.toHaveBeenCalled();
expect(service.isLoaded).not.toHaveBeenCalled();
expect(service.readCommand).not.toHaveBeenCalled();
expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled();
});
it("validates token SecretRef but does not serialize resolved token into service env", async () => {
mockResolvedGatewayTokenSecretRef();
+5 -8
View File
@@ -25,10 +25,7 @@ import {
isLoopbackHost,
resolveGatewayBindHost,
} from "../../gateway/net.js";
import {
formatExternalSupervisorActionRequired,
isGatewayExternallySupervised,
} from "../../infra/gateway-supervision.js";
import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
@@ -149,10 +146,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (failIfNixDaemonInstallMode(fail)) {
return;
}
if (isGatewayExternallySupervised()) {
fail(
`Gateway install blocked: ${formatExternalSupervisorActionRequired("install or rewrite the gateway service")}`,
);
try {
assertGatewayServiceMutationAllowed("install or rewrite the gateway service");
} catch (error) {
fail(`Gateway install blocked: ${String(error)}`);
return;
}
+3 -2
View File
@@ -129,9 +129,10 @@ vi.mock("../../config/config.js", () => ({
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
}));
vi.mock("../../config/paths.js", () => ({ isDefaultInstallIdentity: () => true }));
vi.mock("../../infra/gateway-processes.js", () => ({
findVerifiedGatewayListenerPidsOnPortSync: (port: number) =>
findVerifiedGatewayListenerPidsOnPortSync(port),
findVerifiedGatewayListenerPidsOnPortSync,
signalVerifiedGatewayPidSync: (pid: number, signal: "SIGTERM" | "SIGUSR1") =>
signalVerifiedGatewayPidSync(pid, signal),
formatGatewayPidList: (pids: number[]) => formatGatewayPidList(pids),
+6
View File
@@ -34,6 +34,7 @@ const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const resolveOpenClawWrapperPathMock = vi.hoisted(() => vi.fn());
const formatGatewayServiceStartRepairIssuesMock = vi.hoisted(() => vi.fn());
const defaultRuntimeLogMock = vi.hoisted(() => vi.fn());
const assertGatewayServiceMutationAllowedMock = vi.hoisted(() => vi.fn());
vi.mock("../../commands/daemon-install-helpers.js", () => ({
buildGatewayInstallPlan: buildGatewayInstallPlanMock,
@@ -64,6 +65,10 @@ vi.mock("../../daemon/service.js", () => ({
formatGatewayServiceStartRepairIssues: formatGatewayServiceStartRepairIssuesMock,
}));
vi.mock("../../infra/gateway-supervision.js", () => ({
assertGatewayServiceMutationAllowed: assertGatewayServiceMutationAllowedMock,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: { log: defaultRuntimeLogMock },
}));
@@ -87,6 +92,7 @@ describe("repairLoadedGatewayServiceForStart", () => {
resolveOpenClawWrapperPathMock.mockReset();
formatGatewayServiceStartRepairIssuesMock.mockReset();
defaultRuntimeLogMock.mockClear();
assertGatewayServiceMutationAllowedMock.mockReset();
resolveGatewayInstallTokenMock.mockResolvedValue({
tokenRefConfigured: false,
+2
View File
@@ -12,6 +12,7 @@ import type {
GatewayServiceState,
} from "../../daemon/service.js";
import { formatGatewayServiceStartRepairIssues } from "../../daemon/service.js";
import { assertGatewayServiceMutationAllowed } from "../../infra/gateway-supervision.js";
import { parseTcpPort, parseTcpPortFromArgs } from "../../infra/tcp-port.js";
import { defaultRuntime } from "../../runtime.js";
import { mergeInstallInvocationEnv } from "./install.js";
@@ -48,6 +49,7 @@ export async function repairLoadedGatewayServiceForStart(
warnings?: string[];
loaded: boolean;
}> {
assertGatewayServiceMutationAllowed("repair the gateway service");
const { snapshot: configSnapshot, writeOptions: configWriteOptions } =
await readConfigFileSnapshotForWrite();
const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config;