From 0b6ebf33434be038ffbb988e96a85ed35d641b63 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 08:39:12 +0100 Subject: [PATCH] fix(doctor): honor external service repair policy --- .../doctor-gateway-daemon-flow.test.ts | 71 +++++++++++++++++++ src/commands/doctor-gateway-daemon-flow.ts | 65 +++++++++++++---- src/commands/doctor-gateway-services.test.ts | 56 +++++++++++++++ src/commands/doctor-gateway-services.ts | 63 ++++++++++++---- src/commands/doctor-prompter.ts | 13 ++++ src/commands/doctor-service-repair-policy.ts | 48 +++++++++++++ 6 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 src/commands/doctor-service-repair-policy.ts diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 2aac08f2155..d8d66f31527 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -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"); + }); }); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index ccc865bd3b6..ff03a068fa9 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -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 { 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( { @@ -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, diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 9da95453d9f..6b09c866fc8 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -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.", + ); + }); + }); }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 4aa79502059..35d411d2211 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -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 } = diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index 32f08f53d5b..cfd40385d5c 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -16,6 +16,7 @@ export type DoctorPrompter = { confirmAutoFix: (params: Parameters[0]) => Promise; confirmAggressiveAutoFix: (params: Parameters[0]) => Promise; confirmRuntimeRepair: (params: Parameters[0]) => Promise; + confirmServiceRepair?: (params: Parameters[0]) => Promise; select: (params: Parameters[0], fallback: T) => Promise; 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 (p: Parameters[0], fallback: T) => { if (!repairMode.canPrompt || repairMode.shouldRepair) { return fallback; diff --git a/src/commands/doctor-service-repair-policy.ts b/src/commands/doctor-service-repair-policy.ts new file mode 100644 index 00000000000..f2b64d967e3 --- /dev/null +++ b/src/commands/doctor-service-repair-policy.ts @@ -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[0], + policy: ServiceRepairPolicy = resolveServiceRepairPolicy(), +): Promise { + 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); +}