From e857c795a813dacf47d4ee104c03e975eaab28b3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 23:48:43 +0100 Subject: [PATCH] fix(plugins): allow Discord install repair --- CHANGELOG.md | 1 + extensions/discord/package.json | 3 +- .../official-external-channel-catalog.json | 3 +- src/cli/plugin-install-config-policy.ts | 50 ++++++++++++++++- src/cli/plugins-install-config.test.ts | 56 +++++++++---------- src/cli/program/preaction.test.ts | 16 +++--- 6 files changed, 90 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91bdd7f505c..f4f9a84d7dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Plugins/update: detect tracked plugin install records whose package directories disappeared during `openclaw update`, reinstall them before normal plugin updates, and fail the update if any install record still points at missing disk payloads. - Plugins/registry: hash manifest and package metadata when validating persisted plugin registries so fast same-size rewrites cannot leave stale plugin metadata trusted. - Plugins/registry: canonicalize install-record provenance paths before trust diagnostics, so npm plugins installed under symlinked temp/state roots no longer warn as untracked local code. +- Plugins/install: let official external Discord reinstall requests pass the invalid-config guard and run stale-channel repair, so upgrades can recover missing external plugin state directly. - CLI/infer: reject local `codex/*` one-shot model probes before simple-completion dispatch and point operators at the Codex app-server runtime path instead of ending with an empty-output error. - Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns. - Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting. diff --git a/extensions/discord/package.json b/extensions/discord/package.json index d9db05a22e4..30a773b6290 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -61,7 +61,8 @@ "install": { "npmSpec": "@openclaw/discord", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.10" + "minHostVersion": ">=2026.4.10", + "allowInvalidConfigRecovery": true }, "compat": { "pluginApi": ">=2026.5.2" diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 60b95b96eba..d51887c0f61 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -95,7 +95,8 @@ "install": { "npmSpec": "@openclaw/discord", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.10" + "minHostVersion": ">=2026.4.10", + "allowInvalidConfigRecovery": true } } }, diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts index 86c1ba709e4..aad7fcd8352 100644 --- a/src/cli/plugin-install-config-policy.ts +++ b/src/cli/plugin-install-config-policy.ts @@ -3,6 +3,11 @@ import path from "node:path"; import type { Command } from "commander"; import { findBundledPluginSource } from "../plugins/bundled-sources.js"; import { loadPluginManifest } from "../plugins/manifest.js"; +import { + listOfficialExternalPluginCatalogEntries, + resolveOfficialExternalPluginId, + resolveOfficialExternalPluginInstall, +} from "../plugins/official-external-plugin-catalog.js"; import { resolveUserPath } from "../utils.js"; import { parseNpmPrefixSpec, resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js"; @@ -99,6 +104,40 @@ function resolveBundledInstallRecoveryMetadata( return {}; } +function resolveOfficialExternalInstallRecoveryMetadata( + request: Pick, +): { + pluginId?: string; + allowInvalidConfigRecovery?: boolean; +} { + if (request.marketplace) { + return {}; + } + const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec); + const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec); + const values = new Set( + [request.rawSpec, request.normalizedSpec, rawNpmPrefixSpec ?? "", normalizedNpmPrefixSpec ?? ""] + .map((value) => value.trim()) + .filter(Boolean), + ); + if (values.size === 0) { + return {}; + } + for (const entry of listOfficialExternalPluginCatalogEntries()) { + const install = resolveOfficialExternalPluginInstall(entry); + const npmSpec = install?.npmSpec?.trim() || entry.name?.trim(); + if (!npmSpec || !values.has(npmSpec)) { + continue; + } + const pluginId = resolveOfficialExternalPluginId(entry); + return { + ...(pluginId ? { pluginId } : {}), + allowInvalidConfigRecovery: install?.allowInvalidConfigRecovery === true, + }; + } + return {}; +} + function resolvePluginInstallArgvTokens(commandPath: string[], argv: string[]): string[] { const args = argv.slice(2); let cursor = 0; @@ -165,12 +204,21 @@ export function resolvePluginInstallRequestContext(params: { }; } const normalizedSpec = fileSpec && fileSpec.ok ? fileSpec.path : params.rawSpec; - const recovered = resolveBundledInstallRecoveryMetadata({ + const bundledRecovered = resolveBundledInstallRecoveryMetadata({ rawSpec: params.rawSpec, normalizedSpec, resolvedPath: resolveUserPath(normalizedSpec), marketplace: params.marketplace, }); + const officialRecovered = resolveOfficialExternalInstallRecoveryMetadata({ + rawSpec: params.rawSpec, + normalizedSpec, + marketplace: params.marketplace, + }); + const recovered = + officialRecovered.pluginId || officialRecovered.allowInvalidConfigRecovery !== undefined + ? officialRecovered + : bundledRecovered; return { ok: true, request: { diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index 53371ef1bfd..4005f4d1365 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -26,7 +26,7 @@ vi.mock("../commands/doctor/shared/channel-doctor.js", () => ({ collectChannelDoctorStaleConfigMutationsMock(cfg), })); -const MATRIX_REPO_INSTALL_SPEC = repoInstallSpec("matrix"); +const DISCORD_REPO_INSTALL_SPEC = repoInstallSpec("discord"); function makeSnapshot(overrides: Partial = {}): ConfigFileSnapshot { return { @@ -40,7 +40,7 @@ function makeSnapshot(overrides: Partial = {}): ConfigFileSn runtimeConfig: { plugins: {} } as ConfigFileSnapshot["runtimeConfig"], config: { plugins: {} } as OpenClawConfig, hash: "abc", - issues: [{ path: "plugins.installs.matrix", message: "stale path" }], + issues: [{ path: "plugins.installs.discord", message: "stale path" }], warnings: [], legacyIssues: [], ...overrides, @@ -48,10 +48,10 @@ function makeSnapshot(overrides: Partial = {}): ConfigFileSn } describe("loadConfigForInstall", () => { - const matrixNpmRequest = { - rawSpec: "@openclaw/matrix", - normalizedSpec: "@openclaw/matrix", - bundledPluginId: "matrix", + const discordNpmRequest = { + rawSpec: "@openclaw/discord", + normalizedSpec: "@openclaw/discord", + bundledPluginId: "discord", allowInvalidConfigRecovery: true, } satisfies PluginInstallRequestContext; @@ -68,22 +68,22 @@ describe("loadConfigForInstall", () => { }); it("returns the source config and base hash when the snapshot is valid", async () => { - const cfg = { plugins: { entries: { matrix: { enabled: true } } } } as OpenClawConfig; + const cfg = { plugins: { entries: { discord: { enabled: true } } } } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ valid: true, sourceConfig: cfg, - config: { plugins: { entries: { matrix: { enabled: true } }, enabled: true } }, + config: { plugins: { entries: { discord: { enabled: true } }, enabled: true } }, hash: "config-1", issues: [], }), ); - const result = await loadConfigForInstall(matrixNpmRequest); + const result = await loadConfigForInstall(discordNpmRequest); expect(result).toEqual({ config: cfg, baseHash: "config-1" }); }); - it("does not run stale Matrix cleanup on the happy path", async () => { + it("does not run stale Discord cleanup on the happy path", async () => { const cfg = { plugins: {} } as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ @@ -94,27 +94,27 @@ describe("loadConfigForInstall", () => { }), ); - const result = await loadConfigForInstall(matrixNpmRequest); + const result = await loadConfigForInstall(discordNpmRequest); expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); expect(result.config).toBe(cfg); }); it("falls back to snapshot config for explicit bundled-plugin reinstall when issues match the known upgrade failure", async () => { const snapshotCfg = { - plugins: { installs: { matrix: { source: "path", installPath: "/gone" } } }, + plugins: { installs: { discord: { source: "path", installPath: "/gone" } } }, } as unknown as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ - parsed: { plugins: { installs: { matrix: {} } } }, + parsed: { plugins: { installs: { discord: {} } } }, config: snapshotCfg, issues: [ - { path: "channels.matrix", message: "unknown channel id: matrix" }, + { path: "channels.discord", message: "unknown channel id: discord" }, { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, ], }), ); - const result = await loadConfigForInstall(matrixNpmRequest); + const result = await loadConfigForInstall(discordNpmRequest); expect(readConfigFileSnapshotMock).toHaveBeenCalled(); expect(collectChannelDoctorStaleConfigMutationsMock).toHaveBeenCalledWith(snapshotCfg); expect(result).toEqual({ config: snapshotCfg, baseHash: "abc" }); @@ -122,28 +122,28 @@ describe("loadConfigForInstall", () => { it("allows npm:-prefixed bundled-plugin reinstall recovery", async () => { const snapshotCfg = { - plugins: { installs: { matrix: { source: "path", installPath: "/gone" } } }, + plugins: { installs: { discord: { source: "path", installPath: "/gone" } } }, } as unknown as OpenClawConfig; readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ - parsed: { plugins: { installs: { matrix: {} } } }, + parsed: { plugins: { installs: { discord: {} } } }, config: snapshotCfg, issues: [ - { path: "channels.matrix", message: "unknown channel id: matrix" }, + { path: "channels.discord", message: "unknown channel id: discord" }, { path: "plugins.load.paths", message: "plugin: plugin path not found: /gone" }, ], }), ); const request = resolvePluginInstallRequestContext({ - rawSpec: "npm:@openclaw/matrix", + rawSpec: "npm:@openclaw/discord", }); if (!request.ok) { throw new Error(request.error); } expect(request.request).toMatchObject({ - bundledPluginId: "matrix", + bundledPluginId: "discord", allowInvalidConfigRecovery: true, }); const result = await loadConfigForInstall(request.request); @@ -156,12 +156,12 @@ describe("loadConfigForInstall", () => { readConfigFileSnapshotMock.mockResolvedValue( makeSnapshot({ config: snapshotCfg, - issues: [{ path: "channels.matrix", message: "unknown channel id: matrix" }], + issues: [{ path: "channels.discord", message: "unknown channel id: discord" }], }), ); const repoRequest = resolvePluginInstallRequestContext({ - rawSpec: MATRIX_REPO_INSTALL_SPEC, + rawSpec: DISCORD_REPO_INSTALL_SPEC, }); if (!repoRequest.ok) { throw new Error(repoRequest.error); @@ -169,7 +169,7 @@ describe("loadConfigForInstall", () => { const result = await loadConfigForInstall({ ...repoRequest.request, - resolvedPath: bundledPluginRootAt("/tmp/repo", "matrix"), + resolvedPath: bundledPluginRootAt("/tmp/repo", "discord"), }); expect(result.config).toBe(snapshotCfg); }); @@ -181,12 +181,12 @@ describe("loadConfigForInstall", () => { }), ); - await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( - "Config invalid outside the bundled recovery path for matrix", + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( + "Config invalid outside the bundled recovery path for discord", ); }); - it("rejects non-Matrix install requests when config is invalid", async () => { + it("rejects non-Discord install requests when config is invalid", async () => { readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot()); await expect( @@ -205,7 +205,7 @@ describe("loadConfigForInstall", () => { }), ); - await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( "Config file could not be parsed; run `openclaw doctor` to repair it.", ); }); @@ -213,7 +213,7 @@ describe("loadConfigForInstall", () => { it("throws when invalid snapshot config file does not exist", async () => { readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot({ exists: false, parsed: {} })); - await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( + await expect(loadConfigForInstall(discordNpmRequest)).rejects.toThrow( "Config file could not be parsed; run `openclaw doctor` to repair it.", ); }); diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index d29f3b9c656..0f038cddd30 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import { loggingState } from "../../logging/state.js"; import { setCommandJsonMode } from "./json-mode.js"; -const MATRIX_REPO_INSTALL_SPEC = repoInstallSpec("matrix"); +const DISCORD_REPO_INSTALL_SPEC = repoInstallSpec("discord"); const setVerboseMock = vi.fn(); const emitCliBannerMock = vi.fn(); @@ -299,10 +299,10 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); - it("only allows invalid config for explicit Matrix reinstall requests", async () => { + it("only allows invalid config for explicit Discord reinstall requests", async () => { await runPreAction({ - parseArgv: ["plugins", "install", "@openclaw/matrix"], - processArgv: ["node", "openclaw", "plugins", "install", "@openclaw/matrix"], + parseArgv: ["plugins", "install", "@openclaw/discord"], + processArgv: ["node", "openclaw", "plugins", "install", "@openclaw/discord"], }); expect(ensureConfigReadyMock).toHaveBeenCalledWith({ @@ -324,8 +324,8 @@ describe("registerPreActionHooks", () => { vi.clearAllMocks(); await runPreAction({ - parseArgv: ["plugins", "install", MATRIX_REPO_INSTALL_SPEC], - processArgv: ["node", "openclaw", "plugins", "install", MATRIX_REPO_INSTALL_SPEC], + parseArgv: ["plugins", "install", DISCORD_REPO_INSTALL_SPEC], + processArgv: ["node", "openclaw", "plugins", "install", DISCORD_REPO_INSTALL_SPEC], }); expect(ensureConfigReadyMock).toHaveBeenCalledWith({ @@ -336,13 +336,13 @@ describe("registerPreActionHooks", () => { vi.clearAllMocks(); await runPreAction({ - parseArgv: ["plugins", "install", "@openclaw/matrix", "--marketplace", "local/repo"], + parseArgv: ["plugins", "install", "@openclaw/discord", "--marketplace", "local/repo"], processArgv: [ "node", "openclaw", "plugins", "install", - "@openclaw/matrix", + "@openclaw/discord", "--marketplace", "local/repo", ],