refactor: centralize plugin install config policy

This commit is contained in:
Peter Steinberger
2026-03-23 23:05:19 -07:00
parent c3744fbfc4
commit fd0fa97952
9 changed files with 502 additions and 59 deletions

View File

@@ -50,6 +50,7 @@ describe("ensureConfigReady", () => {
runtime: RuntimeEnv;
commandPath?: string[];
suppressDoctorStdout?: boolean;
allowInvalid?: boolean;
}) => Promise<void>;
let resetConfigGuardStateForTests: () => void;
@@ -144,9 +145,14 @@ describe("ensureConfigReady", () => {
expect(gatewayRuntime.exit).not.toHaveBeenCalled();
});
it("does not exit for invalid config on plugins install", async () => {
it("allows an explicit invalid-config override", async () => {
setInvalidSnapshot();
const runtime = await runEnsureConfigReady(["plugins", "install"]);
const runtime = makeRuntime();
await ensureConfigReady({
runtime: runtime as never,
commandPath: ["plugins", "install"],
allowInvalid: true,
});
expect(runtime.exit).not.toHaveBeenCalled();
});

View File

@@ -15,7 +15,6 @@ const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
"stop",
"restart",
]);
const ALLOWED_INVALID_PLUGINS_SUBCOMMANDS = new Set(["install"]);
let didRunDoctorConfigFlow = false;
let configSnapshotPromise: Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> | null =
null;
@@ -38,6 +37,7 @@ export async function ensureConfigReady(params: {
runtime: RuntimeEnv;
commandPath?: string[];
suppressDoctorStdout?: boolean;
allowInvalid?: boolean;
}): Promise<void> {
const commandPath = params.commandPath ?? [];
let preflightSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | null = null;
@@ -74,13 +74,11 @@ export async function ensureConfigReady(params: {
const commandName = commandPath[0];
const subcommandName = commandPath[1];
const allowInvalid = commandName
? ALLOWED_INVALID_COMMANDS.has(commandName) ||
? params.allowInvalid === true ||
ALLOWED_INVALID_COMMANDS.has(commandName) ||
(commandName === "gateway" &&
subcommandName &&
ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName)) ||
(commandName === "plugins" &&
subcommandName &&
ALLOWED_INVALID_PLUGINS_SUBCOMMANDS.has(subcommandName))
ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName))
: false;
const { formatConfigIssueLines } = await import("../../config/issue-format.js");
const issues =

View File

@@ -129,6 +129,12 @@ describe("registerPreActionHooks", () => {
program.command("onboard").action(() => {});
const channels = program.command("channels");
channels.command("add").action(() => {});
program
.command("plugins")
.command("install")
.argument("<spec>")
.option("--marketplace <marketplace>")
.action(() => {});
program
.command("update")
.command("status")
@@ -229,6 +235,61 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("only allows invalid config for explicit Matrix reinstall requests", async () => {
await runPreAction({
parseArgv: ["plugins", "install", "@openclaw/matrix"],
processArgv: ["node", "openclaw", "plugins", "install", "@openclaw/matrix"],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["plugins", "install"],
allowInvalid: true,
});
vi.clearAllMocks();
await runPreAction({
parseArgv: ["plugins", "install", "alpha"],
processArgv: ["node", "openclaw", "plugins", "install", "alpha"],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["plugins", "install"],
});
vi.clearAllMocks();
await runPreAction({
parseArgv: ["plugins", "install", "./extensions/matrix"],
processArgv: ["node", "openclaw", "plugins", "install", "./extensions/matrix"],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["plugins", "install"],
allowInvalid: true,
});
vi.clearAllMocks();
await runPreAction({
parseArgv: ["plugins", "install", "@openclaw/matrix", "--marketplace", "local/repo"],
processArgv: [
"node",
"openclaw",
"plugins",
"install",
"@openclaw/matrix",
"--marketplace",
"local/repo",
],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["plugins", "install"],
});
});
it("skips help/version preaction and respects banner opt-out", async () => {
await runPreAction({
parseArgv: ["status"],

View File

@@ -8,6 +8,10 @@ import { defaultRuntime } from "../../runtime.js";
import { getCommandPathWithRootOptions, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
import { emitCliBanner } from "../banner.js";
import { resolveCliName } from "../cli-name.js";
import {
resolvePluginInstallInvalidConfigPolicy,
resolvePluginInstallPreactionRequest,
} from "../plugin-install-config-policy.js";
import { isCommandJsonOutputMode } from "./json-mode.js";
function setProcessTitleForCommand(actionCommand: Command) {
@@ -81,6 +85,18 @@ function shouldLoadPluginsForCommand(commandPath: string[], jsonOutputMode: bool
}
return true;
}
function shouldAllowInvalidConfigForAction(actionCommand: Command, commandPath: string[]): boolean {
return (
resolvePluginInstallInvalidConfigPolicy(
resolvePluginInstallPreactionRequest({
actionCommand,
commandPath,
argv: process.argv,
}),
) === "recover-matrix-only"
);
}
function getRootCommand(command: Command): Command {
let current = command;
while (current.parent) {
@@ -133,10 +149,12 @@ export function registerPreActionHooks(program: Command, programVersion: string)
if (shouldBypassConfigGuard(commandPath)) {
return;
}
const allowInvalid = shouldAllowInvalidConfigForAction(actionCommand, commandPath);
const { ensureConfigReady } = await loadConfigGuardModule();
await ensureConfigReady({
runtime: defaultRuntime,
commandPath,
...(allowInvalid ? { allowInvalid: true } : {}),
...(jsonOutputMode ? { suppressDoctorStdout: true } : {}),
});
// Load plugins for commands that need channel access.