From a3870686947b54246474a48020e094f1f6bbc0fa Mon Sep 17 00:00:00 2001 From: NVIDIAN Date: Tue, 5 May 2026 08:05:20 -0700 Subject: [PATCH] fix(cli): handle closed plugin uninstall prompt (#73566) Merged via squash. Prepared head SHA: d754ddcf29a2508d09d749e694fb6a2c869fc716 Co-authored-by: ai-hpc <183861985+ai-hpc@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- CHANGELOG.md | 1 + src/cli/plugins-cli-test-helpers.ts | 7 ++++ src/cli/plugins-cli.uninstall.test.ts | 52 +++++++++++++++++++++++++++ src/cli/plugins-uninstall-command.ts | 23 ++++++++++-- src/cli/prompt.test.ts | 38 ++++++++++++++++++-- src/cli/prompt.ts | 37 +++++++++++++++++-- 6 files changed, 151 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 289bc2cf394..b31c9d8dbfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai - Video generation: accept provider-specific aspect-ratio and resolution hints at the tool boundary, normalize `720P` to MiniMax's supported `768P`, and stop sending Google `generateAudio` on Gemini video requests so provider fallback can recover from model-specific parameter differences. Thanks @vincentkoc. - OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc. - Hooks/session-memory: run reset memory capture off the command reply path and make model-generated memory filename slugs opt-in with `llmSlug: true`, so `/new` and `/reset` no longer block WhatsApp and other message-channel reset replies on hook housekeeping or a nested model call. Thanks @vincentkoc. +- CLI/plugins: handle closed stdin during `plugins uninstall` confirmation prompt and exit 1 with actionable `--force` guidance instead of crashing with Node exit 13 unsettled top-level await. Fixes #73562. (#73566) Thanks @ai-hpc. - CLI/gateway: pause non-TTY stdin after full CLI command completion and stop `openclaw agent` from falling back to embedded mode after gateway request/auth failures, so parent help commands exit cleanly and scoped delivery probes surface the real Gateway error immediately. Thanks @vincentkoc. - Gateway/model catalog: cache empty read-only model catalog results until reload, so TUI and control-plane refresh loops cannot hammer plugin metadata reads when no usable models are currently discovered. Thanks @vincentkoc. - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 1eef4d23caf..c751944caac 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -74,6 +74,12 @@ const uninstallPlugin: AsyncUnknownMock = vi.fn(); export const updateNpmInstalledPlugins: AsyncUnknownMock = vi.fn(); export const updateNpmInstalledHookPacks: AsyncUnknownMock = vi.fn(); export const promptYesNo: AsyncUnknownMock = vi.fn(); +export class PromptInputClosedError extends Error { + constructor() { + super("Prompt input closed before an answer was received."); + this.name = "PromptInputClosedError"; + } +} export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn(); export const installPluginFromPath: AsyncUnknownMock = vi.fn(); export const installPluginFromClawHub: AsyncUnknownMock = vi.fn(); @@ -455,6 +461,7 @@ vi.mock("../hooks/update.js", () => ({ })); vi.mock("./prompt.js", () => ({ + PromptInputClosedError, promptYesNo: ((...args: Parameters<(typeof import("./prompt.js"))["promptYesNo"]>) => invokeMock< Parameters<(typeof import("./prompt.js"))["promptYesNo"]>, diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index e9ec46a1955..01a0fe89da8 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -7,6 +7,7 @@ import { buildPluginSnapshotReport, loadConfig, planPluginUninstall, + PromptInputClosedError, promptYesNo, refreshPluginRegistry, replaceConfigFile, @@ -148,6 +149,57 @@ describe("plugins cli uninstall", () => { }); }); + it("exits cleanly when confirmation input closes before an answer", async () => { + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + installs: { + alpha: { + source: "path", + sourcePath: ALPHA_INSTALL_PATH, + installPath: ALPHA_INSTALL_PATH, + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(baseConfig); + setInstalledPluginIndexInstallRecords(baseConfig.plugins?.installs ?? {}); + buildPluginSnapshotReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + planPluginUninstall.mockReturnValue({ + ok: true, + config: { plugins: { entries: {}, installs: {} } } as OpenClawConfig, + actions: { + entry: true, + install: true, + allowlist: false, + denylist: false, + loadPath: false, + memorySlot: false, + contextEngineSlot: false, + directory: false, + }, + directoryRemoval: null, + }); + promptYesNo.mockRejectedValueOnce(new PromptInputClosedError()); + + await expect(runPluginsCommand(["plugins", "uninstall", "alpha"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors).toContain( + "Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.", + ); + expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled(); + }); + it("restores install records when the config write rejects during uninstall", async () => { const installRecords = { alpha: { diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts index d121955eba9..a9f7d251ad3 100644 --- a/src/cli/plugins-uninstall-command.ts +++ b/src/cli/plugins-uninstall-command.ts @@ -19,6 +19,13 @@ export type PluginUninstallOptions = { dryRun?: boolean; }; +function isPromptInputClosedError( + error: unknown, + PromptInputClosedError: typeof import("./prompt.js").PromptInputClosedError, +): error is InstanceType { + return error instanceof PromptInputClosedError; +} + export async function runPluginUninstallCommand( id: string, opts: PluginUninstallOptions = {}, @@ -44,7 +51,7 @@ export async function runPluginUninstallCommand( const { refreshPluginRegistryAfterConfigMutation } = await import("./plugins-registry-refresh.js"); const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js"); - const { promptYesNo } = await import("./prompt.js"); + const { PromptInputClosedError, promptYesNo } = await import("./prompt.js"); const snapshot = await tracePluginLifecyclePhaseAsync( "config read", () => readConfigFileSnapshot(), @@ -143,7 +150,19 @@ export async function runPluginUninstallCommand( } if (!opts.force) { - const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`); + let confirmed: boolean; + try { + confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`); + } catch (error) { + if (isPromptInputClosedError(error, PromptInputClosedError)) { + runtime.error( + "Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.", + ); + runtime.exit(1); + return; + } + throw error; + } if (!confirmed) { runtime.log("Cancelled."); return; diff --git a/src/cli/prompt.test.ts b/src/cli/prompt.test.ts index b28eb83d7b3..ae1367f6b1f 100644 --- a/src/cli/prompt.test.ts +++ b/src/cli/prompt.test.ts @@ -1,13 +1,32 @@ import readline from "node:readline/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { isYes, setVerbose, setYes } from "../globals.js"; -import { promptYesNo } from "./prompt.js"; +import { PromptInputClosedError, promptYesNo } from "./prompt.js"; const readlineState = vi.hoisted(() => { const question = vi.fn(async () => ""); const close = vi.fn(); - const createInterface = vi.fn(() => ({ question, close })); - return { question, close, createInterface }; + const listeners = new Map void>>(); + const once = vi.fn((event: string, listener: () => void) => { + const current = listeners.get(event) ?? new Set<() => void>(); + current.add(listener); + listeners.set(event, current); + }); + const off = vi.fn((event: string, listener: () => void) => { + listeners.get(event)?.delete(listener); + }); + const emit = (event: string) => { + const current = [...(listeners.get(event) ?? [])]; + listeners.delete(event); + for (const listener of current) { + listener(); + } + }; + const resetListeners = () => { + listeners.clear(); + }; + const createInterface = vi.fn(() => ({ question, close, once, off })); + return { question, close, createInterface, emit, off, once, resetListeners }; }); vi.mock("node:readline/promises", () => ({ @@ -21,6 +40,9 @@ beforeEach(() => { readlineState.question.mockResolvedValue(""); readlineState.close.mockClear(); readlineState.createInterface.mockClear(); + readlineState.off.mockClear(); + readlineState.once.mockClear(); + readlineState.resetListeners(); }); describe("promptYesNo", () => { @@ -48,4 +70,14 @@ describe("promptYesNo", () => { const resultYes = await promptYesNo("Continue?", false); expect(resultYes).toBe(true); }); + + it("rejects when input closes before an answer is received", async () => { + readlineState.question.mockReturnValueOnce(new Promise(() => undefined)); + + const result = promptYesNo("Continue?"); + readlineState.emit("close"); + + await expect(result).rejects.toThrow(PromptInputClosedError); + expect(readlineState.close).toHaveBeenCalled(); + }); }); diff --git a/src/cli/prompt.ts b/src/cli/prompt.ts index 1e05c66f617..506a7531982 100644 --- a/src/cli/prompt.ts +++ b/src/cli/prompt.ts @@ -3,6 +3,36 @@ import readline from "node:readline/promises"; import { isVerbose, isYes } from "../globals.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +export class PromptInputClosedError extends Error { + constructor() { + super("Prompt input closed before an answer was received."); + this.name = "PromptInputClosedError"; + } +} + +type ReadlineInterface = ReturnType; + +function questionUntilClose(rl: ReadlineInterface, question: string): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (complete: () => void) => { + if (settled) { + return; + } + settled = true; + rl.off("close", onClose); + complete(); + }; + const onClose = () => finish(() => reject(new PromptInputClosedError())); + + rl.once("close", onClose); + void rl.question(question).then( + (answer) => finish(() => resolve(answer)), + (error: unknown) => finish(() => reject(error)), + ); + }); +} + export async function promptYesNo(question: string, defaultYes = false): Promise { // Simple Y/N prompt honoring global --yes and verbosity flags. if (isVerbose() && isYes()) { @@ -13,8 +43,11 @@ export async function promptYesNo(question: string, defaultYes = false): Promise } const rl = readline.createInterface({ input, output }); const suffix = defaultYes ? " [Y/n] " : " [y/N] "; - const answer = normalizeLowercaseStringOrEmpty(await rl.question(`${question}${suffix}`)); - rl.close(); + const answer = normalizeLowercaseStringOrEmpty( + await questionUntilClose(rl, `${question}${suffix}`).finally(() => { + rl.close(); + }), + ); if (!answer) { return defaultYes; }