mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:30:43 +00:00
fix(doctor): honor external service repair policy
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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;
|
||||
|
||||
48
src/commands/doctor-service-repair-policy.ts
Normal file
48
src/commands/doctor-service-repair-policy.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user