diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts new file mode 100644 index 00000000000..f2b8b430bda --- /dev/null +++ b/extensions/msteams/src/sdk.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMSTeamsAdapter, type MSTeamsTeamsSdk } from "./sdk.js"; +import type { MSTeamsCredentials } from "./token.js"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function createSdkStub(): MSTeamsTeamsSdk { + class AppStub { + async getBotToken() { + return { + toString() { + return "bot-token"; + }, + }; + } + } + + class ClientStub { + constructor(_serviceUrl: string, _options: unknown) {} + + conversations = { + activities: (_conversationId: string) => ({ + create: async (_activity: unknown) => ({ id: "created" }), + }), + }; + } + + return { + App: AppStub as unknown as MSTeamsTeamsSdk["App"], + Client: ClientStub as unknown as MSTeamsTeamsSdk["Client"], + }; +} + +describe("createMSTeamsAdapter", () => { + it("provides deleteActivity in proactive continueConversation contexts", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const creds = { + appId: "app-id", + appPassword: "secret", + tenantId: "tenant-id", + } satisfies MSTeamsCredentials; + const sdk = createSdkStub(); + const app = new sdk.App({ + clientId: creds.appId, + clientSecret: creds.appPassword, + tenantId: creds.tenantId, + }); + const adapter = createMSTeamsAdapter(app, sdk); + + await adapter.continueConversation( + creds.appId, + { + serviceUrl: "https://service.example.com/", + conversation: { id: "19:conversation@thread.tacv2" }, + channelId: "msteams", + }, + async (ctx) => { + await ctx.deleteActivity("activity-123"); + }, + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://service.example.com/v3/conversations/19%3Aconversation%40thread.tacv2/activities/activity-123", + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + Authorization: "Bearer bot-token", + }), + }), + ); + }); +}); diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts new file mode 100644 index 00000000000..5350b6b84d4 --- /dev/null +++ b/src/cli/plugin-install-config-policy.ts @@ -0,0 +1,148 @@ +import path from "node:path"; +import type { Command } from "commander"; +import { resolveUserPath } from "../utils.js"; +import { resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js"; + +export type PluginInstallInvalidConfigPolicy = "deny" | "recover-matrix-only"; + +export type PluginInstallRequestContext = { + rawSpec: string; + normalizedSpec: string; + resolvedPath?: string; + marketplace?: string; +}; + +type PluginInstallRequestResolution = + | { ok: true; request: PluginInstallRequestContext } + | { ok: false; error: string }; + +function isPluginInstallCommand(commandPath: string[]): boolean { + return commandPath[0] === "plugins" && commandPath[1] === "install"; +} + +function isExplicitMatrixInstallRequest(request: PluginInstallRequestContext): boolean { + if (request.marketplace) { + return false; + } + const candidates = [request.rawSpec.trim(), request.normalizedSpec.trim()]; + if (candidates.includes("@openclaw/matrix")) { + return true; + } + if (!request.resolvedPath) { + return false; + } + return ( + path.basename(request.resolvedPath) === "matrix" && + path.basename(path.dirname(request.resolvedPath)) === "extensions" + ); +} + +function resolvePluginInstallArgvTokens(commandPath: string[], argv: string[]): string[] { + const args = argv.slice(2); + let cursor = 0; + for (const segment of commandPath) { + while (cursor < args.length && args[cursor] !== segment) { + cursor += 1; + } + if (cursor >= args.length) { + return []; + } + cursor += 1; + } + return args.slice(cursor); +} + +function resolvePluginInstallArgvRequest(commandPath: string[], argv: string[]) { + if (!isPluginInstallCommand(commandPath)) { + return null; + } + const tokens = resolvePluginInstallArgvTokens(commandPath, argv); + let rawSpec: string | null = null; + let marketplace: string | undefined; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.startsWith("--marketplace=")) { + marketplace = token.slice("--marketplace=".length); + continue; + } + if (token === "--marketplace") { + const value = tokens[index + 1]; + if (typeof value === "string") { + marketplace = value; + index += 1; + } + continue; + } + if (token.startsWith("-")) { + continue; + } + rawSpec ??= token; + } + return rawSpec ? { rawSpec, marketplace } : null; +} + +export function resolvePluginInstallRequestContext(params: { + rawSpec: string; + marketplace?: string; +}): PluginInstallRequestResolution { + if (params.marketplace) { + return { + ok: true, + request: { + rawSpec: params.rawSpec, + normalizedSpec: params.rawSpec, + marketplace: params.marketplace, + }, + }; + } + const fileSpec = resolveFileNpmSpecToLocalPath(params.rawSpec); + if (fileSpec && !fileSpec.ok) { + return { + ok: false, + error: fileSpec.error, + }; + } + const normalizedSpec = fileSpec && fileSpec.ok ? fileSpec.path : params.rawSpec; + return { + ok: true, + request: { + rawSpec: params.rawSpec, + normalizedSpec, + resolvedPath: resolveUserPath(normalizedSpec), + }, + }; +} + +export function resolvePluginInstallPreactionRequest(params: { + actionCommand: Command; + commandPath: string[]; + argv: string[]; +}): PluginInstallRequestContext | null { + if (!isPluginInstallCommand(params.commandPath)) { + return null; + } + const argvRequest = resolvePluginInstallArgvRequest(params.commandPath, params.argv); + const opts = params.actionCommand.opts>(); + const marketplace = + (typeof opts.marketplace === "string" && opts.marketplace.trim() + ? opts.marketplace + : argvRequest?.marketplace) || undefined; + const rawSpec = + (typeof params.actionCommand.processedArgs?.[0] === "string" + ? params.actionCommand.processedArgs[0] + : argvRequest?.rawSpec) ?? null; + if (!rawSpec) { + return null; + } + const request = resolvePluginInstallRequestContext({ rawSpec, marketplace }); + return request.ok ? request.request : null; +} + +export function resolvePluginInstallInvalidConfigPolicy( + request: PluginInstallRequestContext | null, +): PluginInstallInvalidConfigPolicy { + if (!request) { + return "deny"; + } + return isExplicitMatrixInstallRequest(request) ? "recover-matrix-only" : "deny"; +} diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index cba645233ba..7d845b53a86 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -10,6 +10,7 @@ import { installPluginFromMarketplace, installPluginFromNpmSpec, loadConfig, + readConfigFileSnapshot, parseClawHubPluginSpec, recordHookInstall, recordPluginInstall, @@ -48,6 +49,36 @@ describe("plugins cli install", () => { expect(writeConfigFile).not.toHaveBeenCalled(); }); + it("fails closed for unrelated invalid config before installer side effects", async () => { + const invalidConfigErr = new Error("config invalid"); + (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; + loadConfig.mockImplementation(() => { + throw invalidConfigErr; + }); + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw-config.json5", + exists: true, + raw: '{ "models": { "default": 123 } }', + parsed: { models: { default: 123 } }, + resolved: { models: { default: 123 } }, + valid: false, + config: { models: { default: 123 } }, + hash: "mock", + issues: [{ path: "models.default", message: "invalid model ref" }], + warnings: [], + legacyIssues: [], + }); + + await expect(runPluginsCommand(["plugins", "install", "alpha"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain( + "Config invalid; run `openclaw doctor --fix` before installing plugins.", + ); + expect(installPluginFromMarketplace).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + it("installs marketplace plugins and persists config", async () => { const cfg = { plugins: { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 60c12cc005f..5d4151c4eab 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -5,7 +5,7 @@ import { loadConfig, readConfigFileSnapshot } from "../config/config.js"; import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { parseClawHubPluginSpec } from "../infra/clawhub.js"; -import { extractErrorCode } from "../infra/errors.js"; +import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { formatClawHubSpecifier, installPluginFromClawHub } from "../plugins/clawhub.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; @@ -16,9 +16,14 @@ import { } from "../plugins/marketplace.js"; import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; -import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { shortenHomePath } from "../utils.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js"; +import { + resolvePluginInstallInvalidConfigPolicy, + resolvePluginInstallRequestContext, + type PluginInstallRequestContext, +} from "./plugin-install-config-policy.js"; import { resolveBundledInstallPlanBeforeNpm, resolveBundledInstallPlanForNpmFailure, @@ -29,7 +34,6 @@ import { createPluginInstallLogger, decidePreferredClawHubFallback, formatPluginInstallWithHookFallbackError, - resolveFileNpmSpecToLocalPath, } from "./plugins-command-helpers.js"; import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js"; @@ -169,37 +173,60 @@ async function tryInstallHookPackFromNpmSpec(params: { return { ok: true }; } -// loadConfig() throws when config is invalid; fall back to the raw config -// snapshot so repair-oriented installs (e.g. reinstalling a broken Matrix -// plugin) can still proceed. -// Only catch config-validation errors — real failures (fs permission, OOM) -// must surface so the user sees the actual problem. -// Narrow guard: only proceed from the snapshot when the file was parsed -// successfully (snapshot.parsed has content). For parse/read failures the -// snapshot config is {} which would cause writeConfigFile() to overwrite -// the user's real config with a minimal stub (#52899 concern 4). -export async function loadConfigForInstall(): Promise { +function isAllowedMatrixRecoveryIssue(issue: { path?: string; message?: string }): boolean { + return ( + (issue.path === "channels.matrix" && issue.message === "unknown channel id: matrix") || + (issue.path === "plugins.load.paths" && + typeof issue.message === "string" && + issue.message.includes("plugin path not found")) + ); +} + +function buildInvalidPluginInstallConfigError(message: string): Error { + const error = new Error(message); + (error as { code?: string }).code = "INVALID_CONFIG"; + return error; +} + +async function loadConfigFromSnapshotForInstall( + request: PluginInstallRequestContext, +): Promise { + if (resolvePluginInstallInvalidConfigPolicy(request) !== "recover-matrix-only") { + throw buildInvalidPluginInstallConfigError( + "Config invalid; run `openclaw doctor --fix` before installing plugins.", + ); + } + const snapshot = await readConfigFileSnapshot(); + const parsed = (snapshot.parsed ?? {}) as Record; + if (!snapshot.exists || Object.keys(parsed).length === 0) { + throw buildInvalidPluginInstallConfigError( + "Config file could not be parsed; run `openclaw doctor` to repair it.", + ); + } + if ( + snapshot.legacyIssues.length > 0 || + snapshot.issues.length === 0 || + snapshot.issues.some((issue) => !isAllowedMatrixRecoveryIssue(issue)) + ) { + throw buildInvalidPluginInstallConfigError( + "Config invalid outside the Matrix upgrade recovery path; run `openclaw doctor --fix` before reinstalling Matrix.", + ); + } + const cleaned = await cleanStaleMatrixPluginConfig(snapshot.config); + return cleaned.config; +} + +export async function loadConfigForInstall( + request: PluginInstallRequestContext, +): Promise { try { - const cfg = loadConfig(); - const cleaned = await cleanStaleMatrixPluginConfig(cfg); - return cleaned.config; + return loadConfig(); } catch (err) { if (extractErrorCode(err) !== "INVALID_CONFIG") { throw err; } } - // Config validation failed — recover from the raw snapshot. - const snapshot = await readConfigFileSnapshot(); - const parsed = (snapshot.parsed ?? {}) as Record; - if (!snapshot.exists || Object.keys(parsed).length === 0) { - const configErr = new Error( - "Config file could not be parsed; run `openclaw doctor` to repair it.", - ); - (configErr as { code?: string }).code = "INVALID_CONFIG"; - throw configErr; - } - const cleaned = await cleanStaleMatrixPluginConfig(snapshot.config); - return cleaned.config; + return loadConfigFromSnapshotForInstall(request); } export async function runPluginInstallCommand(params: { @@ -220,7 +247,6 @@ export async function runPluginInstallCommand(params: { marketplace: params.opts.marketplace ?? (shorthand?.ok ? shorthand.marketplaceSource : undefined), }; - if (opts.marketplace) { if (opts.link) { defaultRuntime.error("`--link` is not supported with `--marketplace`."); @@ -230,8 +256,25 @@ export async function runPluginInstallCommand(params: { defaultRuntime.error("`--pin` is not supported with `--marketplace`."); return defaultRuntime.exit(1); } + } + const requestResolution = resolvePluginInstallRequestContext({ + rawSpec: raw, + marketplace: opts.marketplace, + }); + if (!requestResolution.ok) { + defaultRuntime.error(requestResolution.error); + return defaultRuntime.exit(1); + } + const request = requestResolution.request; + const cfg = await loadConfigForInstall(request).catch((error: unknown) => { + defaultRuntime.error(formatErrorMessage(error)); + return null; + }); + if (!cfg) { + return defaultRuntime.exit(1); + } - const cfg = await loadConfigForInstall(); + if (opts.marketplace) { const result = await installPluginFromMarketplace({ marketplace: opts.marketplace, plugin: raw, @@ -258,14 +301,7 @@ export async function runPluginInstallCommand(params: { return; } - const fileSpec = resolveFileNpmSpecToLocalPath(raw); - if (fileSpec && !fileSpec.ok) { - defaultRuntime.error(fileSpec.error); - return defaultRuntime.exit(1); - } - const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw; - const resolved = resolveUserPath(normalized); - const cfg = await loadConfigForInstall(); + const resolved = request.resolvedPath ?? request.normalizedSpec; if (fs.existsSync(resolved)) { if (opts.link) { diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index a45c128226f..39e5650aec1 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -35,6 +35,11 @@ function makeSnapshot(overrides: Partial = {}): ConfigFileSn } describe("loadConfigForInstall", () => { + const matrixNpmRequest = { + rawSpec: "@openclaw/matrix", + normalizedSpec: "@openclaw/matrix", + }; + beforeEach(() => { loadConfigMock.mockReset(); readConfigFileSnapshotMock.mockReset(); @@ -50,23 +55,21 @@ describe("loadConfigForInstall", () => { const cfg = { plugins: { entries: { matrix: { enabled: true } } } } as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); - const result = await loadConfigForInstall(); + const result = await loadConfigForInstall(matrixNpmRequest); expect(result).toBe(cfg); expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); }); - it("runs stale Matrix cleanup on the happy path", async () => { + it("does not run stale Matrix cleanup on the happy path", async () => { const cfg = { plugins: {} } as OpenClawConfig; - const cleanedCfg = { plugins: { cleaned: true } } as unknown as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); - cleanStaleMatrixPluginConfigMock.mockReturnValue({ config: cleanedCfg, changes: ["cleaned"] }); - const result = await loadConfigForInstall(); - expect(cleanStaleMatrixPluginConfigMock).toHaveBeenCalledWith(cfg); - expect(result).toBe(cleanedCfg); + const result = await loadConfigForInstall(matrixNpmRequest); + expect(cleanStaleMatrixPluginConfigMock).not.toHaveBeenCalled(); + expect(result).toBe(cfg); }); - it("falls back to snapshot config when loadConfig throws INVALID_CONFIG and snapshot was parsed", async () => { + it("falls back to snapshot config for explicit Matrix reinstall when issues match the known upgrade failure", async () => { const invalidConfigErr = new Error("config invalid"); (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; loadConfigMock.mockImplementation(() => { @@ -80,16 +83,77 @@ describe("loadConfigForInstall", () => { makeSnapshot({ parsed: { plugins: { installs: { matrix: {} } } }, config: snapshotCfg, + issues: [ + { path: "channels.matrix", message: "unknown channel id: matrix" }, + { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, + ], }), ); - const result = await loadConfigForInstall(); + const result = await loadConfigForInstall(matrixNpmRequest); expect(readConfigFileSnapshotMock).toHaveBeenCalled(); expect(cleanStaleMatrixPluginConfigMock).toHaveBeenCalledWith(snapshotCfg); expect(result).toBe(snapshotCfg); }); - it("throws when loadConfig fails with INVALID_CONFIG and snapshot parsed is empty (parse failure)", async () => { + it("allows explicit repo-checkout Matrix reinstall recovery", async () => { + const invalidConfigErr = new Error("config invalid"); + (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; + loadConfigMock.mockImplementation(() => { + throw invalidConfigErr; + }); + + const snapshotCfg = { plugins: {} } as OpenClawConfig; + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + config: snapshotCfg, + issues: [{ path: "channels.matrix", message: "unknown channel id: matrix" }], + }), + ); + + const result = await loadConfigForInstall({ + rawSpec: "./extensions/matrix", + normalizedSpec: "./extensions/matrix", + resolvedPath: "/tmp/repo/extensions/matrix", + }); + expect(result).toBe(snapshotCfg); + }); + + it("rejects unrelated invalid config even during Matrix reinstall", async () => { + const invalidConfigErr = new Error("config invalid"); + (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; + loadConfigMock.mockImplementation(() => { + throw invalidConfigErr; + }); + + readConfigFileSnapshotMock.mockResolvedValue( + makeSnapshot({ + issues: [{ path: "models.default", message: "invalid model ref" }], + }), + ); + + await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( + "Config invalid outside the Matrix upgrade recovery path", + ); + }); + + it("rejects non-Matrix install requests when config is invalid", async () => { + const invalidConfigErr = new Error("config invalid"); + (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; + loadConfigMock.mockImplementation(() => { + throw invalidConfigErr; + }); + + await expect( + loadConfigForInstall({ + rawSpec: "alpha", + normalizedSpec: "alpha", + }), + ).rejects.toThrow("Config invalid; run `openclaw doctor --fix` before installing plugins."); + expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); + }); + + it("throws when loadConfig fails with INVALID_CONFIG and snapshot parsed is empty", async () => { const invalidConfigErr = new Error("config invalid"); (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; loadConfigMock.mockImplementation(() => { @@ -103,7 +167,7 @@ describe("loadConfigForInstall", () => { }), ); - await expect(loadConfigForInstall()).rejects.toThrow( + await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( "Config file could not be parsed; run `openclaw doctor` to repair it.", ); }); @@ -117,7 +181,7 @@ describe("loadConfigForInstall", () => { readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot({ exists: false, parsed: {} })); - await expect(loadConfigForInstall()).rejects.toThrow( + await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( "Config file could not be parsed; run `openclaw doctor` to repair it.", ); }); @@ -129,7 +193,9 @@ describe("loadConfigForInstall", () => { throw fsErr; }); - await expect(loadConfigForInstall()).rejects.toThrow("EACCES: permission denied"); + await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( + "EACCES: permission denied", + ); expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 85683a0d3d4..b0f092b44ae 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -50,6 +50,7 @@ describe("ensureConfigReady", () => { runtime: RuntimeEnv; commandPath?: string[]; suppressDoctorStdout?: boolean; + allowInvalid?: boolean; }) => Promise; 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(); }); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 68b18151183..ee79d308f51 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -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>> | null = null; @@ -38,6 +37,7 @@ export async function ensureConfigReady(params: { runtime: RuntimeEnv; commandPath?: string[]; suppressDoctorStdout?: boolean; + allowInvalid?: boolean; }): Promise { const commandPath = params.commandPath ?? []; let preflightSnapshot: Awaited> | 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 = diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 0fecac4ea0a..9f2f540dd3d 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -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("") + .option("--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"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 3cdbb2fe65c..732f4da8c0d 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -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.