mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(plugins): allow Discord install repair
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/discord",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
"minHostVersion": ">=2026.4.10",
|
||||
"allowInvalidConfigRecovery": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<PluginInstallRequestContext, "rawSpec" | "normalizedSpec" | "marketplace">,
|
||||
): {
|
||||
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: {
|
||||
|
||||
@@ -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> = {}): ConfigFileSnapshot {
|
||||
return {
|
||||
@@ -40,7 +40,7 @@ function makeSnapshot(overrides: Partial<ConfigFileSnapshot> = {}): 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<ConfigFileSnapshot> = {}): 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.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user