From 472de0e1d53799602262b6972fd838d7887a6d20 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 1 May 2026 06:19:51 -0700 Subject: [PATCH] fix(doctor): keep plugin runtime deps repair explicit (#75603) * fix(doctor): keep plugin runtime deps repair explicit * fix(doctor): keep plugin runtime deps repair explicit * fix(doctor): keep plugin runtime deps repair explicit --------- Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + ...doctor-bundled-plugin-runtime-deps.test.ts | 93 ++++++++++++++----- .../doctor-bundled-plugin-runtime-deps.ts | 10 +- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da715be1fb..5410a1d3a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc. - 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. - 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--` package caches behind after doctor runs. Thanks @vincentkoc. diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 29aced70a5b..5605afb49de 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -121,27 +121,44 @@ function expectNoLegacyRuntimeDepsManifest(installRoot: string): void { expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false); } -function createNonInteractivePrompter( - options: { updateInProgress?: boolean } = {}, +function createNonInteractiveDoctorPrompter( + options: { + repair?: boolean; + updateInProgress?: boolean; + confirmAutoFix?: DoctorPrompter["confirmAutoFix"]; + } = {}, ): DoctorPrompter { + const shouldRepair = options.repair ?? false; return { - shouldRepair: false, + shouldRepair, shouldForce: false, repairMode: { - shouldRepair: false, + shouldRepair, shouldForce: false, nonInteractive: true, canPrompt: false, updateInProgress: options.updateInProgress ?? false, }, confirm: async () => false, - confirmAutoFix: async () => false, + confirmAutoFix: options.confirmAutoFix ?? (async () => false), confirmAggressiveAutoFix: async () => false, confirmRuntimeRepair: async () => false, select: async (_params: unknown, fallback: unknown) => fallback, } as DoctorPrompter; } +function createPlainNonInteractivePrompter( + options: { confirmAutoFix?: DoctorPrompter["confirmAutoFix"] } = {}, +): DoctorPrompter { + return createNonInteractiveDoctorPrompter(options); +} + +function createNonInteractiveRepairPrompter( + options: { updateInProgress?: boolean } = {}, +): DoctorPrompter { + return createNonInteractiveDoctorPrompter({ ...options, repair: true }); +} + function createRuntime(options: { logs?: string[]; errors?: string[] } = {}): RuntimeEnv { return { log: (message: unknown) => { @@ -487,7 +504,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -501,6 +518,36 @@ describe("doctor bundled plugin runtime deps", () => { expect(installed).toEqual([]); }); + it("does not repair missing runtime deps during plain non-interactive doctor", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledProviderPlugin(root, "bedrock", ["bedrock"], { + "bedrock-only": "1.0.0", + }); + const installed = createInstalledRuntimeDeps(); + const confirmAutoFix = vi.fn(async () => true); + + await maybeRepairBundledPluginRuntimeDeps({ + runtime: createRuntime(), + prompter: createPlainNonInteractivePrompter({ confirmAutoFix }), + packageRoot: root, + config: { + plugins: { + enabled: true, + allow: ["bedrock"], + entries: { bedrock: { enabled: true } }, + }, + }, + installDeps: (params) => { + installed.push(params); + materializeRuntimeDeps(params); + }, + }); + + expect(installed).toEqual([]); + expect(confirmAutoFix).not.toHaveBeenCalled(); + }); + it("repairs explicitly enabled provider deps", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); @@ -519,7 +566,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { @@ -553,7 +600,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -588,7 +635,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -624,7 +671,7 @@ describe("doctor bundled plugin runtime deps", () => { ]); }); - it("repairs missing deps during non-interactive doctor", async () => { + it("repairs missing deps during doctor --fix --non-interactive", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); @@ -632,7 +679,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -659,7 +706,7 @@ describe("doctor bundled plugin runtime deps", () => { expectNoLegacyRuntimeDepsManifest(installRoot); }); - it("repairs a previous incomplete runtime deps install during non-interactive doctor", async () => { + it("repairs a previous incomplete runtime deps install during doctor --fix --non-interactive", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); writeBundledChannelPlugin(root, "telegram", { grammy: "1.37.0" }); @@ -672,7 +719,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -701,7 +748,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime({ logs }), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -728,7 +775,7 @@ describe("doctor bundled plugin runtime deps", () => { const repair = maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime({ logs }), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -760,7 +807,7 @@ describe("doctor bundled plugin runtime deps", () => { const repair = maybeRepairBundledPluginRuntimeDeps({ runtime: { error: () => {}, log: () => {} } as never, - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -790,7 +837,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -820,7 +867,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { @@ -851,7 +898,7 @@ describe("doctor bundled plugin runtime deps", () => { await expect( maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime({ errors }), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, config: { plugins: { enabled: true }, @@ -876,7 +923,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter({ updateInProgress: true }), + prompter: createNonInteractiveRepairPrompter({ updateInProgress: true }), packageRoot: root, includeConfiguredChannels: true, config: { @@ -909,7 +956,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), env, packageRoot: root, config: { @@ -963,7 +1010,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), env, packageRoot: root, config: { @@ -999,7 +1046,7 @@ describe("doctor bundled plugin runtime deps", () => { await maybeRepairBundledPluginRuntimeDeps({ runtime: createRuntime(), - prompter: createNonInteractivePrompter(), + prompter: createNonInteractiveRepairPrompter(), packageRoot: root, includeConfiguredChannels: true, config: { diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index ae01acbc9c7..efdab947033 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -92,11 +92,11 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { const shouldRepair = params.prompter.shouldRepair || - params.prompter.repairMode.nonInteractive || - (await params.prompter.confirmAutoFix({ - message: "Install missing bundled plugin runtime deps now?", - initialValue: true, - })); + (!params.prompter.repairMode.nonInteractive && + (await params.prompter.confirmAutoFix({ + message: "Install missing bundled plugin runtime deps now?", + initialValue: true, + }))); if (!shouldRepair) { return; }