fix(doctor): honor external service repair policy

This commit is contained in:
Shakker
2026-04-26 08:39:12 +01:00
parent d24c6095ce
commit 0b6ebf3343
6 changed files with 290 additions and 26 deletions

View File

@@ -1,5 +1,8 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as launchd from "../daemon/launchd.js";
import { withEnvAsync } from "../test-utils/env.js";
import { createDoctorPrompter } from "./doctor-prompter.js";
import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js";
const service = vi.hoisted(() => ({
isLoaded: vi.fn(),
@@ -187,6 +190,22 @@ describe("maybeRepairGatewayDaemon", () => {
});
}
async function runAutoRepair() {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await maybeRepairGatewayDaemon({
cfg: { gateway: {} },
runtime,
prompter: createDoctorPrompter({
runtime,
options: { repair: true },
}),
options: { deep: false, repair: true },
gatewayDetailsMessage: "details",
healthOk: false,
});
return runtime;
}
async function runScheduledGatewayRepair(confirmMessage: string) {
setPlatform("linux");
service.restart.mockResolvedValueOnce({ outcome: "scheduled" });
@@ -235,4 +254,56 @@ describe("maybeRepairGatewayDaemon", () => {
expect(service.restart).not.toHaveBeenCalled();
});
it("skips gateway service install when service repair policy is external", async () => {
setPlatform("linux");
service.isLoaded.mockResolvedValue(false);
await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => {
await runAutoRepair();
});
expect(service.install).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
});
it("skips gateway service start when service repair policy is external", async () => {
setPlatform("linux");
service.readRuntime.mockResolvedValue({ status: "stopped" });
await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => {
await runAutoRepair();
});
expect(service.restart).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
});
it("skips gateway service restart when service repair policy is external", async () => {
setPlatform("linux");
await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => {
await runAutoRepair();
});
expect(service.restart).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
});
it("skips LaunchAgent bootstrap repair when service repair policy is external", async () => {
setPlatform("darwin");
service.isLoaded.mockResolvedValue(false);
vi.mocked(launchd.isLaunchAgentListed).mockResolvedValue(true);
vi.mocked(launchd.isLaunchAgentLoaded).mockResolvedValue(false);
vi.mocked(launchd.launchAgentPlistExists).mockResolvedValue(true);
await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => {
await runAutoRepair();
});
expect(launchd.repairLaunchAgentBootstrap).not.toHaveBeenCalled();
expect(service.install).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway LaunchAgent");
});
});

View File

@@ -28,6 +28,12 @@ import {
} from "./daemon-runtime.js";
import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary } from "./doctor-format.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import {
confirmDoctorServiceRepair,
EXTERNAL_SERVICE_REPAIR_NOTE,
isServiceRepairExternallyManaged,
resolveServiceRepairPolicy,
} from "./doctor-service-repair-policy.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { healthCommand } from "./health.js";
@@ -37,6 +43,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
title: string;
runtime: RuntimeEnv;
prompter: DoctorPrompter;
serviceRepairExternal: boolean;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
@@ -58,8 +65,12 @@ async function maybeRepairLaunchAgentBootstrap(params: {
}
note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
if (params.serviceRepairExternal) {
note(EXTERNAL_SERVICE_REPAIR_NOTE, `${params.title} LaunchAgent`);
return false;
}
const shouldFix = await params.prompter.confirmRuntimeRepair({
const shouldFix = await confirmDoctorServiceRepair(params.prompter, {
message: `Repair ${params.title} LaunchAgent bootstrap now?`,
initialValue: true,
});
@@ -98,6 +109,8 @@ export async function maybeRepairGatewayDaemon(params: {
return;
}
const serviceRepairPolicy = resolveServiceRepairPolicy();
const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy);
const service = resolveGatewayService();
// systemd can throw in containers/WSL; treat as "not loaded" and fall back to hints.
let loaded = false;
@@ -117,6 +130,7 @@ export async function maybeRepairGatewayDaemon(params: {
title: "Gateway",
runtime: params.runtime,
prompter: params.prompter,
serviceRepairExternal,
});
await maybeRepairLaunchAgentBootstrap({
env: {
@@ -126,6 +140,7 @@ export async function maybeRepairGatewayDaemon(params: {
title: "Node",
runtime: params.runtime,
prompter: params.prompter,
serviceRepairExternal,
});
if (gatewayRepaired) {
loaded = await service.isLoaded({ env: process.env });
@@ -162,10 +177,18 @@ export async function maybeRepairGatewayDaemon(params: {
}
note("Gateway service not installed.", "Gateway");
if (params.cfg.gateway?.mode !== "remote") {
const install = await params.prompter.confirmRuntimeRepair({
message: "Install gateway service now?",
initialValue: true,
});
if (serviceRepairExternal) {
note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
return;
}
const install = await confirmDoctorServiceRepair(
params.prompter,
{
message: "Install gateway service now?",
initialValue: true,
},
serviceRepairPolicy,
);
if (install) {
const daemonRuntime = await params.prompter.select<GatewayDaemonRuntime>(
{
@@ -233,10 +256,18 @@ export async function maybeRepairGatewayDaemon(params: {
}
if (serviceRuntime?.status !== "running") {
const start = await params.prompter.confirmRuntimeRepair({
message: "Start gateway service now?",
initialValue: true,
});
if (serviceRepairExternal) {
note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
return;
}
const start = await confirmDoctorServiceRepair(
params.prompter,
{
message: "Start gateway service now?",
initialValue: true,
},
serviceRepairPolicy,
);
if (start) {
const restartResult = await service.restart({
env: process.env,
@@ -260,10 +291,18 @@ export async function maybeRepairGatewayDaemon(params: {
}
if (serviceRuntime?.status === "running") {
const restart = await params.prompter.confirmRuntimeRepair({
message: "Restart gateway service now?",
initialValue: true,
});
if (serviceRepairExternal) {
note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway");
return;
}
const restart = await confirmDoctorServiceRepair(
params.prompter,
{
message: "Restart gateway service now?",
initialValue: true,
},
serviceRepairPolicy,
);
if (restart) {
const restartResult = await service.restart({
env: process.env,

View File

@@ -99,6 +99,7 @@ import {
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js";
import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js";
const originalStdinIsTTY = process.stdin.isTTY;
const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
@@ -593,6 +594,31 @@ describe("maybeRepairGatewayServiceConfig", () => {
},
);
});
it("reports service config drift but skips service rewrite when service repair policy is external", async () => {
await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => {
setupGatewayEntrypointRepairScenario({
currentEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/entry.js",
installEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/index.js",
installWorkingDirectory: "/tmp",
});
await runRepair({ gateway: {} });
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledTimes(1);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install."),
"Gateway service config",
);
expect(mocks.note).toHaveBeenCalledWith(
EXTERNAL_SERVICE_REPAIR_NOTE,
"Gateway service config",
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).not.toHaveBeenCalled();
});
});
});
describe("maybeScanExtraGatewayServices", () => {
@@ -655,4 +681,34 @@ describe("maybeScanExtraGatewayServices", () => {
"Legacy gateway services removed. Installing OpenClaw gateway next.",
);
});
it("reports legacy services but skips cleanup when service repair policy is external", async () => {
await withEnvAsync({ OPENCLAW_SERVICE_REPAIR_POLICY: "external" }, async () => {
mocks.findExtraGatewayServices.mockResolvedValue([
{
platform: "linux",
label: "clawdbot-gateway.service",
detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service",
scope: "user",
legacy: true,
},
]);
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await maybeScanExtraGatewayServices({ deep: false }, runtime, makeDoctorPrompts());
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("clawdbot-gateway.service"),
"Other gateway-like services detected",
);
expect(mocks.note).toHaveBeenCalledWith(
EXTERNAL_SERVICE_REPAIR_NOTE,
"Legacy gateway cleanup skipped",
);
expect(mocks.uninstallLegacySystemdUnits).not.toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalledWith(
"Legacy gateway services removed. Installing OpenClaw gateway next.",
);
});
});
});

View File

@@ -31,6 +31,12 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./dae
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { isDoctorUpdateRepairMode } from "./doctor-repair-mode.js";
import {
confirmDoctorServiceRepair,
EXTERNAL_SERVICE_REPAIR_NOTE,
isServiceRepairExternallyManaged,
resolveServiceRepairPolicy,
} from "./doctor-service-repair-policy.js";
const execFileAsync = promisify(execFile);
@@ -302,6 +308,9 @@ export async function maybeRepairGatewayServiceConfig(
return;
}
const serviceRepairPolicy = resolveServiceRepairPolicy();
const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy);
note(
audit.issues
.map((issue) =>
@@ -321,15 +330,32 @@ export async function maybeRepairGatewayServiceConfig(
);
}
const repair = needsAggressive
? await prompter.confirmAggressiveAutoFix({
message: "Overwrite gateway service config with current defaults now?",
initialValue: prompter.shouldForce,
})
: await prompter.confirmAutoFix({
message: "Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (serviceRepairExternal) {
note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway service config");
return;
}
const repair =
serviceRepairPolicy === "prompt"
? await confirmDoctorServiceRepair(
prompter,
{
message: needsAggressive
? "Overwrite gateway service config with current defaults now?"
: "Update gateway service config to the recommended defaults now?",
initialValue: needsAggressive ? prompter.shouldForce : true,
},
serviceRepairPolicy,
)
: needsAggressive
? await prompter.confirmAggressiveAutoFix({
message: "Overwrite gateway service config with current defaults now?",
initialValue: prompter.shouldForce,
})
: await prompter.confirmAutoFix({
message: "Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (!repair) {
return;
}
@@ -414,10 +440,21 @@ export async function maybeScanExtraGatewayServices(
const legacyServices = extraServices.filter((svc) => svc.legacy === true);
if (legacyServices.length > 0) {
const shouldRemove = await prompter.confirmRuntimeRepair({
message: "Remove legacy gateway services now?",
initialValue: true,
});
const serviceRepairPolicy = resolveServiceRepairPolicy();
const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy);
if (serviceRepairExternal) {
note(EXTERNAL_SERVICE_REPAIR_NOTE, "Legacy gateway cleanup skipped");
}
const shouldRemove = serviceRepairExternal
? false
: await confirmDoctorServiceRepair(
prompter,
{
message: "Remove legacy gateway services now?",
initialValue: true,
},
serviceRepairPolicy,
);
if (shouldRemove) {
const removed: string[] = [];
const { darwinUserServices, linuxUserServices, failed } =

View File

@@ -16,6 +16,7 @@ export type DoctorPrompter = {
confirmAutoFix: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAggressiveAutoFix: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRuntimeRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmServiceRepair?: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
shouldRepair: boolean;
shouldForce: boolean;
@@ -88,6 +89,18 @@ export function createDoctorPrompter(params: {
params.runtime,
);
},
confirmServiceRepair: async (p) => {
if (repairMode.nonInteractive || !repairMode.canPrompt) {
return false;
}
return guardCancel(
await confirm({
...p,
message: stylePromptMessage(p.message),
}),
params.runtime,
);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!repairMode.canPrompt || repairMode.shouldRepair) {
return fallback;

View File

@@ -0,0 +1,48 @@
import type { DoctorPrompter } from "./doctor-prompter.js";
export type ServiceRepairPolicy = "auto" | "prompt" | "external" | "disabled";
export const SERVICE_REPAIR_POLICY_ENV = "OPENCLAW_SERVICE_REPAIR_POLICY";
export const EXTERNAL_SERVICE_REPAIR_NOTE =
"Gateway service is managed externally; skipped service install/start repair. Start or repair the gateway through your supervisor.";
export function resolveServiceRepairPolicy(
env: NodeJS.ProcessEnv = process.env,
): ServiceRepairPolicy {
const value = env[SERVICE_REPAIR_POLICY_ENV]?.trim().toLowerCase();
switch (value) {
case "auto":
case "prompt":
case "external":
case "disabled":
return value;
default:
return "auto";
}
}
export function isServiceRepairExternallyManaged(
policy: ServiceRepairPolicy = resolveServiceRepairPolicy(),
): boolean {
return policy === "external" || policy === "disabled";
}
export async function confirmDoctorServiceRepair(
prompter: DoctorPrompter,
params: Parameters<DoctorPrompter["confirmRuntimeRepair"]>[0],
policy: ServiceRepairPolicy = resolveServiceRepairPolicy(),
): Promise<boolean> {
if (isServiceRepairExternallyManaged(policy)) {
return false;
}
if (policy === "prompt") {
if (!prompter.repairMode.canPrompt) {
return false;
}
return await (prompter.confirmServiceRepair?.(params) ?? prompter.confirmRuntimeRepair(params));
}
return await prompter.confirmRuntimeRepair(params);
}