fix(cli): handle closed plugin uninstall prompt (#73566)

Merged via squash.

Prepared head SHA: d754ddcf29
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
This commit is contained in:
NVIDIAN
2026-05-05 08:05:20 -07:00
committed by GitHub
parent e6f5f5693d
commit a387068694
6 changed files with 151 additions and 7 deletions

View File

@@ -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.

View File

@@ -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"]>,

View File

@@ -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: {

View File

@@ -19,6 +19,13 @@ export type PluginUninstallOptions = {
dryRun?: boolean;
};
function isPromptInputClosedError(
error: unknown,
PromptInputClosedError: typeof import("./prompt.js").PromptInputClosedError,
): error is InstanceType<typeof PromptInputClosedError> {
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;

View File

@@ -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<string, Set<() => 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<string>(() => undefined));
const result = promptYesNo("Continue?");
readlineState.emit("close");
await expect(result).rejects.toThrow(PromptInputClosedError);
expect(readlineState.close).toHaveBeenCalled();
});
});

View File

@@ -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<typeof readline.createInterface>;
function questionUntilClose(rl: ReadlineInterface, question: string): Promise<string> {
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<boolean> {
// 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;
}