fix(doctor): keep noninteractive service repair explicit

This commit is contained in:
Vincent Koc
2026-05-01 03:30:17 -07:00
parent 0e8cb3d94b
commit 24fc40b133
5 changed files with 54 additions and 11 deletions

View File

@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc.
- Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram.
- Doctor/plugins: keep plain `doctor --non-interactive` from installing bundled plugin runtime dependencies, so headless health checks report missing deps while `doctor --fix` remains the explicit repair path. Thanks @vincentkoc.
- Doctor/gateway: require an interactive confirmation before installing or rewriting the Gateway service, so `doctor --fix --non-interactive` can repair plugin/config drift without replacing the operator's launchd/systemd service from a temporary environment. Thanks @vincentkoc.
- Plugins/runtime-deps: include packaged OpenClaw identity in bundled plugin loader cache keys, so same-path package upgrades stop reusing stale versioned runtime-deps mirrors. Fixes #75045. Thanks @sahilsatralkar.
- Plugin SDK: restore reply-prefix and reply-pipeline helpers on the deprecated root/compat SDK surface so external plugins still using `openclaw/plugin-sdk` do not fail message dispatch after update. Fixes #75171. Thanks @zhangxiliang.
- Plugins/runtime-deps: prune inactive same-package versioned runtime-deps roots after bundled dependency repair, so upgrades do not leave old `openclaw-<version>-<hash>` package caches behind after doctor runs. Thanks @vincentkoc.

View File

@@ -185,6 +185,10 @@ describe("maybeRepairGatewayDaemon", () => {
async function runNonInteractiveUpdateRepair() {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
await runNonInteractiveRepair();
}
async function runNonInteractiveRepair() {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await maybeRepairGatewayDaemon({
cfg: { gateway: {} },
@@ -256,6 +260,20 @@ describe("maybeRepairGatewayDaemon", () => {
expect(service.restart).not.toHaveBeenCalled();
});
it("skips gateway install during non-interactive doctor repairs", async () => {
setPlatform("linux");
service.isLoaded.mockResolvedValue(false);
await runNonInteractiveRepair();
expect(service.install).not.toHaveBeenCalled();
expect(service.restart).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(
expect.stringContaining("openclaw gateway install"),
"Gateway",
);
});
it("skips gateway restart during non-interactive update repairs", async () => {
setPlatform("linux");

View File

@@ -199,9 +199,16 @@ export async function maybeRepairGatewayDaemon(params: {
{
message: "Install gateway service now?",
initialValue: true,
requiresInteractiveConfirmation: true,
},
serviceRepairPolicy,
);
if (!install) {
note(
`Run ${formatCliCommand("openclaw gateway install")} when you want to install the gateway service.`,
"Gateway",
);
}
if (install) {
const daemonRuntime = await params.prompter.select<GatewayDaemonRuntime>(
{

View File

@@ -664,7 +664,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect(mocks.stage).not.toHaveBeenCalled();
});
it("repairs entrypoint mismatch in non-interactive fix mode", async () => {
it("skips entrypoint rewrite in non-interactive fix mode", async () => {
setupGatewayEntrypointRepairScenario({
currentEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/entry.js",
installEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/index.js",
@@ -680,8 +680,12 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect.stringContaining("Gateway service entrypoint does not match the current install."),
"Gateway service config",
);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("openclaw gateway install --force"),
"Gateway service config",
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1);
expect(mocks.install).not.toHaveBeenCalled();
});
it("stages service config repairs during non-interactive update repairs", async () => {

View File

@@ -509,19 +509,32 @@ export async function maybeRepairGatewayServiceConfig(
return;
}
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,
const updateRepairMode = isDoctorUpdateRepairMode(prompter.repairMode);
const repairMessage = needsAggressive
? "Overwrite gateway service config with current defaults now?"
: "Update gateway service config to the recommended defaults now?";
const repair = updateRepairMode
? needsAggressive
? await prompter.confirmAggressiveAutoFix({
message: repairMessage,
initialValue: prompter.shouldForce,
})
: await prompter.confirmAutoFix({
message: repairMessage,
initialValue: true,
})
: await prompter.confirmRuntimeRepair({
message: repairMessage,
initialValue: needsAggressive ? prompter.shouldForce : true,
requiresInteractiveConfirmation: true,
});
if (!repair) {
note(
"Run `openclaw gateway install --force` when you want to replace the gateway service definition.",
"Gateway service config",
);
return;
}
const updateRepairMode = isDoctorUpdateRepairMode(prompter.repairMode);
const serviceEmbeddedToken = readEmbeddedGatewayToken(command);
const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken;
const configuredGatewayToken =