mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(plugins): keep packed entries consistent through package lifecycle (#121174)
* fix(plugins): own packed entry lifecycles by package Persist package ownership per runtime child, route lifecycle actions through one closed resolver, reconcile removed child policy during updates, and retain rollback generations until durable config/index commit. * fix(plugins): break uninstall policy import cycle * test(plugins): model package ownership in lifecycle fixtures
This commit is contained in:
committed by
GitHub
parent
016c5aa772
commit
5a643e3543
+5
-3
@@ -421,9 +421,11 @@ openclaw plugins uninstall <id> --keep-files
|
||||
openclaw plugins uninstall <id> --force
|
||||
```
|
||||
|
||||
`uninstall` removes plugin records from `plugins.entries`, the persisted plugin index, plugin allow/deny list entries, and any `plugins.load.paths` entry that exactly resolves to the recorded install path. Linked path installs also remove an exact entry for their recorded source path. Parent directories, child paths, prefix matches, and unrelated load paths are preserved. Unless `--keep-files` is set, uninstall also removes the tracked managed install directory, but only when it resolves inside OpenClaw's plugin extensions root. If the plugin currently owns the `memory` or `contextEngine` slot, that slot resets to its default (`memory-core` for memory, `legacy` for context engine).
|
||||
`uninstall` removes plugin records from `plugins.entries`, the persisted plugin index, plugin allow/deny list entries, and any `plugins.load.paths` entry that exactly resolves to the recorded install path. For a package with multiple child entries, any child id resolves to the package owner; uninstall removes every sibling's policy and slot/channel references, the one package install record, and the managed directory once. Linked path installs also remove an exact entry for their recorded source path. Parent directories, child paths, prefix matches, and unrelated load paths are preserved. Unless `--keep-files` is set, uninstall also removes the tracked managed install directory, but only when it resolves inside OpenClaw's plugin extensions root. If the plugin currently owns the `memory` or `contextEngine` slot, that slot resets to its default (`memory-core` for memory, `legacy` for context engine).
|
||||
|
||||
`uninstall` prints a preview of what will be removed, then prompts `Uninstall plugin "<id>"?` before making changes. Pass `--force` to skip the confirmation prompt (useful for scripts and non-interactive runs); without it, uninstall requires an interactive TTY. `--dry-run` prints the same preview and exits without prompting or changing anything.
|
||||
`uninstall` prints a preview of what will be removed. Multi-entry packages name the package owner and every affected child before prompting. Pass `--force` to skip the confirmation prompt (useful for scripts and non-interactive runs); without it, uninstall requires an interactive TTY. `--dry-run` prints the same preview and exits without prompting or changing anything.
|
||||
|
||||
If OpenClaw cannot prove exactly one package owner and a complete child list, lifecycle mutations fail closed without changing package files, config, or the installed index. Run `openclaw plugins registry --refresh`, inspect `openclaw plugins doctor`, and use `openclaw doctor --fix` for repairable legacy index state. If ownership is still ambiguous, reinstall the package before retrying update or uninstall.
|
||||
|
||||
<Note>
|
||||
`--keep-config` is supported as a deprecated alias for `--keep-files`.
|
||||
@@ -445,7 +447,7 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Resolving plugin id vs npm spec">
|
||||
When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update <id>` runs.
|
||||
When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. For a multi-entry package, a child id resolves to its package owner and updates every sibling together. If the new package version removes or renames children, OpenClaw removes the retired children's entries, allow/deny policy, exact child load paths, channel config, and memory/context slot selections while preserving retained/new children and unrelated plugins. Previously stored dist-tags such as `@beta` and exact pinned versions continue to be used on later `update <id>` runs.
|
||||
|
||||
The narrow exception is a trusted official package completing a catalog-declared plugin id replacement. That update starts from the catalog package selector so the renamed manifest can replace the legacy id.
|
||||
|
||||
|
||||
@@ -143,6 +143,12 @@ OpenClaw records the install but leaves the plugin disabled. Configure
|
||||
`plugins.entries.<id>.config`, then run `openclaw plugins enable <id>`. If an
|
||||
existing config entry is present but invalid, install fails without rewriting it.
|
||||
|
||||
A plugin package can expose multiple child entries. Installation tracks that
|
||||
package once, enables each ready child entry, and preserves any child that you
|
||||
explicitly disabled. Runtime policy remains child-addressable through
|
||||
`plugins.entries.<child-id>`, allow/deny lists, channel config, exact child load
|
||||
paths, and the `memory` and `contextEngine` slots.
|
||||
|
||||
## Restart and inspect
|
||||
|
||||
A running managed Gateway with config reload enabled restarts automatically
|
||||
@@ -171,7 +177,17 @@ openclaw plugins update <plugin-id> --dry-run
|
||||
|
||||
Passing a plugin id reuses its tracked install spec: stored dist-tags
|
||||
(`@beta`) and exact pinned versions carry over to later `update <plugin-id>`
|
||||
runs.
|
||||
runs. For a multi-entry package, any child id resolves to the one tracked
|
||||
package install, so all siblings update together. Removed or renamed children
|
||||
have their stale entries, allow/deny policy, exact load paths, channel config,
|
||||
and memory/context slot selections reconciled before the new package/index
|
||||
state commits; retained/new children and unrelated plugins are preserved.
|
||||
|
||||
If OpenClaw cannot prove exactly one package owner and a complete child list,
|
||||
update and uninstall fail closed without changing package files, config, or the
|
||||
installed index. Run `openclaw plugins registry --refresh`, inspect
|
||||
`openclaw plugins doctor`, and use `openclaw doctor --fix` for repairable legacy
|
||||
index state. If the ambiguity remains, reinstall the package before retrying.
|
||||
|
||||
`openclaw plugins update --all` is the bulk maintenance path. It still
|
||||
respects ordinary tracked install specs, but trusted official OpenClaw
|
||||
@@ -204,10 +220,12 @@ openclaw plugins uninstall <plugin-id>
|
||||
openclaw plugins uninstall <plugin-id> --keep-files
|
||||
```
|
||||
|
||||
Uninstall removes the plugin's config entry, persisted plugin index record,
|
||||
allow/deny list entries, and linked `plugins.load.paths` entries when
|
||||
applicable. The managed install directory is removed unless you pass
|
||||
`--keep-files`. A running managed Gateway restarts automatically when the
|
||||
Uninstall removes the package's persisted install record and every owned child
|
||||
entry from plugin config, allow/deny lists, memory/context slots, exact linked
|
||||
`plugins.load.paths`, and channel config entries when applicable. You may address a multi-entry
|
||||
package by any child id; the preview names the package owner and all siblings
|
||||
that will be removed. The managed install directory is removed once unless you
|
||||
pass `--keep-files`. A running managed Gateway restarts automatically when the
|
||||
uninstall changes plugin source.
|
||||
|
||||
In Nix mode (`OPENCLAW_NIX_MODE=1`), plugin install, update, uninstall,
|
||||
|
||||
@@ -47,6 +47,38 @@ function writePlugin([dir, id, version, method, name]) {
|
||||
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
|
||||
}
|
||||
|
||||
function writePluginPack([dir, id, version, entryList]) {
|
||||
for (const [value, label] of [
|
||||
[dir, "dir"],
|
||||
[id, "id"],
|
||||
[version, "version"],
|
||||
]) {
|
||||
requireArg(value, label);
|
||||
}
|
||||
const entries = entryList
|
||||
? entryList
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: ["one", "two"];
|
||||
if (entries.length === 0) {
|
||||
throw new Error("plugin-pack entries must not be empty");
|
||||
}
|
||||
writeJson(path.join(dir, "package.json"), {
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
openclaw: { extensions: entries.map((entry) => `./${entry}.js`) },
|
||||
});
|
||||
for (const entry of entries) {
|
||||
const childId = `${id}/${entry}`;
|
||||
write(
|
||||
path.join(dir, `${entry}.js`),
|
||||
`module.exports = { id: ${JSON.stringify(childId)}, name: ${JSON.stringify(childId)}, register(api) { api.registerGatewayMethod(${JSON.stringify(`${id}.${entry}`)}, async () => ({ version: ${JSON.stringify(version)} })); }, };\n`,
|
||||
);
|
||||
}
|
||||
writePluginManifest(path.join(dir, "openclaw.plugin.json"), id);
|
||||
}
|
||||
|
||||
function writePluginWithVendoredDependency([dir, id, version, method, name]) {
|
||||
writePlugin([dir, id, version, method, name]);
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
@@ -162,6 +194,7 @@ function writePluginMarketplace(args) {
|
||||
export const pluginCommands = {
|
||||
"plugin-demo": writePluginDemo,
|
||||
plugin: writePlugin,
|
||||
"plugin-pack": writePluginPack,
|
||||
"plugin-vendored-dep": writePluginWithVendoredDependency,
|
||||
"plugin-cli": writePluginWithCli,
|
||||
"plugin-cli-registry-dep": writePluginWithCliRegistryDependency,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { HookInstallRecord } from "../config/types.hooks.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
|
||||
import { recordPluginManifestInstallOwner } from "../plugins/manifest-install-owner.js";
|
||||
import type { CliMockOutputRuntime } from "./test-runtime-capture.js";
|
||||
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
@@ -69,6 +70,7 @@ function clonePluginInstallRecords(records: PluginInstallRecordMap): PluginInsta
|
||||
export function createTestInstalledPluginIndex(params: {
|
||||
policyHash: string;
|
||||
installRecords: PluginInstallRecordMap;
|
||||
plugins?: InstalledPluginIndex["plugins"];
|
||||
}): InstalledPluginIndex {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -79,7 +81,7 @@ export function createTestInstalledPluginIndex(params: {
|
||||
generatedAtMs: 0,
|
||||
refreshReason: "source-changed",
|
||||
installRecords: clonePluginInstallRecords(params.installRecords),
|
||||
plugins: [],
|
||||
plugins: params.plugins ?? [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
@@ -955,9 +957,30 @@ export function resetPluginsCliTestState() {
|
||||
return true;
|
||||
},
|
||||
);
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
loadPluginManifestRegistryMock.mockImplementation((input: unknown) => {
|
||||
const installRecords =
|
||||
(input as { installRecords?: PluginInstallRecordMap } | undefined)?.installRecords ?? {};
|
||||
return {
|
||||
plugins: Object.entries(installRecords).map(([pluginId, record]) => {
|
||||
const rootDir = record.installPath ?? record.sourcePath ?? `/tmp/${pluginId}`;
|
||||
return recordPluginManifestInstallOwner(
|
||||
{
|
||||
id: pluginId,
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "global",
|
||||
rootDir,
|
||||
source: `${rootDir}/index.js`,
|
||||
manifestPath: `${rootDir}/openclaw.plugin.json`,
|
||||
},
|
||||
pluginId,
|
||||
);
|
||||
}),
|
||||
diagnostics: [],
|
||||
};
|
||||
});
|
||||
const defaultPluginReport = {
|
||||
plugins: [],
|
||||
|
||||
@@ -6,6 +6,7 @@ import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { hashConfigIncludeRaw } from "../config/includes.js";
|
||||
import { recordPluginManifestInstallOwner } from "../plugins/manifest-install-owner.js";
|
||||
import {
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
resolveOfficialExternalPluginId,
|
||||
@@ -1268,7 +1269,7 @@ describe("plugins cli install", () => {
|
||||
marketplaceSource: "local/repo",
|
||||
marketplacePlugin: "alpha",
|
||||
});
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledCfg });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledCfg, enabled: true });
|
||||
buildPluginSnapshotReportMock.mockReturnValue({
|
||||
plugins: [{ id: "alpha", kind: "provider" }],
|
||||
diagnostics: [],
|
||||
@@ -1276,19 +1277,22 @@ describe("plugins cli install", () => {
|
||||
const alphaRoot = cliInstallPath("alpha");
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "alpha",
|
||||
kind: "memory",
|
||||
origin: "global",
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
rootDir: alphaRoot,
|
||||
source: `${alphaRoot}/index.js`,
|
||||
manifestPath: `${alphaRoot}/openclaw.plugin.json`,
|
||||
},
|
||||
recordPluginManifestInstallOwner(
|
||||
{
|
||||
id: "alpha",
|
||||
kind: "memory",
|
||||
origin: "global",
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
rootDir: alphaRoot,
|
||||
source: `${alphaRoot}/index.js`,
|
||||
manifestPath: `${alphaRoot}/openclaw.plugin.json`,
|
||||
},
|
||||
"alpha",
|
||||
),
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
@@ -150,6 +150,9 @@ describe("plugins cli uninstall", () => {
|
||||
plugins: [{ id: "alpha", name: "alpha" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
alpha: { source: "path", sourcePath: ALPHA_INSTALL_PATH, installPath: ALPHA_INSTALL_PATH },
|
||||
});
|
||||
primeUninstallPlan({} as OpenClawConfig, { actions: { contextEngineSlot: true } });
|
||||
|
||||
await runPluginsCommand(["plugins", "uninstall", "alpha", "--dry-run"]);
|
||||
@@ -564,42 +567,58 @@ describe("plugins cli uninstall", () => {
|
||||
expect(refreshPluginRegistryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans stale policy refs even when plugin is absent from the current registry", async () => {
|
||||
it("rejects stale child-keyed records that claim one package path", async () => {
|
||||
const sharedPath = "/tmp/openclaw-ambiguous-uninstall-pack";
|
||||
const installRecords = {
|
||||
"pack/one": {
|
||||
source: "npm" as const,
|
||||
spec: "@acme/pack",
|
||||
installPath: sharedPath,
|
||||
},
|
||||
"pack/two": {
|
||||
source: "npm" as const,
|
||||
spec: "@acme/pack",
|
||||
installPath: sharedPath,
|
||||
},
|
||||
};
|
||||
const config = {} as OpenClawConfig;
|
||||
pluginCliConfigMock.mockReturnValue(config);
|
||||
setInstalledPluginIndexInstallRecords(installRecords);
|
||||
buildPluginSnapshotReportMock.mockReturnValue({
|
||||
plugins: [{ id: "pack/one", name: "pack/one" }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "uninstall", "pack/one", "--force"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain('Plugin "pack/one"');
|
||||
expect(planPluginUninstallMock).not.toHaveBeenCalled();
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed for stale policy refs without authoritative installed children", async () => {
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
allow: ["alpha", "beta"],
|
||||
deny: ["alpha"],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const nextConfig = {
|
||||
plugins: {
|
||||
allow: ["beta"],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
pluginCliConfigMock.mockReturnValue(baseConfig);
|
||||
buildPluginSnapshotReportMock.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
primeUninstallPlan(nextConfig, {
|
||||
actions: {
|
||||
entry: false,
|
||||
install: false,
|
||||
allowlist: true,
|
||||
denylist: true,
|
||||
channelConfig: false,
|
||||
},
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]);
|
||||
|
||||
expectLatestUninstallPlanParams({ pluginId: "alpha", deleteFiles: true });
|
||||
expect(configWriteMock).toHaveBeenCalledWith(nextConfig);
|
||||
expect(pluginsCliRuntimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
|
||||
await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
expect(planPluginUninstallMock).not.toHaveBeenCalled();
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uninstalls stale enabled entries when plugin is absent from the current registry", async () => {
|
||||
it("fails closed for stale enabled entries without authoritative installed children", async () => {
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -607,28 +626,18 @@ describe("plugins cli uninstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const nextConfig = {} as OpenClawConfig;
|
||||
|
||||
pluginCliConfigMock.mockReturnValue(baseConfig);
|
||||
buildPluginSnapshotReportMock.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
primeUninstallPlan(nextConfig, {
|
||||
actions: { install: false, channelConfig: false },
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "uninstall", "alpha", "--force"]);
|
||||
|
||||
expectLatestUninstallPlanParams({ pluginId: "alpha", deleteFiles: true });
|
||||
expect(configWriteMock).toHaveBeenCalledWith(nextConfig);
|
||||
expect(refreshPluginRegistryMock).toHaveBeenCalledWith({
|
||||
config: nextConfig,
|
||||
installRecords: {},
|
||||
reason: "source-changed",
|
||||
});
|
||||
expect(runtimeErrors).not.toContain("Plugin not found: alpha");
|
||||
expect(pluginsCliRuntimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
|
||||
await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
expect(planPluginUninstallMock).not.toHaveBeenCalled();
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -782,7 +791,7 @@ describe("plugins cli uninstall", () => {
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records");
|
||||
expect(planPluginUninstallMock).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeErrors.at(-1)).toContain("is not associated with a tracked package install");
|
||||
expect(planPluginUninstallMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ClawHubTrustErrorCode } from "../infra/clawhub-install-trust.js";
|
||||
import { resolveRegistryUpdateChannel } from "../infra/update-channels.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js";
|
||||
import {
|
||||
attachPluginInstallOwnerMigrations,
|
||||
resolvePluginInstallTransactionSink,
|
||||
type PluginInstallTransaction,
|
||||
} from "../plugins/install-transaction.js";
|
||||
import { recordInstalledPluginIndexInstallOwner } from "../plugins/installed-plugin-index-install-owner.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
createTestInstalledPluginIndex,
|
||||
@@ -168,8 +174,20 @@ function primePluginUpdate(
|
||||
config: OpenClawConfig,
|
||||
outcomes: Awaited<ReturnType<typeof updateNpmInstalledPluginsMock>>["outcomes"] = [],
|
||||
changed = false,
|
||||
transactions?: PluginInstallTransaction[],
|
||||
installOwnerMigrations?: Readonly<Record<string, string>>,
|
||||
): void {
|
||||
updateNpmInstalledPluginsMock.mockResolvedValue({ config, changed, outcomes });
|
||||
updateNpmInstalledPluginsMock.mockImplementation(async (params: unknown) => {
|
||||
resolvePluginInstallTransactionSink(params as object)?.push(...(transactions ?? []));
|
||||
const result = {
|
||||
config,
|
||||
changed,
|
||||
outcomes,
|
||||
};
|
||||
return installOwnerMigrations
|
||||
? attachPluginInstallOwnerMigrations(result, installOwnerMigrations)
|
||||
: result;
|
||||
});
|
||||
}
|
||||
|
||||
function primeBravePluginRecordUpdate(config: OpenClawConfig) {
|
||||
@@ -379,6 +397,33 @@ describe("plugins cli update", () => {
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "a stale child-keyed owner", args: ["pack/one"] },
|
||||
{ label: "update all", args: ["--all"] },
|
||||
])("rejects ambiguous package paths for $label", async ({ args }) => {
|
||||
const sharedPath = "/tmp/openclaw-ambiguous-update-pack";
|
||||
const installRecords = {
|
||||
"pack/one": {
|
||||
source: "npm" as const,
|
||||
spec: "@acme/pack",
|
||||
installPath: sharedPath,
|
||||
},
|
||||
"pack/two": {
|
||||
source: "npm" as const,
|
||||
spec: "@acme/pack",
|
||||
installPath: sharedPath,
|
||||
},
|
||||
};
|
||||
const config = {} as OpenClawConfig;
|
||||
primeUpdateConfigSnapshot({ config });
|
||||
setInstalledPluginIndexInstallRecords(installRecords);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", ...args])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(updateNpmInstalledPluginsMock).not.toHaveBeenCalled();
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates tracked hook packs through plugins update", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const nextConfig = cfg;
|
||||
@@ -592,6 +637,8 @@ describe("plugins cli update", () => {
|
||||
},
|
||||
],
|
||||
true,
|
||||
undefined,
|
||||
{ "voice-call": "@openclaw/voice-call" },
|
||||
);
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "--all"]);
|
||||
@@ -603,13 +650,11 @@ describe("plugins cli update", () => {
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows scoped non-npm updates beside include-owned plugin config", async () => {
|
||||
it("blocks child load-path cleanup beside include-owned plugin config", async () => {
|
||||
const pluginId = "@acme/demo";
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
[pluginId]: { enabled: true },
|
||||
},
|
||||
load: { paths: ["/tmp/demo/index.js"] },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const pluginRecords = {
|
||||
@@ -634,11 +679,11 @@ describe("plugins cli update", () => {
|
||||
true,
|
||||
);
|
||||
|
||||
await runPluginsCommand(["plugins", "update", pluginId]);
|
||||
await expect(runPluginsCommand(["plugins", "update", pluginId])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
expect(updateNpmInstalledPluginsMock).toHaveBeenCalledOnce();
|
||||
expectInstallRecordsWrittenWithLease(pluginRecords, cfg);
|
||||
expect(runtimeErrors.at(-1)).toContain("external or unresolved top-level $include");
|
||||
expect(updateNpmInstalledPluginsMock).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLeaseMock).not.toHaveBeenCalled();
|
||||
expect(configWriteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -793,9 +838,33 @@ describe("plugins cli update", () => {
|
||||
.mockResolvedValueOnce(initialSnapshot)
|
||||
.mockResolvedValueOnce(changedSnapshot);
|
||||
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
|
||||
const rollback = vi.fn(async () => undefined);
|
||||
const commit = vi.fn(async () => undefined);
|
||||
primePluginUpdate(
|
||||
{ ...cfg, plugins: { ...cfg.plugins, installs: nextRecords } },
|
||||
[{ pluginId: "brave", status: "updated", message: "Updated brave." }],
|
||||
true,
|
||||
[{ rollback, commit }],
|
||||
);
|
||||
const previousPersistedIndex = createTestInstalledPluginIndex({
|
||||
policyHash: "previous-policy",
|
||||
installRecords: previousRecords,
|
||||
plugins: [
|
||||
recordInstalledPluginIndexInstallOwner(
|
||||
{
|
||||
pluginId: "brave",
|
||||
manifestPath: "/tmp/brave-beta/openclaw.plugin.json",
|
||||
manifestHash: "brave-v1",
|
||||
source: "/tmp/brave-beta/index.js",
|
||||
rootDir: "/tmp/brave-beta",
|
||||
origin: "global",
|
||||
enabled: true,
|
||||
startup: { sidecar: false, memory: false, agentHarnesses: [] },
|
||||
compat: [],
|
||||
},
|
||||
"brave",
|
||||
),
|
||||
],
|
||||
});
|
||||
readPersistedInstalledPluginIndexMock.mockResolvedValue(previousPersistedIndex);
|
||||
|
||||
@@ -816,18 +885,14 @@ describe("plugins cli update", () => {
|
||||
expect(replaceConfigFileMock).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistryMock).not.toHaveBeenCalled();
|
||||
expect(notifyGatewayPluginMetadataChangedMock).not.toHaveBeenCalled();
|
||||
expect(rollback).toHaveBeenCalledTimes(1);
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back persisted install records when included config changes during a records-only update", async () => {
|
||||
const includePath = "/tmp/plugins.json5";
|
||||
const includeTarget = "/tmp/plugins.json5";
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
brave: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const cfg = { plugins: {} } as OpenClawConfig;
|
||||
const initialSnapshot = primeUpdateConfigSnapshot({
|
||||
config: cfg,
|
||||
parsed: {
|
||||
@@ -854,14 +919,34 @@ describe("plugins cli update", () => {
|
||||
readConfigFileSnapshotForWriteMock
|
||||
.mockResolvedValueOnce(initialSnapshot)
|
||||
.mockResolvedValueOnce(changedSnapshot);
|
||||
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
|
||||
const pluginId = "@openclaw/brave-plugin";
|
||||
const previousRecords = {
|
||||
[pluginId]: {
|
||||
source: "npm" as const,
|
||||
spec: `${pluginId}@1.0.0`,
|
||||
installPath: "/tmp/brave-beta",
|
||||
},
|
||||
};
|
||||
const nextRecords = {
|
||||
[pluginId]: {
|
||||
...previousRecords[pluginId],
|
||||
spec: `${pluginId}@2.0.0`,
|
||||
installPath: "/tmp/brave-stable",
|
||||
},
|
||||
};
|
||||
setInstalledPluginIndexInstallRecords(previousRecords);
|
||||
primePluginUpdate(
|
||||
{ ...cfg, plugins: { installs: nextRecords } },
|
||||
[{ pluginId, status: "updated", message: `Updated ${pluginId}.` }],
|
||||
true,
|
||||
);
|
||||
const previousPersistedIndex = createTestInstalledPluginIndex({
|
||||
policyHash: "previous-policy",
|
||||
installRecords: previousRecords,
|
||||
});
|
||||
readPersistedInstalledPluginIndexMock.mockResolvedValue(previousPersistedIndex);
|
||||
|
||||
await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow(
|
||||
await expect(runPluginsCommand(["plugins", "update", pluginId])).rejects.toThrow(
|
||||
"included config changed since last load",
|
||||
);
|
||||
|
||||
@@ -1411,7 +1496,7 @@ describe("plugins cli update", () => {
|
||||
expect(updateParams.onClawHubRisk).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes updated config when updater reports changes", async () => {
|
||||
it("keeps durable state when transaction cleanup fails after the write", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
installs: {
|
||||
@@ -1450,10 +1535,19 @@ describe("plugins cli update", () => {
|
||||
},
|
||||
});
|
||||
setInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
const rollback = vi.fn(async () => undefined);
|
||||
const failedCommit = vi.fn(async () => {
|
||||
throw new Error("cleanup failed");
|
||||
});
|
||||
const remainingCommit = vi.fn(async () => undefined);
|
||||
primePluginUpdate(
|
||||
nextRuntimeConfig,
|
||||
[{ pluginId: "alpha", status: "updated", message: "Updated alpha -> 1.1.0" }],
|
||||
true,
|
||||
[
|
||||
{ commit: failedCommit, rollback },
|
||||
{ commit: remainingCommit, rollback },
|
||||
],
|
||||
);
|
||||
updateNpmInstalledHookPacksMock.mockResolvedValue({
|
||||
outcomes: [],
|
||||
@@ -1461,7 +1555,9 @@ describe("plugins cli update", () => {
|
||||
config: nextRuntimeConfig,
|
||||
});
|
||||
|
||||
await runPluginsCommand(["plugins", "update", "alpha"]);
|
||||
await expect(runPluginsCommand(["plugins", "update", "alpha"])).rejects.toThrow(
|
||||
"Plugin install transaction commit failed",
|
||||
);
|
||||
|
||||
const updateParams = expectSingleCallParams(updateNpmInstalledPluginsMock);
|
||||
expect(updateParams.config).toEqual(runtimeConfig);
|
||||
@@ -1479,12 +1575,10 @@ describe("plugins cli update", () => {
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(refreshPluginRegistryMock).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
installRecords: nextConfig.plugins?.installs,
|
||||
reason: "source-changed",
|
||||
});
|
||||
expectRestartNoticeLogged();
|
||||
expect(failedCommit).toHaveBeenCalledOnce();
|
||||
expect(remainingCommit).toHaveBeenCalledOnce();
|
||||
expect(rollback).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits non-zero when a plugin update reports an error after persisting successes", async () => {
|
||||
|
||||
@@ -64,6 +64,9 @@ async function runPluginUninstallCommandUnlocked(
|
||||
assertConfigWriteAllowedInCurrentMode();
|
||||
}
|
||||
|
||||
const { loadInstalledPluginIndex } = await import("../plugins/installed-plugin-index.js");
|
||||
const { resolveInstalledPluginPackageOwnership } =
|
||||
await import("../plugins/installed-plugin-package-ownership.js");
|
||||
const {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
removePluginInstallRecordFromRecords,
|
||||
@@ -77,10 +80,11 @@ async function runPluginUninstallCommandUnlocked(
|
||||
formatUninstallSlotResetPreview,
|
||||
planPluginUninstall,
|
||||
pluginUninstallTargetExists,
|
||||
prepareConfigForPendingPluginDirectoryRemoval,
|
||||
resolveUninstallChannelConfigKeys,
|
||||
UNINSTALL_ACTION_LABELS,
|
||||
} = await import("../plugins/uninstall.js");
|
||||
const { prepareConfigForPendingPluginDirectoryRemovalSet, recordPluginPackageUninstallPlan } =
|
||||
await import("../plugins/uninstall-package-plan.js");
|
||||
const { commitPluginInstallRecordsWithConfig } =
|
||||
await import("../plugins/install-record-commit.js");
|
||||
const { selectInstallMutationWriteOptions } = await import("../plugins/install-persistence.js");
|
||||
@@ -102,6 +106,7 @@ async function runPluginUninstallCommandUnlocked(
|
||||
{ command: "uninstall" },
|
||||
);
|
||||
const cfg = withPluginInstallRecords(sourceConfig, installRecords);
|
||||
const installedIndex = loadInstalledPluginIndex({ config: cfg, installRecords });
|
||||
const report = tracePluginLifecyclePhase(
|
||||
"plugin registry snapshot",
|
||||
() => buildPluginSnapshotReport({ config: cfg }),
|
||||
@@ -124,15 +129,42 @@ async function runPluginUninstallCommandUnlocked(
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const { plugin, pluginId } = selection.value;
|
||||
const channelIds = plugin?.channelIds;
|
||||
const initialPlan = planPluginUninstall({
|
||||
config: cfg,
|
||||
pluginId,
|
||||
channelIds,
|
||||
deleteFiles: !keepFiles,
|
||||
extensionsDir,
|
||||
});
|
||||
const { plugin } = selection.value;
|
||||
const requestedPluginId = selection.value.pluginId;
|
||||
const ownership = resolveInstalledPluginPackageOwnership(installedIndex, requestedPluginId);
|
||||
if (!ownership.ok) {
|
||||
runtime.error(ownership.error);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const { installOwner: pluginId, pluginIds: ownedPluginIds } = ownership.value;
|
||||
const channelIds =
|
||||
ownedPluginIds.length === 1 && ownedPluginIds[0] === requestedPluginId
|
||||
? plugin?.channelIds
|
||||
: [
|
||||
...new Set(
|
||||
ownedPluginIds.flatMap(
|
||||
(entryId) => report.plugins.find((entry) => entry.id === entryId)?.channelIds ?? [],
|
||||
),
|
||||
),
|
||||
];
|
||||
const initialPlan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: cfg,
|
||||
pluginId,
|
||||
...(channelIds !== undefined ? { channelIds } : {}),
|
||||
deleteFiles: !keepFiles,
|
||||
extensionsDir,
|
||||
},
|
||||
{
|
||||
runtimePluginIds: ownedPluginIds,
|
||||
runtimeLoadPaths: ownedPluginIds.flatMap(
|
||||
(entryId) => report.plugins.find((entry) => entry.id === entryId)?.source ?? [],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!initialPlan.ok) {
|
||||
if (plugin) {
|
||||
runtime.error(
|
||||
@@ -186,6 +218,11 @@ async function runPluginUninstallCommandUnlocked(
|
||||
runtime.log(
|
||||
`Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`,
|
||||
);
|
||||
if (ownedPluginIds.length > 1 || requestedPluginId !== pluginId) {
|
||||
runtime.log(
|
||||
`Package owner: ${theme.command(pluginId)}; all entries will be removed: ${ownedPluginIds.join(", ")}`,
|
||||
);
|
||||
}
|
||||
runtime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`);
|
||||
|
||||
const { collectClawPluginUninstallWarnings } =
|
||||
@@ -208,7 +245,11 @@ async function runPluginUninstallCommandUnlocked(
|
||||
if (!opts.force) {
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
|
||||
confirmed = await promptYesNo(
|
||||
ownedPluginIds.length > 1
|
||||
? `Uninstall plugin package "${pluginId}" and all entries?`
|
||||
: `Uninstall plugin "${pluginId}"?`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isPromptInputClosedError(error, PromptInputClosedError)) {
|
||||
runtime.error(
|
||||
@@ -235,7 +276,10 @@ async function runPluginUninstallCommandUnlocked(
|
||||
let finalWriteOptions = mutationWriteOptions;
|
||||
let directoryResult = { directoryRemoved: false, warnings: [] as string[] };
|
||||
if (plan.directoryRemoval) {
|
||||
const disabledConfig = prepareConfigForPendingPluginDirectoryRemoval(sourceConfig, pluginId);
|
||||
const disabledConfig = prepareConfigForPendingPluginDirectoryRemovalSet(
|
||||
sourceConfig,
|
||||
ownedPluginIds,
|
||||
);
|
||||
const disabledCommit = await tracePluginLifecyclePhaseAsync(
|
||||
"config disable",
|
||||
() =>
|
||||
@@ -267,13 +311,23 @@ async function runPluginUninstallCommandUnlocked(
|
||||
const refreshedSnapshot = refreshedPrepared.snapshot;
|
||||
const refreshedSourceConfig = (refreshedSnapshot.sourceConfig ??
|
||||
refreshedSnapshot.config) as OpenClawConfig;
|
||||
const refreshedPlan = planPluginUninstall({
|
||||
config: withPluginInstallRecords(refreshedSourceConfig, installRecords),
|
||||
pluginId,
|
||||
channelIds,
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
const refreshedPlan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: withPluginInstallRecords(refreshedSourceConfig, installRecords),
|
||||
pluginId,
|
||||
...(channelIds !== undefined ? { channelIds } : {}),
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
},
|
||||
{
|
||||
runtimePluginIds: ownedPluginIds,
|
||||
runtimeLoadPaths: ownedPluginIds.flatMap(
|
||||
(entryId) => report.plugins.find((entry) => entry.id === entryId)?.source ?? [],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!refreshedPlan.ok) {
|
||||
throw new Error(refreshedPlan.error);
|
||||
}
|
||||
@@ -319,8 +373,12 @@ async function runPluginUninstallCommandUnlocked(
|
||||
directory: directoryResult.directoryRemoved,
|
||||
});
|
||||
|
||||
const uninstalledSubject =
|
||||
ownedPluginIds.length > 1 || requestedPluginId !== pluginId
|
||||
? `plugin package "${pluginId}" and entries ${ownedPluginIds.join(", ")}`
|
||||
: `plugin "${pluginId}"`;
|
||||
runtime.log(
|
||||
`Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`,
|
||||
`Uninstalled ${uninstalledSubject}. Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`,
|
||||
);
|
||||
runtime.log("Restart the gateway to apply changes.");
|
||||
};
|
||||
|
||||
+245
-125
@@ -30,13 +30,26 @@ import {
|
||||
commitPluginInstallRecordsOnly,
|
||||
commitPluginInstallRecordsWithConfig,
|
||||
} from "../plugins/install-record-commit.js";
|
||||
import {
|
||||
requestDeferredPluginInstall,
|
||||
resolvePluginInstallOwnerMigrations,
|
||||
settlePluginInstallTransactions,
|
||||
type PluginInstallTransaction,
|
||||
} from "../plugins/install-transaction.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
withoutPluginInstallRecords,
|
||||
withPluginInstallRecords,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
|
||||
import { resolveInstalledPluginPackageOwnership } from "../plugins/installed-plugin-package-ownership.js";
|
||||
import { configReferencesNpmInstallPath } from "../plugins/installs.js";
|
||||
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
|
||||
import {
|
||||
capturePluginPackageUpdateSnapshot,
|
||||
pluginPackageUpdateMayMutateConfig,
|
||||
reconcilePluginPackageUpdateConfig,
|
||||
} from "../plugins/plugin-package-update.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "../plugins/registry-refresh.js";
|
||||
import {
|
||||
isPluginInstallRecordUpdateSource,
|
||||
@@ -225,6 +238,24 @@ async function runPluginUpdateCommandUnlocked(params: RunPluginUpdateCommandPara
|
||||
sourceCfg,
|
||||
pluginInstallRecords,
|
||||
);
|
||||
const installedPluginIndex = loadInstalledPluginIndex({
|
||||
config: cfgWithPluginInstallRecords,
|
||||
installRecords: pluginInstallRecords,
|
||||
});
|
||||
const installOwnerByPluginId = new Map<string, string>();
|
||||
const rejectedPluginIds = new Map<string, string>();
|
||||
for (const pluginId of new Set([
|
||||
...installedPluginIndex.plugins.map((plugin) => plugin.pluginId),
|
||||
...Object.keys(pluginInstallRecords),
|
||||
])) {
|
||||
const ownership = resolveInstalledPluginPackageOwnership(installedPluginIndex, pluginId);
|
||||
if (!ownership.ok) {
|
||||
rejectedPluginIds.set(pluginId, ownership.error);
|
||||
continue;
|
||||
}
|
||||
installOwnerByPluginId.set(pluginId, ownership.value.installOwner);
|
||||
installOwnerByPluginId.set(ownership.value.installOwner, ownership.value.installOwner);
|
||||
}
|
||||
const configuredUpdateChannel = normalizeUpdateChannel(cfg.update?.channel) ?? undefined;
|
||||
const officialPluginUpdateChannel = resolveRegistryUpdateChannel({
|
||||
configChannel: configuredUpdateChannel,
|
||||
@@ -239,9 +270,24 @@ async function runPluginUpdateCommandUnlocked(params: RunPluginUpdateCommandPara
|
||||
}
|
||||
const pluginSelection = resolvePluginUpdateSelection({
|
||||
installs: pluginInstallRecords,
|
||||
installOwnerByPluginId,
|
||||
rejectedPluginIds,
|
||||
rawId: params.id,
|
||||
all: params.opts.all,
|
||||
});
|
||||
if (pluginSelection.error) {
|
||||
defaultRuntime.error(pluginSelection.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
const packageUpdateSnapshotResult = capturePluginPackageUpdateSnapshot({
|
||||
index: installedPluginIndex,
|
||||
installOwners: pluginSelection.pluginIds,
|
||||
});
|
||||
if (!packageUpdateSnapshotResult.ok) {
|
||||
defaultRuntime.error(packageUpdateSnapshotResult.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
const packageUpdateSnapshot = packageUpdateSnapshotResult.value;
|
||||
const selectedHooks = readHookInstalls();
|
||||
const hookSelection = resolveHookPackUpdateSelection({
|
||||
installs: selectedHooks,
|
||||
@@ -316,7 +362,14 @@ async function runPluginUpdateCommandUnlocked(params: RunPluginUpdateCommandPara
|
||||
);
|
||||
// Manual update records stay in the index unless scoped-package compatibility
|
||||
// migrates authored references or moves an explicit prior managed root.
|
||||
const pluginConfigMayMutate = pluginIdMigrationMayMutate || pluginLoadPathMayMutate;
|
||||
const pluginConfigMayMutate =
|
||||
pluginIdMigrationMayMutate ||
|
||||
pluginLoadPathMayMutate ||
|
||||
pluginPackageUpdateMayMutateConfig({
|
||||
config: mutationSnapshot.snapshot.sourceConfig,
|
||||
index: installedPluginIndex,
|
||||
snapshot: packageUpdateSnapshot,
|
||||
});
|
||||
const blockedReasons = new Set<string>();
|
||||
if (pluginConfigMayMutate && pluginMutation.mode === "blocked") {
|
||||
blockedReasons.add(pluginMutation.reason);
|
||||
@@ -346,140 +399,207 @@ async function runPluginUpdateCommandUnlocked(params: RunPluginUpdateCommandPara
|
||||
}
|
||||
}
|
||||
|
||||
const pluginResult =
|
||||
pluginSelection.pluginIds.length > 0
|
||||
? await updateNpmInstalledPlugins({
|
||||
config: cfgWithPluginInstallRecords,
|
||||
pluginIds: pluginSelection.pluginIds,
|
||||
specOverrides: pluginSelection.specOverrides,
|
||||
dryRun: params.opts.dryRun,
|
||||
updateChannel: params.opts.all ? undefined : configuredUpdateChannel,
|
||||
officialPluginUpdateChannel,
|
||||
syncOfficialPluginInstalls: params.opts.all ? true : undefined,
|
||||
coreVersion: VERSION,
|
||||
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
|
||||
...resolveClawHubRiskAcknowledgementCliOptions({
|
||||
acknowledgeClawHubRisk: params.opts.acknowledgeClawHubRisk,
|
||||
action: "updating",
|
||||
allowPrompt: !params.opts.dryRun,
|
||||
}),
|
||||
logger,
|
||||
onIntegrityDrift: async (drift) => {
|
||||
const specLabel = drift.resolvedSpec ?? drift.spec;
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
|
||||
`\nExpected: ${drift.expectedIntegrity}` +
|
||||
`\nActual: ${drift.actualIntegrity}`,
|
||||
),
|
||||
);
|
||||
if (drift.dryRun) {
|
||||
return true;
|
||||
}
|
||||
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
|
||||
},
|
||||
})
|
||||
: { config: cfgWithPluginInstallRecords, changed: false, outcomes: [] };
|
||||
const hookResult =
|
||||
hookSelection.hookIds.length > 0
|
||||
? await updateNpmInstalledHookPacks({
|
||||
config: pluginResult.config,
|
||||
hookIds: hookSelection.hookIds,
|
||||
specOverrides: hookSelection.specOverrides,
|
||||
dryRun: params.opts.dryRun,
|
||||
logger,
|
||||
onIntegrityDrift: async (drift) => {
|
||||
const specLabel = drift.resolvedSpec ?? drift.spec;
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` +
|
||||
`\nExpected: ${drift.expectedIntegrity}` +
|
||||
`\nActual: ${drift.actualIntegrity}`,
|
||||
),
|
||||
);
|
||||
if (drift.dryRun) {
|
||||
return true;
|
||||
}
|
||||
return await promptYesNo(
|
||||
`Continue updating hook pack "${drift.hookId}" with this artifact?`,
|
||||
);
|
||||
},
|
||||
})
|
||||
: { config: pluginResult.config, changed: false, outcomes: [] };
|
||||
const deferredPluginTransactions: PluginInstallTransaction[] = [];
|
||||
let pluginResult;
|
||||
try {
|
||||
pluginResult =
|
||||
pluginSelection.pluginIds.length > 0
|
||||
? await updateNpmInstalledPlugins(
|
||||
requestDeferredPluginInstall(
|
||||
{
|
||||
config: cfgWithPluginInstallRecords,
|
||||
pluginIds: pluginSelection.pluginIds,
|
||||
specOverrides: pluginSelection.specOverrides,
|
||||
dryRun: params.opts.dryRun,
|
||||
updateChannel: params.opts.all ? undefined : configuredUpdateChannel,
|
||||
officialPluginUpdateChannel,
|
||||
syncOfficialPluginInstalls: params.opts.all ? true : undefined,
|
||||
coreVersion: VERSION,
|
||||
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
|
||||
...resolveClawHubRiskAcknowledgementCliOptions({
|
||||
acknowledgeClawHubRisk: params.opts.acknowledgeClawHubRisk,
|
||||
action: "updating",
|
||||
allowPrompt: !params.opts.dryRun,
|
||||
}),
|
||||
logger,
|
||||
onIntegrityDrift: async (drift) => {
|
||||
const specLabel = drift.resolvedSpec ?? drift.spec;
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
|
||||
`\nExpected: ${drift.expectedIntegrity}` +
|
||||
`\nActual: ${drift.actualIntegrity}`,
|
||||
),
|
||||
);
|
||||
if (drift.dryRun) {
|
||||
return true;
|
||||
}
|
||||
return await promptYesNo(
|
||||
`Continue updating "${drift.pluginId}" with this artifact?`,
|
||||
);
|
||||
},
|
||||
},
|
||||
deferredPluginTransactions,
|
||||
),
|
||||
)
|
||||
: { config: cfgWithPluginInstallRecords, changed: false, outcomes: [] };
|
||||
} catch (error) {
|
||||
await settlePluginInstallTransactions(deferredPluginTransactions, "rollback");
|
||||
throw error;
|
||||
}
|
||||
const settlePluginTransactions = async (action: "commit" | "rollback") => {
|
||||
await settlePluginInstallTransactions(deferredPluginTransactions, action);
|
||||
};
|
||||
let packageCommitFinalized = false;
|
||||
try {
|
||||
if (pluginSelection.pluginIds.length > 0 && pluginResult.changed && !params.opts.dryRun) {
|
||||
const nextInstallRecords = pluginResult.config.plugins?.installs ?? {};
|
||||
const afterIndex = loadInstalledPluginIndex({
|
||||
config: pluginResult.config,
|
||||
installRecords: nextInstallRecords,
|
||||
});
|
||||
const reconciled = reconcilePluginPackageUpdateConfig({
|
||||
config: pluginResult.config,
|
||||
beforeIndex: installedPluginIndex,
|
||||
afterIndex,
|
||||
snapshot: packageUpdateSnapshot,
|
||||
installOwnerMigrations: resolvePluginInstallOwnerMigrations(pluginResult),
|
||||
});
|
||||
if (!reconciled.ok) {
|
||||
await settlePluginTransactions("rollback");
|
||||
defaultRuntime.error(reconciled.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
pluginResult = { ...pluginResult, config: reconciled.config };
|
||||
}
|
||||
const hookResult =
|
||||
hookSelection.hookIds.length > 0
|
||||
? await updateNpmInstalledHookPacks({
|
||||
config: pluginResult.config,
|
||||
hookIds: hookSelection.hookIds,
|
||||
specOverrides: hookSelection.specOverrides,
|
||||
dryRun: params.opts.dryRun,
|
||||
logger,
|
||||
onIntegrityDrift: async (drift) => {
|
||||
const specLabel = drift.resolvedSpec ?? drift.spec;
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Integrity drift detected for hook pack "${drift.hookId}" (${specLabel})` +
|
||||
`\nExpected: ${drift.expectedIntegrity}` +
|
||||
`\nActual: ${drift.actualIntegrity}`,
|
||||
),
|
||||
);
|
||||
if (drift.dryRun) {
|
||||
return true;
|
||||
}
|
||||
return await promptYesNo(
|
||||
`Continue updating hook pack "${drift.hookId}" with this artifact?`,
|
||||
);
|
||||
},
|
||||
})
|
||||
: { config: pluginResult.config, changed: false, outcomes: [] };
|
||||
|
||||
const outcomeSummary = logPluginUpdateOutcomes({
|
||||
outcomes: [...pluginResult.outcomes, ...hookResult.outcomes],
|
||||
log: (message) => defaultRuntime.log(message),
|
||||
});
|
||||
const outcomeSummary = logPluginUpdateOutcomes({
|
||||
outcomes: [...pluginResult.outcomes, ...hookResult.outcomes],
|
||||
log: (message) => defaultRuntime.log(message),
|
||||
});
|
||||
|
||||
if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) {
|
||||
const sourceSnapshot = mutationSnapshot ?? (await sourceSnapshotPromise);
|
||||
const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {};
|
||||
const shouldPersistPluginInstallIndex =
|
||||
pluginResult.changed || Object.keys(pluginInstallRecords).length > 0;
|
||||
const sourceShapedUpdateConfig = projectUpdaterResultOntoSourceConfig({
|
||||
runtimeBase: cfgWithPluginInstallRecords,
|
||||
sourceBase: sourceCfgWithPluginInstallRecords,
|
||||
updatedConfig: hookResult.config,
|
||||
});
|
||||
// Plugin install records live in the persisted index. Preserve an authored
|
||||
// empty plugins section so include ownership does not become a false mutation.
|
||||
const nextConfig = withoutPluginInstallRecords(sourceShapedUpdateConfig, {
|
||||
preserveEmptyPlugins: shouldPreserveEmptyPlugins({
|
||||
parsed: sourceSnapshot?.snapshot.parsed,
|
||||
sourceConfig: sourceSnapshot?.snapshot.sourceConfig ?? {},
|
||||
}),
|
||||
});
|
||||
let recordsOnlyPluginUpdate = false;
|
||||
if (shouldPersistPluginInstallIndex) {
|
||||
if (isDeepStrictEqual(nextConfig, sourceSnapshot?.snapshot.sourceConfig ?? sourceCfg)) {
|
||||
await commitPluginInstallRecordsOnly({
|
||||
previousInstallRecords: persistedPluginInstallRecords,
|
||||
nextInstallRecords: nextPluginInstallRecords,
|
||||
nextConfig,
|
||||
verifyConfigFresh: async () => {
|
||||
await assertRecordsOnlyUpdateConfigFresh({
|
||||
baseHash: sourceSnapshot?.snapshot.hash,
|
||||
writeOptions: sourceSnapshot?.writeOptions,
|
||||
});
|
||||
},
|
||||
if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) {
|
||||
const sourceSnapshot = mutationSnapshot ?? (await sourceSnapshotPromise);
|
||||
if (pluginResult.changed) {
|
||||
const currentInstallRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const currentSnapshot = capturePluginPackageUpdateSnapshot({
|
||||
index: installedPluginIndex,
|
||||
installOwners: pluginSelection.pluginIds,
|
||||
});
|
||||
recordsOnlyPluginUpdate = pluginResult.changed;
|
||||
if (
|
||||
!isDeepStrictEqual(currentInstallRecords, persistedPluginInstallRecords) ||
|
||||
!currentSnapshot.ok ||
|
||||
!isDeepStrictEqual([...currentSnapshot.value], [...packageUpdateSnapshot])
|
||||
) {
|
||||
await settlePluginTransactions("rollback");
|
||||
defaultRuntime.error(
|
||||
currentSnapshot.ok
|
||||
? "Plugin package ownership changed during update; no config or index changes were committed. Refresh the plugin registry and retry."
|
||||
: currentSnapshot.error,
|
||||
);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
}
|
||||
const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {};
|
||||
const shouldPersistPluginInstallIndex =
|
||||
pluginResult.changed || Object.keys(pluginInstallRecords).length > 0;
|
||||
const sourceShapedUpdateConfig = projectUpdaterResultOntoSourceConfig({
|
||||
runtimeBase: cfgWithPluginInstallRecords,
|
||||
sourceBase: sourceCfgWithPluginInstallRecords,
|
||||
updatedConfig: hookResult.config,
|
||||
});
|
||||
// Plugin install records live in the persisted index. Preserve an authored
|
||||
// empty plugins section so include ownership does not become a false mutation.
|
||||
const nextConfig = withoutPluginInstallRecords(sourceShapedUpdateConfig, {
|
||||
preserveEmptyPlugins: shouldPreserveEmptyPlugins({
|
||||
parsed: sourceSnapshot?.snapshot.parsed,
|
||||
sourceConfig: sourceSnapshot?.snapshot.sourceConfig ?? {},
|
||||
}),
|
||||
});
|
||||
let recordsOnlyPluginUpdate = false;
|
||||
if (shouldPersistPluginInstallIndex) {
|
||||
if (isDeepStrictEqual(nextConfig, sourceSnapshot?.snapshot.sourceConfig ?? sourceCfg)) {
|
||||
await commitPluginInstallRecordsOnly({
|
||||
previousInstallRecords: persistedPluginInstallRecords,
|
||||
nextInstallRecords: nextPluginInstallRecords,
|
||||
nextConfig,
|
||||
verifyConfigFresh: async () => {
|
||||
await assertRecordsOnlyUpdateConfigFresh({
|
||||
baseHash: sourceSnapshot?.snapshot.hash,
|
||||
writeOptions: sourceSnapshot?.writeOptions,
|
||||
});
|
||||
},
|
||||
});
|
||||
recordsOnlyPluginUpdate = pluginResult.changed;
|
||||
} else {
|
||||
await commitPluginInstallRecordsWithConfig({
|
||||
previousInstallRecords: persistedPluginInstallRecords,
|
||||
nextInstallRecords: nextPluginInstallRecords,
|
||||
nextConfig,
|
||||
baseHash: sourceSnapshot?.snapshot.hash,
|
||||
writeOptions: {
|
||||
...sourceSnapshot?.writeOptions,
|
||||
afterWrite: { mode: "restart", reason: "plugin source changed" },
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await commitPluginInstallRecordsWithConfig({
|
||||
previousInstallRecords: persistedPluginInstallRecords,
|
||||
nextInstallRecords: nextPluginInstallRecords,
|
||||
await replaceConfigFile({
|
||||
nextConfig,
|
||||
baseHash: sourceSnapshot?.snapshot.hash,
|
||||
writeOptions: {
|
||||
...sourceSnapshot?.writeOptions,
|
||||
afterWrite: { mode: "restart", reason: "plugin source changed" },
|
||||
},
|
||||
writeOptions: sourceSnapshot?.writeOptions,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await replaceConfigFile({
|
||||
nextConfig,
|
||||
baseHash: sourceSnapshot?.snapshot.hash,
|
||||
writeOptions: sourceSnapshot?.writeOptions,
|
||||
});
|
||||
}
|
||||
if (pluginResult.changed) {
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: nextConfig,
|
||||
reason: "source-changed",
|
||||
installRecords: nextPluginInstallRecords,
|
||||
invalidateRuntimeCache: false,
|
||||
logger,
|
||||
});
|
||||
if (recordsOnlyPluginUpdate) {
|
||||
await notifyGatewayPluginMetadataChanged(cfg);
|
||||
packageCommitFinalized = true;
|
||||
await settlePluginTransactions("commit");
|
||||
if (pluginResult.changed) {
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: nextConfig,
|
||||
reason: "source-changed",
|
||||
installRecords: nextPluginInstallRecords,
|
||||
invalidateRuntimeCache: false,
|
||||
logger,
|
||||
});
|
||||
if (recordsOnlyPluginUpdate) {
|
||||
await notifyGatewayPluginMetadataChanged(cfg);
|
||||
}
|
||||
}
|
||||
defaultRuntime.log("Restart the gateway to load plugins and hooks.");
|
||||
}
|
||||
defaultRuntime.log("Restart the gateway to load plugins and hooks.");
|
||||
}
|
||||
|
||||
if (outcomeSummary.hasErrors) {
|
||||
defaultRuntime.exit(1);
|
||||
if (outcomeSummary.hasErrors) {
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!packageCommitFinalized) {
|
||||
await settlePluginTransactions("rollback");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,65 @@ describe("resolvePluginUpdateSelection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a packed child update to its tracked package owner", () => {
|
||||
expect(
|
||||
resolvePluginUpdateSelection({
|
||||
installs: {
|
||||
pack: createNpmInstall({ spec: "@acme/pack", resolvedName: "@acme/pack" }),
|
||||
},
|
||||
installOwnerByPluginId: new Map([
|
||||
["pack/one", "pack"],
|
||||
["pack/two", "pack"],
|
||||
]),
|
||||
rawId: "pack/two",
|
||||
}),
|
||||
).toEqual({ pluginIds: ["pack"] });
|
||||
});
|
||||
|
||||
it("does not infer a packed child owner when owner metadata is missing", () => {
|
||||
expect(
|
||||
resolvePluginUpdateSelection({
|
||||
installs: {
|
||||
pack: createNpmInstall({ spec: "@acme/pack", resolvedName: "@acme/pack" }),
|
||||
},
|
||||
rawId: "pack/two",
|
||||
}),
|
||||
).toEqual({ pluginIds: [] });
|
||||
});
|
||||
|
||||
it("rejects an ambiguous child before exact install-record selection", () => {
|
||||
expect(
|
||||
resolvePluginUpdateSelection({
|
||||
installs: {
|
||||
"pack/one": createNpmInstall({ spec: "@acme/pack" }),
|
||||
"pack/two": createNpmInstall({ spec: "@acme/pack" }),
|
||||
},
|
||||
rejectedPluginIds: new Map([
|
||||
["pack/one", "ambiguous pack/one"],
|
||||
["pack/two", "ambiguous pack/two"],
|
||||
]),
|
||||
rawId: "pack/one",
|
||||
}),
|
||||
).toEqual({ pluginIds: [], error: "ambiguous pack/one" });
|
||||
});
|
||||
|
||||
it("rejects an ambiguous package owner for targeted and update-all selection", () => {
|
||||
const installs = {
|
||||
pack: createNpmInstall({ spec: "@acme/pack" }),
|
||||
stable: createNpmInstall({ spec: "@acme/stable" }),
|
||||
};
|
||||
const rejectedPluginIds = new Map([["pack", "ambiguous pack"]]);
|
||||
|
||||
expect(resolvePluginUpdateSelection({ installs, rejectedPluginIds, rawId: "pack" })).toEqual({
|
||||
pluginIds: [],
|
||||
error: "ambiguous pack",
|
||||
});
|
||||
expect(resolvePluginUpdateSelection({ installs, rejectedPluginIds, all: true })).toEqual({
|
||||
pluginIds: [],
|
||||
error: "ambiguous pack",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps prototype-named npm packages by own install records", () => {
|
||||
expect(
|
||||
resolvePluginUpdateSelection({
|
||||
|
||||
@@ -11,19 +11,39 @@ import {
|
||||
/** Resolve a plugin update target and optional npm spec override from CLI input. */
|
||||
export function resolvePluginUpdateSelection(params: {
|
||||
installs: Record<string, PluginInstallRecord>;
|
||||
installOwnerByPluginId?: ReadonlyMap<string, string>;
|
||||
rejectedPluginIds?: ReadonlyMap<string, string>;
|
||||
rawId?: string;
|
||||
all?: boolean;
|
||||
}): { pluginIds: string[]; specOverrides?: Record<string, string> } {
|
||||
}): { pluginIds: string[]; specOverrides?: Record<string, string>; error?: string } {
|
||||
if (params.all) {
|
||||
return { pluginIds: Object.keys(params.installs) };
|
||||
const rejectedOwners = Object.keys(params.installs).filter((pluginId) =>
|
||||
params.rejectedPluginIds?.has(pluginId),
|
||||
);
|
||||
if (rejectedOwners.length > 0) {
|
||||
return {
|
||||
pluginIds: [],
|
||||
error: params.rejectedPluginIds?.get(rejectedOwners[0]!),
|
||||
};
|
||||
}
|
||||
return {
|
||||
pluginIds: Object.keys(params.installs),
|
||||
};
|
||||
}
|
||||
if (!params.rawId) {
|
||||
return { pluginIds: [] };
|
||||
}
|
||||
|
||||
if (params.rejectedPluginIds?.has(params.rawId)) {
|
||||
return { pluginIds: [], error: params.rejectedPluginIds.get(params.rawId) };
|
||||
}
|
||||
if (Object.hasOwn(params.installs, params.rawId)) {
|
||||
return { pluginIds: [params.rawId] };
|
||||
}
|
||||
const installOwner = params.installOwnerByPluginId?.get(params.rawId);
|
||||
if (installOwner && Object.hasOwn(params.installs, installOwner)) {
|
||||
return { pluginIds: [installOwner] };
|
||||
}
|
||||
|
||||
const parsedSpec = parseRegistryNpmSpec(params.rawId);
|
||||
if (!parsedSpec) {
|
||||
@@ -40,6 +60,9 @@ export function resolvePluginUpdateSelection(params: {
|
||||
if (!pluginId) {
|
||||
return { pluginIds: [] };
|
||||
}
|
||||
if (params.rejectedPluginIds?.has(pluginId)) {
|
||||
return { pluginIds: [], error: params.rejectedPluginIds.get(pluginId) };
|
||||
}
|
||||
return {
|
||||
pluginIds: [pluginId],
|
||||
specOverrides: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import { recordPluginCandidateInstallOwner } from "../../../plugins/candidate-install-owner.js";
|
||||
import type { PluginCandidate } from "../../../plugins/discovery.js";
|
||||
import {
|
||||
readPersistedInstalledPluginIndex,
|
||||
@@ -40,7 +41,11 @@ function createCandidate(
|
||||
rootDir: string,
|
||||
id = "demo",
|
||||
origin: PluginCandidate["origin"] = "global",
|
||||
options: { enabledByDefault?: boolean; manifest?: Record<string, unknown> } = {},
|
||||
options: {
|
||||
enabledByDefault?: boolean;
|
||||
installOwner?: string;
|
||||
manifest?: Record<string, unknown>;
|
||||
} = {},
|
||||
): PluginCandidate {
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "index.ts"),
|
||||
@@ -59,12 +64,15 @@ function createCandidate(
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
idHint: id,
|
||||
source: path.join(rootDir, "index.ts"),
|
||||
rootDir,
|
||||
origin,
|
||||
};
|
||||
return recordPluginCandidateInstallOwner(
|
||||
{
|
||||
idHint: id,
|
||||
source: path.join(rootDir, "index.ts"),
|
||||
rootDir,
|
||||
origin,
|
||||
},
|
||||
options.installOwner,
|
||||
);
|
||||
}
|
||||
|
||||
function createCurrentIndex(): InstalledPluginIndex {
|
||||
@@ -454,7 +462,7 @@ describe("plugin registry install migration", () => {
|
||||
|
||||
const result = await migratePluginRegistryForInstall({
|
||||
stateDir,
|
||||
candidates: [createCandidate(pluginDir)],
|
||||
candidates: [createCandidate(pluginDir, "demo", "global", { installOwner: "demo" })],
|
||||
readConfig: async () => ({
|
||||
plugins: {
|
||||
entries: {
|
||||
|
||||
@@ -5,7 +5,11 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runCommandWithTimeout, type CommandOptions, type SpawnResult } from "../process/exec.js";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { installPackageDir } from "./install-package-dir.js";
|
||||
import {
|
||||
installPackageDir,
|
||||
requestDeferredPackageDirInstall,
|
||||
resolvePackageDirInstallTransaction,
|
||||
} from "./install-package-dir.js";
|
||||
|
||||
vi.mock("../process/exec.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../process/exec.js")>("../process/exec.js");
|
||||
@@ -812,4 +816,36 @@ describe("installPackageDir", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("restores the previous package when a deferred update rolls back", async () => {
|
||||
await fixtureRootTracker.setup();
|
||||
const fixtureRoot = await fixtureRootTracker.make("deferred-rollback");
|
||||
const sourceDir = path.join(fixtureRoot, "source");
|
||||
const targetDir = path.join(fixtureRoot, "plugins", "demo");
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sourceDir, "version.txt"), "v2", "utf8");
|
||||
await fs.writeFile(path.join(targetDir, "version.txt"), "v1", "utf8");
|
||||
|
||||
const result = await installPackageDir(
|
||||
requestDeferredPackageDirInstall({
|
||||
sourceDir,
|
||||
targetDir,
|
||||
mode: "update",
|
||||
timeoutMs: 1_000,
|
||||
copyErrorPrefix: "failed to copy plugin",
|
||||
hasDeps: false,
|
||||
depsLogMessage: "",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const transaction = result.ok ? resolvePackageDirInstallTransaction(result) : undefined;
|
||||
if (!transaction) {
|
||||
throw new Error("expected deferred package transaction");
|
||||
}
|
||||
expect(await fs.readFile(path.join(targetDir, "version.txt"), "utf8")).toBe("v2");
|
||||
await transaction.rollback();
|
||||
expect(await fs.readFile(path.join(targetDir, "version.txt"), "utf8")).toBe("v1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,53 @@ async function resolveInstallPublishTarget(params: {
|
||||
};
|
||||
}
|
||||
|
||||
type PackageDirInstallTransaction = {
|
||||
commit(): Promise<void>;
|
||||
rollback(): Promise<void>;
|
||||
};
|
||||
|
||||
const PACKAGE_DIR_INSTALL_TRANSACTION = Symbol.for("openclaw.packageDirInstallTransaction");
|
||||
const PACKAGE_DIR_INSTALL_TRANSACTION_REQUEST = Symbol.for(
|
||||
"openclaw.packageDirInstallTransactionRequest",
|
||||
);
|
||||
|
||||
export function requestDeferredPackageDirInstall<T extends object>(params: T): T {
|
||||
Object.defineProperty(params, PACKAGE_DIR_INSTALL_TRANSACTION_REQUEST, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: true,
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
function isPackageDirInstallCommitDeferred(params: object): boolean {
|
||||
return (
|
||||
(params as { [PACKAGE_DIR_INSTALL_TRANSACTION_REQUEST]?: true })[
|
||||
PACKAGE_DIR_INSTALL_TRANSACTION_REQUEST
|
||||
] === true
|
||||
);
|
||||
}
|
||||
|
||||
function attachPackageDirInstallTransaction<T extends object>(
|
||||
result: T,
|
||||
transaction: PackageDirInstallTransaction,
|
||||
): T {
|
||||
Object.defineProperty(result, PACKAGE_DIR_INSTALL_TRANSACTION, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: transaction,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolvePackageDirInstallTransaction(
|
||||
result: object,
|
||||
): PackageDirInstallTransaction | undefined {
|
||||
return (result as { [PACKAGE_DIR_INSTALL_TRANSACTION]?: PackageDirInstallTransaction })[
|
||||
PACKAGE_DIR_INSTALL_TRANSACTION
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a package directory into an install target via a staged copy.
|
||||
* Update mode backs up the existing target, runs optional validation hooks,
|
||||
@@ -183,6 +230,7 @@ export async function installPackageDir(params: {
|
||||
installedDir: string,
|
||||
) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> {
|
||||
const deferCommit = isPackageDirInstallCommitDeferred(params);
|
||||
params.logger?.info?.(`Installing to ${params.targetDir}…`);
|
||||
const installBaseDir = path.dirname(params.targetDir);
|
||||
let initialInstallBaseRealPath: string;
|
||||
@@ -364,14 +412,46 @@ export async function installPackageDir(params: {
|
||||
backupDir = null;
|
||||
}
|
||||
}
|
||||
if (backupDir) {
|
||||
const retainedBackupDir = backupDir;
|
||||
if (backupDir && !deferCommit) {
|
||||
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
if (stageDir) {
|
||||
await cleanupInstallTempDir(stageDir);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
if (!deferCommit) {
|
||||
return { ok: true };
|
||||
}
|
||||
let settled = false;
|
||||
return attachPackageDirInstallTransaction(
|
||||
{ ok: true },
|
||||
{
|
||||
async commit() {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (retainedBackupDir) {
|
||||
await fs.rm(retainedBackupDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
async rollback() {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await fs.rm(canonicalTargetDir, { recursive: true, force: true });
|
||||
if (retainedBackupDir) {
|
||||
await movePathWithCopyFallback({
|
||||
from: retainedBackupDir,
|
||||
sourceHardlinks,
|
||||
to: canonicalTargetDir,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
const PLUGIN_CANDIDATE_INSTALL_OWNER = Symbol.for("openclaw.pluginCandidateInstallOwner");
|
||||
const PLUGIN_INSTALL_OWNER_LOOKUP = Symbol.for("openclaw.pluginInstallOwnerLookup");
|
||||
|
||||
type PluginCandidateInstallOwner = { installOwner?: string; ambiguous?: true };
|
||||
|
||||
export function recordPluginCandidateInstallOwner<T extends object>(
|
||||
candidate: T,
|
||||
installOwner: string | undefined,
|
||||
ambiguous = false,
|
||||
): T {
|
||||
if (!installOwner && !ambiguous) {
|
||||
return candidate;
|
||||
}
|
||||
Object.defineProperty(candidate, PLUGIN_CANDIDATE_INSTALL_OWNER, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: ambiguous ? { ambiguous: true } : { installOwner },
|
||||
});
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readPluginCandidateInstallOwner(
|
||||
candidate: object,
|
||||
): PluginCandidateInstallOwner | undefined {
|
||||
return (candidate as { [PLUGIN_CANDIDATE_INSTALL_OWNER]?: PluginCandidateInstallOwner })[
|
||||
PLUGIN_CANDIDATE_INSTALL_OWNER
|
||||
];
|
||||
}
|
||||
|
||||
export function resolvePluginCandidateInstallOwner(candidate: object): string | undefined {
|
||||
return readPluginCandidateInstallOwner(candidate)?.installOwner;
|
||||
}
|
||||
|
||||
export function isPluginCandidateInstallOwnerAmbiguous(candidate: object): boolean {
|
||||
return readPluginCandidateInstallOwner(candidate)?.ambiguous === true;
|
||||
}
|
||||
|
||||
export function recordPluginInstallOwnerLookup<T extends object>(
|
||||
params: T,
|
||||
installOwnerByPluginId: ReadonlyMap<string, string>,
|
||||
): T {
|
||||
Object.defineProperty(params, PLUGIN_INSTALL_OWNER_LOOKUP, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: installOwnerByPluginId,
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
export function resolvePluginInstallOwnerLookup(
|
||||
params: object,
|
||||
): ReadonlyMap<string, string> | undefined {
|
||||
return (params as { [PLUGIN_INSTALL_OWNER_LOOKUP]?: ReadonlyMap<string, string> })[
|
||||
PLUGIN_INSTALL_OWNER_LOOKUP
|
||||
];
|
||||
}
|
||||
+25
-22
@@ -52,6 +52,7 @@ import type { RuntimeVersionEnv } from "../version.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE, type ClawHubInstallErrorCode } from "./clawhub-error-codes.js";
|
||||
import type { ClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import {
|
||||
installPluginFromArchive,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
@@ -1441,29 +1442,31 @@ export async function installPluginFromClawHub(
|
||||
params.logger?.info?.(
|
||||
`Downloading ${detail.package?.family === "bundle-plugin" ? "bundle" : "plugin"} ${releaseLabel} from ClawHub…`,
|
||||
);
|
||||
const installResult = await installPluginFromArchive({
|
||||
archivePath: archive.archivePath,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall:
|
||||
officialClawHubPackage || isTrustedSourceLinkedOfficialPackage(detail.package!),
|
||||
config: params.config,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: runtimeIdResolution.expectedPluginId,
|
||||
installPolicyRequest: {
|
||||
kind: "plugin-archive",
|
||||
requestedSpecifier: params.spec,
|
||||
source: {
|
||||
kind: "clawhub",
|
||||
authority: officialClawHubPackage ? "official" : clawhubAuthority,
|
||||
mutable: false,
|
||||
network: true,
|
||||
const installResult = await installPluginFromArchive(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
archivePath: archive.archivePath,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall:
|
||||
officialClawHubPackage || isTrustedSourceLinkedOfficialPackage(detail.package!),
|
||||
config: params.config,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: runtimeIdResolution.expectedPluginId,
|
||||
installPolicyRequest: {
|
||||
kind: "plugin-archive",
|
||||
requestedSpecifier: params.spec,
|
||||
source: {
|
||||
kind: "clawhub",
|
||||
authority: officialClawHubPackage ? "official" : clawhubAuthority,
|
||||
mutable: false,
|
||||
network: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (!installResult.ok) {
|
||||
return installResult;
|
||||
}
|
||||
|
||||
+136
-14
@@ -5,6 +5,10 @@ import path from "node:path";
|
||||
import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import {
|
||||
isPluginCandidateInstallOwnerAmbiguous,
|
||||
resolvePluginCandidateInstallOwner,
|
||||
} from "./candidate-install-owner.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import * as pluginHardlinkPolicy from "./hardlink-policy.js";
|
||||
import { loadPluginManifestRegistryCore } from "./manifest-registry.js";
|
||||
@@ -351,24 +355,20 @@ function expectNoDiagnostic(params: {
|
||||
expect(matched).toBe(false);
|
||||
}
|
||||
|
||||
function expectCandidateFields(
|
||||
candidate:
|
||||
| {
|
||||
idHint?: string;
|
||||
format?: string;
|
||||
bundleFormat?: string;
|
||||
source?: string;
|
||||
rootDir?: string;
|
||||
origin?: string;
|
||||
}
|
||||
| undefined,
|
||||
expected: Record<string, unknown>,
|
||||
) {
|
||||
function expectCandidateFields(candidate: object | undefined, expected: Record<string, unknown>) {
|
||||
if (!candidate) {
|
||||
throw new Error("Expected plugin candidate");
|
||||
}
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(candidate[key as keyof typeof candidate], key).toBe(value);
|
||||
if (key === "installOwner") {
|
||||
expect(resolvePluginCandidateInstallOwner(candidate), key).toBe(value);
|
||||
continue;
|
||||
}
|
||||
if (key === "installOwnerAmbiguous") {
|
||||
expect(isPluginCandidateInstallOwnerAmbiguous(candidate), key).toBe(value);
|
||||
continue;
|
||||
}
|
||||
expect((candidate as Record<string, unknown>)[key], key).toBe(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,14 +1033,112 @@ describe("discoverOpenClawPlugins", () => {
|
||||
);
|
||||
expectCandidateFields(requireCandidateById(result.candidates, "linked-source-pack"), {
|
||||
setupSource: fs.realpathSync(path.join(pluginDir, "src", "setup-entry.ts")),
|
||||
installOwner: "linked-source-pack",
|
||||
});
|
||||
expectNoDiagnostic({
|
||||
diagnostics: result.diagnostics,
|
||||
pluginId: "linked-source-pack",
|
||||
messageIncludes: "requires compiled runtime output",
|
||||
});
|
||||
|
||||
const configured = await discoverWithStateDir(stateDir, {
|
||||
extraPaths: [pluginDir],
|
||||
installRecords,
|
||||
});
|
||||
expectCandidateFields(requireCandidateById(configured.candidates, "linked-source-pack"), {
|
||||
origin: "config",
|
||||
installOwner: "linked-source-pack",
|
||||
});
|
||||
|
||||
const ambiguous = await discoverWithStateDir(stateDir, {
|
||||
installRecords: {
|
||||
...installRecords,
|
||||
"other-owner": installRecords["linked-source-pack"],
|
||||
},
|
||||
});
|
||||
const ambiguousCandidate = requireCandidateById(ambiguous.candidates, "linked-source-pack");
|
||||
expect(resolvePluginCandidateInstallOwner(ambiguousCandidate)).toBeUndefined();
|
||||
expect(isPluginCandidateInstallOwnerAmbiguous(ambiguousCandidate)).toBe(true);
|
||||
expect(
|
||||
ambiguous.diagnostics.some((diagnostic) =>
|
||||
diagnostic.message.includes("multiple plugin install records claim the same package path"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.runIf(canCreateDirectorySymlinks)(
|
||||
"fails closed when aliased install paths claim the same package",
|
||||
async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "aliased-pack");
|
||||
const aliasDir = path.join(stateDir, "aliased-pack-link");
|
||||
mkdirSafe(pluginDir);
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/aliased-pack",
|
||||
extensions: ["./index.ts"],
|
||||
});
|
||||
writePluginManifest({ pluginDir, id: "aliased-pack" });
|
||||
writePluginEntry(path.join(pluginDir, "index.ts"));
|
||||
symlinkDirectory(pluginDir, aliasDir);
|
||||
|
||||
const result = await discoverWithStateDir(stateDir, {
|
||||
installRecords: {
|
||||
"owner-one": { source: "path", sourcePath: pluginDir, installPath: pluginDir },
|
||||
"owner-two": { source: "path", sourcePath: aliasDir, installPath: aliasDir },
|
||||
},
|
||||
});
|
||||
|
||||
const candidate = requireCandidateById(result.candidates, "aliased-pack");
|
||||
expect(resolvePluginCandidateInstallOwner(candidate)).toBeUndefined();
|
||||
expect(isPluginCandidateInstallOwnerAmbiguous(candidate)).toBe(true);
|
||||
expect(
|
||||
result.diagnostics.some((diagnostic) =>
|
||||
diagnostic.message.includes(
|
||||
"multiple plugin install records claim the same package path",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(canCreateDirectorySymlinks)(
|
||||
"keeps configured-path precedence while inheriting one physical package owner",
|
||||
async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "configured-alias-pack");
|
||||
const aliasDir = path.join(stateDir, "configured-alias-pack-link");
|
||||
mkdirSafe(pluginDir);
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/configured-alias-pack",
|
||||
extensions: ["./one.ts", "./two.ts"],
|
||||
});
|
||||
writePluginManifest({ pluginDir, id: "configured-alias-pack" });
|
||||
writePluginEntry(path.join(pluginDir, "one.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "two.ts"));
|
||||
symlinkDirectory(pluginDir, aliasDir);
|
||||
|
||||
const result = await discoverWithStateDir(stateDir, {
|
||||
extraPaths: [aliasDir],
|
||||
installRecords: {
|
||||
"configured-alias-pack": {
|
||||
source: "path",
|
||||
sourcePath: pluginDir,
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const pluginId of ["configured-alias-pack/one", "configured-alias-pack/two"]) {
|
||||
expectCandidateFields(requireCandidateById(result.candidates, pluginId), {
|
||||
origin: "config",
|
||||
installOwner: "configured-alias-pack",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("still requires compiled runtime output for tracked installed package plugins", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "source-only-pack");
|
||||
@@ -2409,6 +2507,30 @@ describe("discoverOpenClawPlugins", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the package install owner for managed bundle candidates", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const bundleDir = path.join(stateDir, "extensions", "package-owner");
|
||||
createBundleRoot(bundleDir, ".codex-plugin/plugin.json", {
|
||||
name: "runtime-child",
|
||||
skills: "skills",
|
||||
});
|
||||
mkdirSafe(path.join(bundleDir, "skills"));
|
||||
|
||||
const { candidates } = await discoverWithStateDir(stateDir, {
|
||||
installRecords: {
|
||||
"package-owner": {
|
||||
source: "path",
|
||||
sourcePath: bundleDir,
|
||||
installPath: bundleDir,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectCandidateFields(requireCandidateById(candidates, "runtime-child"), {
|
||||
installOwner: "package-owner",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "falls back to legacy index discovery when a scanned bundle sidecar is malformed",
|
||||
|
||||
+89
-16
@@ -18,6 +18,11 @@ import {
|
||||
} from "./bundled-dir.js";
|
||||
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
|
||||
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
|
||||
import {
|
||||
isPluginCandidateInstallOwnerAmbiguous,
|
||||
recordPluginCandidateInstallOwner,
|
||||
resolvePluginCandidateInstallOwner,
|
||||
} from "./candidate-install-owner.js";
|
||||
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
|
||||
import { readLegacyNpmPluginDeclaration } from "./legacy-npm-declaration.js";
|
||||
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
|
||||
@@ -389,15 +394,31 @@ function createDiscoveryResult(): PluginDiscoveryResult {
|
||||
function mergeDiscoveryResult(
|
||||
target: PluginDiscoveryResult,
|
||||
source: PluginDiscoveryResult,
|
||||
seenSources: Set<string>,
|
||||
candidatesBySource: Map<string, PluginCandidate>,
|
||||
seenDiagnostics: Set<string>,
|
||||
realpathCache: Map<string, string>,
|
||||
): void {
|
||||
for (const candidate of source.candidates) {
|
||||
const key = candidate.source;
|
||||
if (seenSources.has(key)) {
|
||||
// Configured aliases keep their precedence, but lifecycle ownership follows
|
||||
// the one physical package entry rather than its textual path spelling.
|
||||
const key = safeRealpathSync(candidate.source, realpathCache) ?? path.resolve(candidate.source);
|
||||
const existing = candidatesBySource.get(key);
|
||||
if (existing) {
|
||||
const existingOwner = resolvePluginCandidateInstallOwner(existing);
|
||||
const candidateOwner = resolvePluginCandidateInstallOwner(candidate);
|
||||
const ownerConflict = existingOwner && candidateOwner && existingOwner !== candidateOwner;
|
||||
if (
|
||||
isPluginCandidateInstallOwnerAmbiguous(existing) ||
|
||||
isPluginCandidateInstallOwnerAmbiguous(candidate) ||
|
||||
ownerConflict
|
||||
) {
|
||||
recordPluginCandidateInstallOwner(existing, undefined, true);
|
||||
} else if (candidateOwner) {
|
||||
recordPluginCandidateInstallOwner(existing, candidateOwner);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seenSources.add(key);
|
||||
candidatesBySource.set(key, candidate);
|
||||
target.candidates.push(candidate);
|
||||
}
|
||||
for (const diagnostic of source.diagnostics) {
|
||||
@@ -468,6 +489,8 @@ function addMissingRequiredPluginDiagnostics(
|
||||
type InstalledPluginRecordPath = {
|
||||
path: string;
|
||||
requireBuiltRuntimeEntry: boolean;
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
};
|
||||
|
||||
function isLinkedLocalPluginRecord(params: {
|
||||
@@ -497,10 +520,11 @@ function collectInstalledPluginRecordPaths(
|
||||
installRecords: Record<string, PluginInstallRecord> | undefined,
|
||||
env: NodeJS.ProcessEnv,
|
||||
realpathCache: Map<string, string>,
|
||||
diagnostics: PluginDiagnostic[],
|
||||
): InstalledPluginRecordPath[] {
|
||||
const paths: InstalledPluginRecordPath[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const record of Object.values(installRecords ?? {})) {
|
||||
const byPath = new Map<string, InstalledPluginRecordPath>();
|
||||
for (const [installOwner, record] of Object.entries(installRecords ?? {})) {
|
||||
const rawPath =
|
||||
typeof record.installPath === "string" && record.installPath.trim()
|
||||
? record.installPath
|
||||
@@ -511,14 +535,33 @@ function collectInstalledPluginRecordPaths(
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveUserPath(rawPath, env);
|
||||
if (seen.has(resolved) || !fs.existsSync(resolved)) {
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(resolved);
|
||||
paths.push({
|
||||
const pathKey = safeRealpathSync(resolved, realpathCache) ?? path.resolve(resolved);
|
||||
const requireBuiltRuntimeEntry = !isLinkedLocalPluginRecord({ record, env, realpathCache });
|
||||
const existing = byPath.get(pathKey);
|
||||
if (existing) {
|
||||
existing.requireBuiltRuntimeEntry ||= requireBuiltRuntimeEntry;
|
||||
if (existing.installOwner !== installOwner) {
|
||||
delete existing.installOwner;
|
||||
existing.installOwnerAmbiguous = true;
|
||||
diagnostics.push({
|
||||
level: "error",
|
||||
source: resolved,
|
||||
message:
|
||||
"multiple plugin install records claim the same package path; refresh or reinstall the package before using managed lifecycle actions",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const installedPath: InstalledPluginRecordPath = {
|
||||
path: resolved,
|
||||
requireBuiltRuntimeEntry: !isLinkedLocalPluginRecord({ record, env, realpathCache }),
|
||||
});
|
||||
requireBuiltRuntimeEntry,
|
||||
installOwner,
|
||||
};
|
||||
byPath.set(pathKey, installedPath);
|
||||
paths.push(installedPath);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -737,6 +780,8 @@ function addCandidate(params: {
|
||||
seen: Set<string>;
|
||||
idHint: string;
|
||||
effectivePluginId?: string;
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
diagnosticIdHint?: string;
|
||||
source: string;
|
||||
setupSource?: string;
|
||||
@@ -782,7 +827,7 @@ function addCandidate(params: {
|
||||
dependencies: manifest?.dependencies,
|
||||
optionalDependencies: manifest?.optionalDependencies,
|
||||
});
|
||||
params.candidates.push({
|
||||
const candidate = {
|
||||
idHint: params.idHint,
|
||||
...(params.effectivePluginId ? { effectivePluginId: params.effectivePluginId } : {}),
|
||||
...(params.diagnosticIdHint && params.diagnosticIdHint !== params.idHint
|
||||
@@ -810,7 +855,14 @@ function addCandidate(params: {
|
||||
? { requiredPluginIds: params.requiredPluginIds }
|
||||
: {}),
|
||||
...(params.requiredPluginSource ? { requiredPluginSource: params.requiredPluginSource } : {}),
|
||||
});
|
||||
} satisfies PluginCandidate;
|
||||
params.candidates.push(
|
||||
recordPluginCandidateInstallOwner(
|
||||
candidate,
|
||||
params.installOwner,
|
||||
params.installOwnerAmbiguous === true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function discoverBundleInRoot(params: {
|
||||
@@ -819,6 +871,8 @@ function discoverBundleInRoot(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
manifest?: PackageManifest | null;
|
||||
candidates: PluginCandidate[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
@@ -863,6 +917,8 @@ function discoverBundleInRoot(params: {
|
||||
bundleFormat,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
...(params.installOwner ? { installOwner: params.installOwner } : {}),
|
||||
...(params.installOwnerAmbiguous ? { installOwnerAmbiguous: true } : {}),
|
||||
manifest: params.manifest,
|
||||
packageDir: params.rootDir,
|
||||
bundledManifestId: bundleManifest.manifest.id,
|
||||
@@ -935,6 +991,8 @@ type PluginDirectoryDiscoveryParams = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
requireBuiltRuntimeEntry?: boolean;
|
||||
managedPluginDirs?: Set<string>;
|
||||
candidates: PluginCandidate[];
|
||||
@@ -1030,6 +1088,8 @@ function discoverPluginDirectory(params: PluginDirectoryDiscoveryParams): boolea
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
...(params.installOwner ? { installOwner: params.installOwner } : {}),
|
||||
...(params.installOwnerAmbiguous ? { installOwnerAmbiguous: true } : {}),
|
||||
manifest,
|
||||
packageDir: dir,
|
||||
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
||||
@@ -1097,6 +1157,8 @@ function discoverPluginDirectory(params: PluginDirectoryDiscoveryParams): boolea
|
||||
env: params.env,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
...(params.installOwner ? { installOwner: params.installOwner } : {}),
|
||||
...(params.installOwnerAmbiguous ? { installOwnerAmbiguous: true } : {}),
|
||||
manifest,
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics,
|
||||
@@ -1123,6 +1185,8 @@ function discoverInDirectory(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
requireBuiltRuntimeEntry?: boolean;
|
||||
managedPluginDirs?: Set<string>;
|
||||
skipRootDirKeys?: Set<string>;
|
||||
@@ -1179,6 +1243,8 @@ function discoverInDirectory(params: {
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
...(params.installOwner ? { installOwner: params.installOwner } : {}),
|
||||
...(params.installOwnerAmbiguous ? { installOwnerAmbiguous: true } : {}),
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
continue;
|
||||
@@ -1277,6 +1343,8 @@ function discoverFromPath(params: {
|
||||
origin: PluginOrigin;
|
||||
ownershipUid?: number | null;
|
||||
workspaceDir?: string;
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
requireBuiltRuntimeEntry?: boolean;
|
||||
managedPluginDirs?: Set<string>;
|
||||
skipRootDirKeys?: Set<string>;
|
||||
@@ -1318,6 +1386,8 @@ function discoverFromPath(params: {
|
||||
origin: params.origin,
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir: params.workspaceDir,
|
||||
...(params.installOwner ? { installOwner: params.installOwner } : {}),
|
||||
...(params.installOwnerAmbiguous ? { installOwnerAmbiguous: true } : {}),
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
return;
|
||||
@@ -1570,6 +1640,7 @@ export function discoverOpenClawPlugins(params: {
|
||||
params.installRecords,
|
||||
env,
|
||||
realpathCache,
|
||||
result.diagnostics,
|
||||
);
|
||||
const installedPluginDirKeys = collectManagedPluginDirKeys(
|
||||
installedPaths.map((installedPath) => installedPath.path),
|
||||
@@ -1585,6 +1656,8 @@ export function discoverOpenClawPlugins(params: {
|
||||
origin: "global",
|
||||
ownershipUid: params.ownershipUid,
|
||||
workspaceDir,
|
||||
...(installedPath.installOwner ? { installOwner: installedPath.installOwner } : {}),
|
||||
...(installedPath.installOwnerAmbiguous ? { installOwnerAmbiguous: true } : {}),
|
||||
requireBuiltRuntimeEntry: installedPath.requireBuiltRuntimeEntry,
|
||||
managedPluginDirs,
|
||||
scanFiles: true,
|
||||
@@ -1617,10 +1690,10 @@ export function discoverOpenClawPlugins(params: {
|
||||
{ scope: "shared" },
|
||||
);
|
||||
const result = createDiscoveryResult();
|
||||
const seenSources = new Set<string>();
|
||||
const candidatesBySource = new Map<string, PluginCandidate>();
|
||||
const seenDiagnostics = new Set<string>();
|
||||
mergeDiscoveryResult(result, scopedResult, seenSources, seenDiagnostics);
|
||||
mergeDiscoveryResult(result, sharedResult, seenSources, seenDiagnostics);
|
||||
mergeDiscoveryResult(result, scopedResult, candidatesBySource, seenDiagnostics, realpathCache);
|
||||
mergeDiscoveryResult(result, sharedResult, candidatesBySource, seenDiagnostics, realpathCache);
|
||||
addMissingRequiredPluginDiagnostics(result, { env, realpathCache });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
|
||||
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
||||
import { sha256HexPrefixCore } from "../infra/crypto-digest.js";
|
||||
import { pathExists } from "../infra/fs-safe.js";
|
||||
import {
|
||||
installPackageDir,
|
||||
requestDeferredPackageDirInstall,
|
||||
resolvePackageDirInstallTransaction,
|
||||
} from "../infra/install-package-dir.js";
|
||||
import { withInstallWorkspace } from "../infra/install-source-utils.js";
|
||||
import { replaceDirectoryAtomic } from "../infra/replace-file.js";
|
||||
import {
|
||||
@@ -22,6 +27,11 @@ import {
|
||||
type InstallSafetyOverrides,
|
||||
type InstallSecurityScanResult,
|
||||
} from "./install-security-scan.js";
|
||||
import {
|
||||
attachPluginInstallTransaction,
|
||||
isPluginInstallCommitDeferred,
|
||||
type PluginInstallTransaction,
|
||||
} from "./install-transaction.js";
|
||||
import {
|
||||
installPluginFromInstalledPackageDir,
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
@@ -282,8 +292,24 @@ async function withGitStagingDir<T>(
|
||||
async function replaceManagedGitRepo(params: {
|
||||
stagedRepoDir: string;
|
||||
persistentRepoDir: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
deferCommit?: boolean;
|
||||
}): Promise<{ ok: true; transaction?: PluginInstallTransaction } | { ok: false; error: string }> {
|
||||
try {
|
||||
if (params.deferCommit) {
|
||||
const result = await installPackageDir(
|
||||
requestDeferredPackageDirInstall({
|
||||
sourceDir: params.stagedRepoDir,
|
||||
targetDir: params.persistentRepoDir,
|
||||
mode: (await pathExists(params.persistentRepoDir)) ? "update" : "install",
|
||||
timeoutMs: DEFAULT_GIT_TIMEOUT_MS,
|
||||
copyErrorPrefix: "failed to replace managed git plugin repository",
|
||||
hasDeps: false,
|
||||
depsLogMessage: "",
|
||||
}),
|
||||
);
|
||||
const transaction = result.ok ? resolvePackageDirInstallTransaction(result) : undefined;
|
||||
return result.ok ? { ok: true, ...(transaction ? { transaction } : {}) } : result;
|
||||
}
|
||||
await replaceDirectoryAtomic({
|
||||
stagedDir: params.stagedRepoDir,
|
||||
targetDir: params.persistentRepoDir,
|
||||
@@ -494,14 +520,17 @@ export async function installPluginFromGitSpec(
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
let transaction: PluginInstallTransaction | undefined;
|
||||
if (!params.dryRun) {
|
||||
const replaceResult = await replaceManagedGitRepo({
|
||||
stagedRepoDir: repoDir,
|
||||
persistentRepoDir,
|
||||
deferCommit: isPluginInstallCommitDeferred(params),
|
||||
});
|
||||
if (!replaceResult.ok) {
|
||||
return replaceResult;
|
||||
}
|
||||
transaction = replaceResult.transaction;
|
||||
emitPluginInstallSecurityEvent({
|
||||
pluginId: result.pluginId,
|
||||
mode: effectiveMode,
|
||||
@@ -512,7 +541,7 @@ export async function installPluginFromGitSpec(
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
const installed = {
|
||||
...result,
|
||||
targetDir: params.dryRun ? result.targetDir : persistentRepoDir,
|
||||
git: {
|
||||
@@ -522,5 +551,6 @@ export async function installPluginFromGitSpec(
|
||||
resolvedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
return transaction ? attachPluginInstallTransaction(installed, transaction) : installed;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ import {
|
||||
runInstallSourceScan,
|
||||
sourceFamilyForInstallPolicySource,
|
||||
} from "./install-shared.js";
|
||||
import {
|
||||
attachPluginInstallTransaction,
|
||||
isPluginInstallCommitDeferred,
|
||||
} from "./install-transaction.js";
|
||||
import type {
|
||||
InstallPluginResult,
|
||||
PluginInstallLogger,
|
||||
@@ -184,6 +188,7 @@ export async function installPluginFromManagedNpmRoot(
|
||||
quarantine: ManagedNpmProjectQuarantine;
|
||||
}
|
||||
| undefined;
|
||||
let deferredTransaction = false;
|
||||
try {
|
||||
rollbackSnapshot = await createManagedNpmPluginInstallRollbackSnapshot({ npmRoot });
|
||||
} catch (error) {
|
||||
@@ -615,16 +620,67 @@ export async function installPluginFromManagedNpmRoot(
|
||||
return dependencyResult;
|
||||
}
|
||||
preparedDependency = dependencyResult;
|
||||
return await runManagedNpmInstall(preparedDependency);
|
||||
const result = await runManagedNpmInstall(preparedDependency);
|
||||
if (!result.ok || !isPluginInstallCommitDeferred(params)) {
|
||||
return result;
|
||||
}
|
||||
deferredTransaction = true;
|
||||
let settled = false;
|
||||
const cleanup = async () => {
|
||||
await cleanupManagedNpmRootPreparedDependency({
|
||||
packageName: params.packageName,
|
||||
preparedDependency,
|
||||
logger,
|
||||
});
|
||||
await cleanupManagedNpmPluginInstallRollbackSnapshot({
|
||||
snapshot: rollbackSnapshot,
|
||||
logger,
|
||||
});
|
||||
};
|
||||
return attachPluginInstallTransaction(
|
||||
{ ...result },
|
||||
{
|
||||
async commit() {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await cleanup();
|
||||
},
|
||||
async rollback() {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await rollbackManagedNpmPluginInstall({
|
||||
npmRoot,
|
||||
packageName: params.packageName,
|
||||
targetDir: installRoot,
|
||||
timeoutMs,
|
||||
logger,
|
||||
peerDependencySnapshot: rollbackPeerDependencySnapshot,
|
||||
snapshot: recovery ? undefined : rollbackSnapshot,
|
||||
});
|
||||
await rollbackManagedNpmRootPreparedDependency({
|
||||
packageName: params.packageName,
|
||||
preparedDependency: dependencyResult,
|
||||
logger,
|
||||
});
|
||||
await cleanup();
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await cleanupManagedNpmRootPreparedDependency({
|
||||
packageName: params.packageName,
|
||||
preparedDependency,
|
||||
logger,
|
||||
});
|
||||
await cleanupManagedNpmPluginInstallRollbackSnapshot({
|
||||
snapshot: rollbackSnapshot,
|
||||
logger,
|
||||
});
|
||||
if (!deferredTransaction) {
|
||||
await cleanupManagedNpmRootPreparedDependency({
|
||||
packageName: params.packageName,
|
||||
preparedDependency,
|
||||
logger,
|
||||
});
|
||||
await cleanupManagedNpmPluginInstallRollbackSnapshot({
|
||||
snapshot: rollbackSnapshot,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-27
@@ -35,6 +35,7 @@ import {
|
||||
resolveEffectiveInstallMode,
|
||||
runInstallSourceScan,
|
||||
} from "./install-shared.js";
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import {
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
type InstallPluginResult,
|
||||
@@ -250,34 +251,36 @@ export async function installPluginFromNpmSpec(
|
||||
await fs.rm(policyTempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const result = await installPluginFromManagedNpmRoot({
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
config: params.config,
|
||||
packageName: parsedSpec.name,
|
||||
dependencySpec: resolveManagedNpmRootDependencySpec({
|
||||
parsedSpec,
|
||||
resolution: npmResolution,
|
||||
const result = await installPluginFromManagedNpmRoot(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
config: params.config,
|
||||
packageName: parsedSpec.name,
|
||||
dependencySpec: resolveManagedNpmRootDependencySpec({
|
||||
parsedSpec,
|
||||
resolution: npmResolution,
|
||||
}),
|
||||
displaySpec: spec,
|
||||
installPolicyRequest: {
|
||||
kind: "plugin-npm",
|
||||
requestedSpecifier: spec,
|
||||
source: npmInstallPolicySource,
|
||||
},
|
||||
extensionsDir: params.extensionsDir,
|
||||
npmDir: params.npmDir,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
skipPolicyPreflight: true,
|
||||
expectedPluginId,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
npmResolution,
|
||||
...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}),
|
||||
}),
|
||||
displaySpec: spec,
|
||||
installPolicyRequest: {
|
||||
kind: "plugin-npm",
|
||||
requestedSpecifier: spec,
|
||||
source: npmInstallPolicySource,
|
||||
},
|
||||
extensionsDir: params.extensionsDir,
|
||||
npmDir: params.npmDir,
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
logger,
|
||||
mode,
|
||||
dryRun,
|
||||
skipPolicyPreflight: true,
|
||||
expectedPluginId,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
npmResolution,
|
||||
...(driftResult.integrityDrift ? { integrityDrift: driftResult.integrityDrift } : {}),
|
||||
});
|
||||
);
|
||||
emitSuccessfulPluginInstallSecurityEvent(result, {
|
||||
dryRun,
|
||||
mode: policyMode,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
validateOpenClawPackageInstallCompatibility,
|
||||
type PreparedInstallTarget,
|
||||
} from "./install-shared.js";
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import {
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
type InstallPluginResult,
|
||||
@@ -44,7 +45,7 @@ const PLUGIN_ARCHIVE_ROOT_MARKERS = [
|
||||
function pickPackageInstallCommonParams(
|
||||
params: InternalPackageInstallCommonParams,
|
||||
): InternalPackageInstallCommonParams {
|
||||
return {
|
||||
return copyPluginInstallTransactionRequest(params, {
|
||||
config: params.config,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
@@ -59,7 +60,7 @@ function pickPackageInstallCommonParams(
|
||||
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
|
||||
installPolicyRequest: params.installPolicyRequest,
|
||||
onEffectiveMode: params.onEffectiveMode,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function installPolicyRequestForPath(
|
||||
@@ -176,22 +177,24 @@ async function installBundleFromSourceDir(
|
||||
return scanResult;
|
||||
}
|
||||
|
||||
const installed = await installPluginDirectoryIntoExtensions({
|
||||
sourceDir: params.sourceDir,
|
||||
pluginId,
|
||||
manifestName: manifestRes.manifest.name,
|
||||
version: manifestRes.manifest.version,
|
||||
extensions: [],
|
||||
targetDir: targetResult.target.targetPath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
logger,
|
||||
timeoutMs,
|
||||
mode: targetResult.target.effectiveMode,
|
||||
dryRun,
|
||||
copyErrorPrefix: "failed to copy plugin bundle",
|
||||
hasDeps: false,
|
||||
depsLogMessage: "",
|
||||
});
|
||||
const installed = await installPluginDirectoryIntoExtensions(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
sourceDir: params.sourceDir,
|
||||
pluginId,
|
||||
manifestName: manifestRes.manifest.name,
|
||||
version: manifestRes.manifest.version,
|
||||
extensions: [],
|
||||
targetDir: targetResult.target.targetPath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
logger,
|
||||
timeoutMs,
|
||||
mode: targetResult.target.effectiveMode,
|
||||
dryRun,
|
||||
copyErrorPrefix: "failed to copy plugin bundle",
|
||||
hasDeps: false,
|
||||
depsLogMessage: "",
|
||||
}),
|
||||
);
|
||||
return installed.ok
|
||||
? {
|
||||
...installed,
|
||||
@@ -310,42 +313,44 @@ async function installPluginFromPackageDir(
|
||||
!hasBundleManifest &&
|
||||
params.installPolicyRequest?.kind === "plugin-archive";
|
||||
|
||||
return await installPluginDirectoryIntoExtensions({
|
||||
sourceDir: params.packageDir,
|
||||
pluginId: plugin.pluginId,
|
||||
manifestName: plugin.manifestName,
|
||||
version: plugin.version,
|
||||
extensions: plugin.extensions,
|
||||
setup: plugin.setup,
|
||||
targetDir: preparedTarget.targetPath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
logger,
|
||||
timeoutMs,
|
||||
mode: effectiveMode,
|
||||
dryRun,
|
||||
copyErrorPrefix: "failed to copy plugin",
|
||||
hasDeps: shouldInstallRuntimeDeps,
|
||||
sourceHardlinks: shouldInstallRuntimeDeps ? "package-manager" : "reject",
|
||||
depsLogMessage: "Installing plugin dependencies…",
|
||||
nameEncoder: encodePluginInstallDirName,
|
||||
afterInstall: async (installedDir) => {
|
||||
return await scanAndLinkInstalledPackage({
|
||||
runtime,
|
||||
installedDir,
|
||||
pluginId: plugin.pluginId,
|
||||
peerDependencies: plugin.peerDependencies,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
config: params.config,
|
||||
mode: effectiveMode,
|
||||
...(params.installPolicyRequest?.kind
|
||||
? { requestKind: params.installPolicyRequest.kind }
|
||||
: {}),
|
||||
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
|
||||
source: params.installPolicyRequest?.source,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
return await installPluginDirectoryIntoExtensions(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
sourceDir: params.packageDir,
|
||||
pluginId: plugin.pluginId,
|
||||
manifestName: plugin.manifestName,
|
||||
version: plugin.version,
|
||||
extensions: plugin.extensions,
|
||||
setup: plugin.setup,
|
||||
targetDir: preparedTarget.targetPath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
logger,
|
||||
timeoutMs,
|
||||
mode: effectiveMode,
|
||||
dryRun,
|
||||
copyErrorPrefix: "failed to copy plugin",
|
||||
hasDeps: shouldInstallRuntimeDeps,
|
||||
sourceHardlinks: shouldInstallRuntimeDeps ? "package-manager" : "reject",
|
||||
depsLogMessage: "Installing plugin dependencies…",
|
||||
nameEncoder: encodePluginInstallDirName,
|
||||
afterInstall: async (installedDir) => {
|
||||
return await scanAndLinkInstalledPackage({
|
||||
runtime,
|
||||
installedDir,
|
||||
pluginId: plugin.pluginId,
|
||||
peerDependencies: plugin.peerDependencies,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
config: params.config,
|
||||
mode: effectiveMode,
|
||||
...(params.installPolicyRequest?.kind
|
||||
? { requestKind: params.installPolicyRequest.kind }
|
||||
: {}),
|
||||
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
|
||||
source: params.installPolicyRequest?.source,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function installPluginFromArchive(
|
||||
@@ -378,22 +383,24 @@ export async function installPluginFromArchive(
|
||||
onExtracted: async (sourceDir) =>
|
||||
await installPluginFromSourceDir({
|
||||
sourceDir,
|
||||
...pickPackageInstallCommonParams({
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun: params.dryRun,
|
||||
config: params.config,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
requirePluginManifest: true,
|
||||
installPolicyRequest,
|
||||
onEffectiveMode: (resolvedMode) => {
|
||||
effectiveMode = resolvedMode;
|
||||
},
|
||||
}),
|
||||
...pickPackageInstallCommonParams(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun: params.dryRun,
|
||||
config: params.config,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
requirePluginManifest: true,
|
||||
installPolicyRequest,
|
||||
onEffectiveMode: (resolvedMode) => {
|
||||
effectiveMode = resolvedMode;
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
emitSuccessfulPluginInstallSecurityEvent(result, {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "../cli/plugins-cli-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { hasRetainedManagedNpmInstallMarker } from "./managed-npm-retention.js";
|
||||
import { recordPluginManifestInstallOwner } from "./manifest-install-owner.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
|
||||
function requireMockCallArg(
|
||||
@@ -45,19 +46,22 @@ function createManifestRecord(
|
||||
overrides: Partial<PluginManifestRecord> = {},
|
||||
): PluginManifestRecord {
|
||||
const rootDir = path.join(os.tmpdir(), "openclaw-plugin-fixtures", id);
|
||||
return {
|
||||
return recordPluginManifestInstallOwner(
|
||||
{
|
||||
id,
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "config",
|
||||
rootDir,
|
||||
source: path.join(rootDir, "index.ts"),
|
||||
manifestPath: path.join(rootDir, "openclaw.plugin.json"),
|
||||
...overrides,
|
||||
},
|
||||
id,
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "config",
|
||||
rootDir,
|
||||
source: path.join(rootDir, "index.ts"),
|
||||
manifestPath: path.join(rootDir, "openclaw.plugin.json"),
|
||||
...overrides,
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
const installWriteOptions = {
|
||||
@@ -105,7 +109,7 @@ describe("persistPluginInstall", () => {
|
||||
const [cfg, pluginId] = args as [OpenClawConfig, string];
|
||||
expect(pluginId).toBe("alpha");
|
||||
expect(cfg.plugins?.allow).toEqual(["memory-core", "alpha"]);
|
||||
return { config: enabledConfig };
|
||||
return { config: enabledConfig, enabled: true };
|
||||
});
|
||||
|
||||
const next = await persistPluginInstall({
|
||||
@@ -182,7 +186,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
clearPluginRegistryLoadCacheMock.mockImplementation(() => {
|
||||
throw new Error("cache unavailable");
|
||||
});
|
||||
@@ -220,7 +224,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
codex: {
|
||||
source: "clawhub",
|
||||
@@ -266,21 +270,23 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(planPluginUninstallMock).toHaveBeenCalledWith({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
codex: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/codex",
|
||||
installPath: "/tmp/openclaw/extensions/codex",
|
||||
expect(planPluginUninstallMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
codex: {
|
||||
source: "clawhub",
|
||||
spec: "clawhub:@openclaw/codex",
|
||||
installPath: "/tmp/openclaw/extensions/codex",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginId: "codex",
|
||||
deleteFiles: true,
|
||||
});
|
||||
pluginId: "codex",
|
||||
deleteFiles: true,
|
||||
}),
|
||||
);
|
||||
expect(applyPluginUninstallDirectoryRemovalMock).toHaveBeenCalledWith({
|
||||
target: "/tmp/openclaw/extensions/codex",
|
||||
});
|
||||
@@ -308,7 +314,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
setInstalledPluginIndexInstallRecords({
|
||||
codex: {
|
||||
source: "npm",
|
||||
@@ -349,7 +355,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-persist-"));
|
||||
const previousProjectRoot = path.join(tempRoot, "npm", "projects", "codex-v1");
|
||||
const previousInstallPath = path.join(
|
||||
@@ -415,21 +421,23 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(planPluginUninstallMock).toHaveBeenCalledWith({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
codex: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/codex@1.0.0",
|
||||
installPath: previousInstallPath,
|
||||
expect(planPluginUninstallMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
codex: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/codex@1.0.0",
|
||||
installPath: previousInstallPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginId: "codex",
|
||||
deleteFiles: true,
|
||||
});
|
||||
pluginId: "codex",
|
||||
deleteFiles: true,
|
||||
}),
|
||||
);
|
||||
expect(applyPluginUninstallDirectoryRemovalMock).not.toHaveBeenCalled();
|
||||
expect(hasRetainedManagedNpmInstallMarker(previousInstallPath)).toBe(true);
|
||||
} finally {
|
||||
@@ -451,7 +459,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
buildPluginSnapshotReportMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
@@ -510,7 +518,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
buildPluginSnapshotReportMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
@@ -554,7 +562,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
refreshPluginRegistryMock.mockRejectedValueOnce(new Error("registry unavailable"));
|
||||
|
||||
const next = await persistPluginInstall({
|
||||
@@ -591,7 +599,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
|
||||
const next = await persistPluginInstall({
|
||||
snapshot: {
|
||||
@@ -632,7 +640,7 @@ describe("persistPluginInstall", () => {
|
||||
const [cfg, pluginId] = args as [OpenClawConfig, string];
|
||||
expect(pluginId).toBe("alpha");
|
||||
expect(cfg.plugins?.deny).toEqual(["other"]);
|
||||
return { config: enabledConfig };
|
||||
return { config: enabledConfig, enabled: true };
|
||||
});
|
||||
|
||||
const next = await persistPluginInstall({
|
||||
@@ -669,7 +677,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [createManifestRecord("legacy-memory")],
|
||||
diagnostics: [],
|
||||
@@ -747,7 +755,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [createManifestRecord("memory-b", { kind: "memory" })],
|
||||
diagnostics: [],
|
||||
@@ -814,7 +822,7 @@ describe("persistPluginInstall", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig });
|
||||
enablePluginInConfigMock.mockReturnValue({ config: enabledConfig, enabled: true });
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [createManifestRecord("plain")],
|
||||
diagnostics: [],
|
||||
@@ -868,15 +876,18 @@ describe("persistPluginInstall", () => {
|
||||
} as OpenClawConfig;
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "needs-config",
|
||||
manifestPath: "/tmp/needs-config/openclaw.plugin.json",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string" } },
|
||||
recordPluginManifestInstallOwner(
|
||||
{
|
||||
id: "needs-config",
|
||||
manifestPath: "/tmp/needs-config/openclaw.plugin.json",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"needs-config",
|
||||
),
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
@@ -934,15 +945,18 @@ describe("persistPluginInstall", () => {
|
||||
} as OpenClawConfig;
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "needs-config",
|
||||
manifestPath: "/tmp/needs-config/openclaw.plugin.json",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string" } },
|
||||
recordPluginManifestInstallOwner(
|
||||
{
|
||||
id: "needs-config",
|
||||
manifestPath: "/tmp/needs-config/openclaw.plugin.json",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"needs-config",
|
||||
),
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
@@ -16,6 +16,11 @@ import { isPathInside } from "../infra/path-guards.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
import {
|
||||
isPluginCandidateInstallOwnerAmbiguous,
|
||||
resolvePluginCandidateInstallOwner,
|
||||
} from "./candidate-install-owner.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import { enablePluginInConfig } from "./enable.js";
|
||||
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
|
||||
import type { PluginInstallLogger } from "./install-types.js";
|
||||
@@ -25,12 +30,18 @@ import {
|
||||
withoutPluginInstallRecords,
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import { reconcileNpmPluginLoadPath, type PluginInstallUpdate } from "./installs.js";
|
||||
import { loadPluginManifestRegistryCore } from "./manifest-registry.js";
|
||||
import {
|
||||
isPluginManifestInstallOwnerAmbiguous,
|
||||
resolvePluginManifestInstallOwner,
|
||||
} from "./manifest-install-owner.js";
|
||||
import { loadPluginManifestRegistryCore, type PluginManifestRecord } from "./manifest-registry.js";
|
||||
import { safeRealpathSync } from "./path-safety.js";
|
||||
import { tracePluginLifecyclePhaseAsync } from "./plugin-lifecycle-trace.js";
|
||||
import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js";
|
||||
import { validateJsonSchemaValue } from "./schema-validator.js";
|
||||
import { applySlotSelectionForPlugin } from "./slot-selection.js";
|
||||
import { buildPluginSnapshotReport } from "./status.js";
|
||||
import { recordPluginPackageUninstallPlan } from "./uninstall-package-plan.js";
|
||||
import {
|
||||
applyPluginUninstallDirectoryRemoval,
|
||||
planPluginUninstall,
|
||||
@@ -386,17 +397,22 @@ function resolveReplacedManagedInstallRemoval(params: {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const plan = planPluginUninstall({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
[params.pluginId]: params.previousInstall,
|
||||
},
|
||||
const plan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
[params.pluginId]: params.previousInstall,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: params.pluginId,
|
||||
deleteFiles: true,
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: params.pluginId,
|
||||
deleteFiles: true,
|
||||
});
|
||||
{ runtimePluginIds: [] },
|
||||
),
|
||||
);
|
||||
if (!plan.ok || !plan.directoryRemoval) {
|
||||
return null;
|
||||
}
|
||||
@@ -435,12 +451,9 @@ type PluginConfigEnablement =
|
||||
function resolvePluginConfigEnablement(params: {
|
||||
config: OpenClawConfig;
|
||||
pluginId: string;
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
manifest?: PluginManifestRecord;
|
||||
}): PluginConfigEnablement {
|
||||
const manifest = loadPluginManifestRegistryCore({
|
||||
config: params.config,
|
||||
installRecords: params.installRecords,
|
||||
}).plugins.find((plugin) => plugin.id === params.pluginId);
|
||||
const manifest = params.manifest;
|
||||
if (!manifest?.configSchema) {
|
||||
return { mode: "ready" };
|
||||
}
|
||||
@@ -501,41 +514,112 @@ export async function persistPluginInstall(params: {
|
||||
previousInstall,
|
||||
nextInstall: params.install,
|
||||
});
|
||||
const configEnablement = resolvePluginConfigEnablement({
|
||||
config: reconciledConfig,
|
||||
pluginId: params.pluginId,
|
||||
installRecords: nextInstallRecords,
|
||||
const installedDiscovery = discoverOpenClawPlugins({ installRecords: nextInstallRecords });
|
||||
const realpathCache = new Map<string, string>();
|
||||
const targetPathKeys = new Set(
|
||||
[params.install.installPath, params.install.sourcePath]
|
||||
.filter((candidate): candidate is string => Boolean(candidate?.trim()))
|
||||
.map((candidate) => {
|
||||
const resolved = resolveUserPath(candidate, process.env);
|
||||
return safeRealpathSync(resolved, realpathCache) ?? path.resolve(resolved);
|
||||
}),
|
||||
);
|
||||
const installedCandidates = installedDiscovery.candidates.filter((candidate) => {
|
||||
if (resolvePluginCandidateInstallOwner(candidate) === params.pluginId) {
|
||||
return true;
|
||||
}
|
||||
const candidatePath = candidate.packageDir ?? candidate.rootDir;
|
||||
const resolved = resolveUserPath(candidatePath, process.env);
|
||||
const pathKey = safeRealpathSync(resolved, realpathCache) ?? path.resolve(resolved);
|
||||
return targetPathKeys.has(pathKey);
|
||||
});
|
||||
if (configEnablement.mode === "invalid") {
|
||||
if (installedCandidates.some(isPluginCandidateInstallOwnerAmbiguous)) {
|
||||
throw new Error(
|
||||
`Plugin "${params.pluginId}" has invalid configured settings: ${configEnablement.error}. Fix plugins.entries.${params.pluginId}.config, then rerun the install.`,
|
||||
`Plugin package "${params.pluginId}" has ambiguous install ownership. Refresh the plugin registry or reinstall the package before retrying.`,
|
||||
);
|
||||
}
|
||||
const shouldEnable = params.enable !== false && configEnablement.mode === "ready";
|
||||
const configBase =
|
||||
params.enable === false || configEnablement.mode === "ready"
|
||||
? reconciledConfig
|
||||
: prepareConfigForDisabledInstall(reconciledConfig, params.pluginId);
|
||||
const installConfig =
|
||||
params.enable === false
|
||||
? configBase
|
||||
: removeInstalledPluginFromDenylist(
|
||||
addInstalledPluginToAllowlist(configBase, params.pluginId),
|
||||
params.pluginId,
|
||||
);
|
||||
let next = shouldEnable
|
||||
? enablePluginInConfig(installConfig, params.pluginId, {
|
||||
updateChannelConfig: false,
|
||||
}).config
|
||||
: installConfig;
|
||||
const slotResult = shouldEnable
|
||||
? await tracePluginLifecyclePhaseAsync(
|
||||
"slot selection",
|
||||
async () => applySlotSelectionForPlugin(next, params.pluginId),
|
||||
{ command: "install", pluginId: params.pluginId },
|
||||
)
|
||||
: { config: next, warnings: [] };
|
||||
next = withoutPluginInstallRecords(slotResult.config);
|
||||
const installedRegistry = loadPluginManifestRegistryCore({
|
||||
config: reconciledConfig,
|
||||
candidates: installedCandidates,
|
||||
diagnostics: installedDiscovery.diagnostics,
|
||||
installRecords: nextInstallRecords,
|
||||
});
|
||||
if (installedRegistry.plugins.some(isPluginManifestInstallOwnerAmbiguous)) {
|
||||
throw new Error(
|
||||
`Plugin package "${params.pluginId}" has ambiguous install ownership. Refresh the plugin registry or reinstall the package before retrying.`,
|
||||
);
|
||||
}
|
||||
const manifests = installedRegistry.plugins.filter(
|
||||
(plugin) => resolvePluginManifestInstallOwner(plugin) === params.pluginId,
|
||||
);
|
||||
if (manifests.length === 0) {
|
||||
throw new Error(
|
||||
`Plugin package "${params.pluginId}" has no authoritative runtime child list. Refresh the plugin registry, then reinstall the package or run openclaw doctor before retrying.`,
|
||||
);
|
||||
}
|
||||
const ownedPluginIds = manifests.map((plugin) => plugin.id).toSorted();
|
||||
const manifestByPluginId = new Map(manifests.map((plugin) => [plugin.id, plugin]));
|
||||
const enablementByPluginId = new Map(
|
||||
ownedPluginIds.map((pluginId) => [
|
||||
pluginId,
|
||||
resolvePluginConfigEnablement({
|
||||
config: reconciledConfig,
|
||||
pluginId,
|
||||
manifest: manifestByPluginId.get(pluginId),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
for (const [pluginId, configEnablement] of enablementByPluginId) {
|
||||
if (configEnablement.mode === "invalid") {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" has invalid configured settings: ${configEnablement.error}. Fix plugins.entries.${pluginId}.config, then rerun the install.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let next = reconciledConfig;
|
||||
const enabledPluginIds: string[] = [];
|
||||
const preserveExistingPolicy = previousInstall !== undefined;
|
||||
for (const pluginId of ownedPluginIds) {
|
||||
const configEnablement = enablementByPluginId.get(pluginId) ?? { mode: "ready" as const };
|
||||
const explicitlyDisabled = reconciledConfig.plugins?.entries?.[pluginId]?.enabled === false;
|
||||
const existingAllow = reconciledConfig.plugins?.allow ?? [];
|
||||
const blockedByExistingPolicy =
|
||||
preserveExistingPolicy &&
|
||||
((reconciledConfig.plugins?.deny ?? []).includes(pluginId) ||
|
||||
(existingAllow.length > 0 && !existingAllow.includes(pluginId)));
|
||||
if (configEnablement.mode === "missing") {
|
||||
next = prepareConfigForDisabledInstall(next, pluginId);
|
||||
}
|
||||
if (params.enable === false) {
|
||||
continue;
|
||||
}
|
||||
if (!preserveExistingPolicy) {
|
||||
next = removeInstalledPluginFromDenylist(
|
||||
addInstalledPluginToAllowlist(next, pluginId),
|
||||
pluginId,
|
||||
);
|
||||
}
|
||||
if (configEnablement.mode !== "ready" || explicitlyDisabled || blockedByExistingPolicy) {
|
||||
continue;
|
||||
}
|
||||
const enabled = enablePluginInConfig(next, pluginId, { updateChannelConfig: false });
|
||||
next = enabled.config;
|
||||
if (enabled.enabled) {
|
||||
enabledPluginIds.push(pluginId);
|
||||
}
|
||||
}
|
||||
const slotWarnings: string[] = [];
|
||||
for (const pluginId of enabledPluginIds) {
|
||||
const slotResult = await tracePluginLifecyclePhaseAsync(
|
||||
"slot selection",
|
||||
async () => applySlotSelectionForPlugin(next, pluginId),
|
||||
{ command: "install", pluginId },
|
||||
);
|
||||
next = slotResult.config;
|
||||
slotWarnings.push(...slotResult.warnings);
|
||||
}
|
||||
next = withoutPluginInstallRecords(next);
|
||||
await tracePluginLifecyclePhaseAsync(
|
||||
"config mutation",
|
||||
() =>
|
||||
@@ -585,12 +669,17 @@ export async function persistPluginInstall(params: {
|
||||
),
|
||||
},
|
||||
});
|
||||
for (const warning of slotResult.warnings) {
|
||||
for (const warning of slotWarnings) {
|
||||
warn(warning, warning);
|
||||
}
|
||||
const configurationRequiredPluginIds = [...enablementByPluginId]
|
||||
.filter(([, state]) => state.mode === "missing")
|
||||
.map(([pluginId]) => pluginId);
|
||||
const configWarning =
|
||||
params.enable !== false && configEnablement.mode === "missing"
|
||||
? `Installed plugin "${params.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.pluginId}\`.`
|
||||
params.enable !== false && configurationRequiredPluginIds.length > 0
|
||||
? configurationRequiredPluginIds.length === 1
|
||||
? `Installed plugin "${configurationRequiredPluginIds[0]}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${configurationRequiredPluginIds[0]}\`.`
|
||||
: `Installed plugin entries ${configurationRequiredPluginIds.join(", ")} without enabling them because they require configuration first. Configure each entry, then run \`openclaw plugins enable <plugin-id>\`.`
|
||||
: undefined;
|
||||
const warningMessage = [params.warningMessage, configWarning].filter(Boolean).join("\n");
|
||||
if (warningMessage) {
|
||||
@@ -599,7 +688,12 @@ export async function persistPluginInstall(params: {
|
||||
configWarning ?? "Plugin installation reported a warning. Run `openclaw plugins doctor`.",
|
||||
);
|
||||
}
|
||||
runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`);
|
||||
runtime.log(
|
||||
params.successMessage ??
|
||||
(ownedPluginIds.length > 1
|
||||
? `Installed plugin package ${params.pluginId}: ${ownedPluginIds.join(", ")}`
|
||||
: `Installed plugin: ${params.pluginId}`),
|
||||
);
|
||||
logShadowedNpmInstallWarning({
|
||||
config: next,
|
||||
pluginId: params.pluginId,
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
resolveRetainedManagedNpmInstallMarkerPath,
|
||||
} from "./managed-npm-retention.js";
|
||||
import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js";
|
||||
import { recordPluginPackageUninstallPlan } from "./uninstall-package-plan.js";
|
||||
import { planPluginUninstall } from "./uninstall.js";
|
||||
|
||||
function mergeUnsetPaths(
|
||||
@@ -213,15 +214,20 @@ function resolveRetainedManagedNpmInstallMarkerTarget(params: {
|
||||
}
|
||||
const installs = createPluginInstallRecordMap<PluginInstallRecord>();
|
||||
setPluginInstallRecordMapEntry(installs, params.pluginId, params.previousRecord);
|
||||
const plan = planPluginUninstall({
|
||||
config: {
|
||||
plugins: {
|
||||
installs,
|
||||
const plan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: {
|
||||
plugins: {
|
||||
installs,
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: params.pluginId,
|
||||
deleteFiles: true,
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginId: params.pluginId,
|
||||
deleteFiles: true,
|
||||
});
|
||||
{ runtimePluginIds: [] },
|
||||
),
|
||||
);
|
||||
if (
|
||||
!plan.ok ||
|
||||
!plan.directoryRemoval ||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
requestDeferredPackageDirInstall,
|
||||
resolvePackageDirInstallTransaction,
|
||||
} from "../infra/install-package-dir.js";
|
||||
import type { InstallPolicySource } from "../security/install-policy.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
|
||||
import type { InstallSecurityScanResult } from "./install-security-scan.js";
|
||||
import {
|
||||
attachPluginInstallTransaction,
|
||||
isPluginInstallCommitDeferred,
|
||||
} from "./install-transaction.js";
|
||||
import {
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
type InstallPluginResult,
|
||||
@@ -413,7 +421,7 @@ export async function installPluginDirectoryIntoExtensions(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const installRes = await runtime.installPackageDir({
|
||||
const packageInstallParams = {
|
||||
sourceDir: params.sourceDir,
|
||||
targetDir,
|
||||
mode: params.mode,
|
||||
@@ -424,7 +432,7 @@ export async function installPluginDirectoryIntoExtensions(params: {
|
||||
sourceHardlinks: params.sourceHardlinks ?? "reject",
|
||||
depsLogMessage: params.depsLogMessage,
|
||||
afterCopy: params.afterCopy,
|
||||
afterInstall: async (installedDir) => {
|
||||
afterInstall: async (installedDir: string) => {
|
||||
const postInstallResult = await params.afterInstall?.(installedDir);
|
||||
if (!postInstallResult) {
|
||||
return { ok: true as const };
|
||||
@@ -435,7 +443,12 @@ export async function installPluginDirectoryIntoExtensions(params: {
|
||||
...(postInstallResult.code ? { code: postInstallResult.code } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
const installRes = await runtime.installPackageDir(
|
||||
isPluginInstallCommitDeferred(params)
|
||||
? requestDeferredPackageDirInstall(packageInstallParams)
|
||||
: packageInstallParams,
|
||||
);
|
||||
if (!installRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -444,14 +457,18 @@ export async function installPluginDirectoryIntoExtensions(params: {
|
||||
};
|
||||
}
|
||||
|
||||
return buildDirectoryInstallResult({
|
||||
pluginId: params.pluginId,
|
||||
targetDir,
|
||||
manifestName: params.manifestName,
|
||||
version: params.version,
|
||||
extensions: params.extensions,
|
||||
setup: params.setup,
|
||||
});
|
||||
const result = {
|
||||
...buildDirectoryInstallResult({
|
||||
pluginId: params.pluginId,
|
||||
targetDir,
|
||||
manifestName: params.manifestName,
|
||||
version: params.version,
|
||||
extensions: params.extensions,
|
||||
setup: params.setup,
|
||||
}),
|
||||
};
|
||||
const transaction = resolvePackageDirInstallTransaction(installRes);
|
||||
return transaction ? attachPluginInstallTransaction(result, transaction) : result;
|
||||
}
|
||||
|
||||
async function resolvePluginInstallTarget(params: {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
export type PluginInstallTransaction = {
|
||||
commit(): Promise<void>;
|
||||
rollback(): Promise<void>;
|
||||
};
|
||||
|
||||
const PLUGIN_INSTALL_TRANSACTION = Symbol.for("openclaw.pluginInstallTransaction");
|
||||
const PLUGIN_INSTALL_TRANSACTION_REQUEST = Symbol.for("openclaw.pluginInstallTransactionRequest");
|
||||
const PLUGIN_INSTALL_OWNER_MIGRATIONS = Symbol.for("openclaw.pluginInstallOwnerMigrations");
|
||||
|
||||
type PluginInstallTransactionRequest = {
|
||||
deferCommit: true;
|
||||
transactionSink?: PluginInstallTransaction[];
|
||||
};
|
||||
|
||||
export function attachPluginInstallTransaction<T extends object>(
|
||||
result: T,
|
||||
transaction: PluginInstallTransaction,
|
||||
): T {
|
||||
Object.defineProperty(result, PLUGIN_INSTALL_TRANSACTION, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: transaction,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolvePluginInstallTransaction(
|
||||
result: object,
|
||||
): PluginInstallTransaction | undefined {
|
||||
return (result as { [PLUGIN_INSTALL_TRANSACTION]?: PluginInstallTransaction })[
|
||||
PLUGIN_INSTALL_TRANSACTION
|
||||
];
|
||||
}
|
||||
|
||||
export function requestDeferredPluginInstall<T extends object>(
|
||||
params: T,
|
||||
transactionSink?: PluginInstallTransaction[],
|
||||
): T {
|
||||
Object.defineProperty(params, PLUGIN_INSTALL_TRANSACTION_REQUEST, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: {
|
||||
deferCommit: true,
|
||||
...(transactionSink ? { transactionSink } : {}),
|
||||
} satisfies PluginInstallTransactionRequest,
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
export function copyPluginInstallTransactionRequest<T extends object>(
|
||||
source: object,
|
||||
target: T,
|
||||
): T {
|
||||
const request = resolvePluginInstallTransactionRequest(source);
|
||||
return request ? requestDeferredPluginInstall(target, request.transactionSink) : target;
|
||||
}
|
||||
|
||||
function resolvePluginInstallTransactionRequest(
|
||||
params: object,
|
||||
): PluginInstallTransactionRequest | undefined {
|
||||
return (params as { [PLUGIN_INSTALL_TRANSACTION_REQUEST]?: PluginInstallTransactionRequest })[
|
||||
PLUGIN_INSTALL_TRANSACTION_REQUEST
|
||||
];
|
||||
}
|
||||
|
||||
export function isPluginInstallCommitDeferred(params: object): boolean {
|
||||
return resolvePluginInstallTransactionRequest(params)?.deferCommit === true;
|
||||
}
|
||||
|
||||
export function resolvePluginInstallTransactionSink(
|
||||
params: object,
|
||||
): PluginInstallTransaction[] | undefined {
|
||||
return resolvePluginInstallTransactionRequest(params)?.transactionSink;
|
||||
}
|
||||
|
||||
export function attachPluginInstallOwnerMigrations<T extends object>(
|
||||
result: T,
|
||||
migrations: Readonly<Record<string, string>>,
|
||||
): T {
|
||||
Object.defineProperty(result, PLUGIN_INSTALL_OWNER_MIGRATIONS, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: migrations,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolvePluginInstallOwnerMigrations(
|
||||
result: object,
|
||||
): Readonly<Record<string, string>> | undefined {
|
||||
return (result as { [PLUGIN_INSTALL_OWNER_MIGRATIONS]?: Readonly<Record<string, string>> })[
|
||||
PLUGIN_INSTALL_OWNER_MIGRATIONS
|
||||
];
|
||||
}
|
||||
|
||||
export async function settlePluginInstallTransactions(
|
||||
transactions: readonly PluginInstallTransaction[],
|
||||
action: "commit" | "rollback",
|
||||
): Promise<void> {
|
||||
const ordered = action === "rollback" ? transactions.toReversed() : transactions;
|
||||
const errors: unknown[] = [];
|
||||
for (const transaction of ordered) {
|
||||
try {
|
||||
await transaction[action]();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, `Plugin install transaction ${action} failed`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
type InstalledPluginIndexInstallOwner = { installOwner?: string; ambiguous?: true };
|
||||
type InstalledPluginIndexRecordWithOwner = {
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
};
|
||||
|
||||
export function recordInstalledPluginIndexInstallOwner<T extends object>(
|
||||
record: T,
|
||||
installOwner: string | undefined,
|
||||
ambiguous = false,
|
||||
): T {
|
||||
if (!installOwner && !ambiguous) {
|
||||
return record;
|
||||
}
|
||||
const ownedRecord = record as T & InstalledPluginIndexRecordWithOwner;
|
||||
if (ambiguous) {
|
||||
delete ownedRecord.installOwner;
|
||||
ownedRecord.installOwnerAmbiguous = true;
|
||||
} else {
|
||||
ownedRecord.installOwner = installOwner;
|
||||
delete ownedRecord.installOwnerAmbiguous;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function readInstalledPluginIndexInstallOwner(
|
||||
record: object,
|
||||
): InstalledPluginIndexInstallOwner | undefined {
|
||||
const ownedRecord = record as InstalledPluginIndexRecordWithOwner;
|
||||
return ownedRecord.installOwnerAmbiguous
|
||||
? { ambiguous: true }
|
||||
: ownedRecord.installOwner
|
||||
? { installOwner: ownedRecord.installOwner }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginIndexInstallOwner(record: object): string | undefined {
|
||||
return readInstalledPluginIndexInstallOwner(record)?.installOwner;
|
||||
}
|
||||
|
||||
export function isInstalledPluginIndexInstallOwnerAmbiguous(record: object): boolean {
|
||||
return readInstalledPluginIndexInstallOwner(record)?.ambiguous === true;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// Invalidates installed plugin index entries after activation metadata changes.
|
||||
import { hasConfigPathActivationMetadataMigration } from "./installed-plugin-index-config-path-scope.js";
|
||||
import { hashJson } from "./installed-plugin-index-hash.js";
|
||||
import {
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous,
|
||||
resolveInstalledPluginIndexInstallOwner,
|
||||
} from "./installed-plugin-index-install-owner.js";
|
||||
import type {
|
||||
InstalledPluginIndex,
|
||||
InstalledPluginIndexRefreshReason,
|
||||
@@ -41,6 +45,10 @@ export function diffInstalledPluginIndexInvalidationReasons(
|
||||
if (
|
||||
previousPlugin.rootDir !== currentPlugin.rootDir ||
|
||||
previousPlugin.manifestPath !== currentPlugin.manifestPath ||
|
||||
resolveInstalledPluginIndexInstallOwner(previousPlugin) !==
|
||||
resolveInstalledPluginIndexInstallOwner(currentPlugin) ||
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous(previousPlugin) !==
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous(currentPlugin) ||
|
||||
previousPlugin.installRecordHash !== currentPlugin.installRecordHash
|
||||
) {
|
||||
reasons.add("source-changed");
|
||||
|
||||
@@ -4,6 +4,10 @@ import { normalizeOptionalString as normalizeStringField } from "@openclaw/norma
|
||||
import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { getPluginInstallRecordMapEntry } from "../config/plugin-install-record-map.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import {
|
||||
isPluginCandidateInstallOwnerAmbiguous,
|
||||
resolvePluginCandidateInstallOwner,
|
||||
} from "./candidate-install-owner.js";
|
||||
import type { PluginCompatCode } from "./compat/registry.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
|
||||
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
|
||||
@@ -12,6 +16,7 @@ import { resolvePluginDoctorContractArtifactPath } from "./doctor-contract-artif
|
||||
import type { PluginInstallSourceInfo } from "./install-source-info.js";
|
||||
import { describePluginInstallSource } from "./install-source-info.js";
|
||||
import { hashJson, safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js";
|
||||
import { recordInstalledPluginIndexInstallOwner } from "./installed-plugin-index-install-owner.js";
|
||||
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
|
||||
import type {
|
||||
InstalledPluginContributionInfo,
|
||||
@@ -20,6 +25,7 @@ import type {
|
||||
InstalledPluginPackageChannelInfo,
|
||||
InstalledPluginStartupInfo,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
import { resolvePluginManifestInstallOwner } from "./manifest-install-owner.js";
|
||||
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import type { PluginPackageChannel } from "./manifest.js";
|
||||
@@ -221,11 +227,11 @@ function resolveManifestHash(params: {
|
||||
function buildCandidateLookup(
|
||||
candidates: readonly PluginCandidate[],
|
||||
): Map<string, PluginCandidate> {
|
||||
const byRootDir = new Map<string, PluginCandidate>();
|
||||
const bySource = new Map<string, PluginCandidate>();
|
||||
for (const candidate of candidates) {
|
||||
byRootDir.set(candidate.rootDir, candidate);
|
||||
bySource.set(candidate.source, candidate);
|
||||
}
|
||||
return byRootDir;
|
||||
return bySource;
|
||||
}
|
||||
|
||||
export function buildInstalledPluginIndexRecords(params: {
|
||||
@@ -235,13 +241,20 @@ export function buildInstalledPluginIndexRecords(params: {
|
||||
diagnostics: PluginDiagnostic[];
|
||||
installRecords: Record<string, InstalledPluginInstallRecordInfo>;
|
||||
}): InstalledPluginIndexRecord[] {
|
||||
const candidateByRootDir = buildCandidateLookup(params.candidates);
|
||||
const candidateBySource = buildCandidateLookup(params.candidates);
|
||||
const normalizedConfig = normalizePluginsConfig(params.config?.plugins);
|
||||
const realpathCache = new Map<string, string>();
|
||||
return params.registry.plugins.map((record): InstalledPluginIndexRecord => {
|
||||
const candidate = candidateByRootDir.get(record.rootDir);
|
||||
const candidate = candidateBySource.get(record.source);
|
||||
const packageJsonPath = resolvePackageJsonPath(candidate, realpathCache);
|
||||
const installRecord = getPluginInstallRecordMapEntry(params.installRecords, record.id);
|
||||
const installOwner =
|
||||
candidate && isPluginCandidateInstallOwnerAmbiguous(candidate)
|
||||
? undefined
|
||||
: (resolvePluginManifestInstallOwner(record) ??
|
||||
(candidate ? resolvePluginCandidateInstallOwner(candidate) : undefined));
|
||||
const installRecord = installOwner
|
||||
? getPluginInstallRecordMapEntry(params.installRecords, installOwner)
|
||||
: undefined;
|
||||
const packageInstall = describePackageInstallSource(candidate);
|
||||
const packageChannel = normalizePackageChannel(
|
||||
record.packageChannel ?? candidate?.packageManifest?.channel,
|
||||
@@ -330,6 +343,10 @@ export function buildInstalledPluginIndexRecords(params: {
|
||||
if (packageJson) {
|
||||
indexRecord.packageJson = packageJson;
|
||||
}
|
||||
return indexRecord;
|
||||
return recordInstalledPluginIndexInstallOwner(
|
||||
indexRecord,
|
||||
installOwner,
|
||||
candidate ? isPluginCandidateInstallOwnerAmbiguous(candidate) : false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
|
||||
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
|
||||
import { hashJson } from "./installed-plugin-index-hash.js";
|
||||
import {
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous,
|
||||
recordInstalledPluginIndexInstallOwner,
|
||||
resolveInstalledPluginIndexInstallOwner,
|
||||
} from "./installed-plugin-index-install-owner.js";
|
||||
import { resolveCompatRegistryVersion } from "./installed-plugin-index-policy.js";
|
||||
import { clearLoadInstalledPluginIndexInstallRecordsCache } from "./installed-plugin-index-record-cache.js";
|
||||
import {
|
||||
@@ -42,6 +47,7 @@ import {
|
||||
type LoadInstalledPluginIndexParams,
|
||||
type RefreshInstalledPluginIndexParams,
|
||||
} from "./installed-plugin-index.js";
|
||||
import { hasMissingInstalledPluginOwnerMetadata } from "./installed-plugin-package-ownership.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
|
||||
export {
|
||||
resolveInstalledPluginIndexStorePath,
|
||||
@@ -98,6 +104,8 @@ const InstalledPluginFileSignatureSchema = z.object({
|
||||
|
||||
const InstalledPluginIndexRecordSchema = z.object({
|
||||
pluginId: z.string(),
|
||||
installOwner: z.string().optional(),
|
||||
installOwnerAmbiguous: z.literal(true).optional(),
|
||||
packageName: z.string().optional(),
|
||||
packageVersion: z.string().optional(),
|
||||
installRecord: PluginInstallRecordSchema.optional(),
|
||||
@@ -159,8 +167,14 @@ const InstalledPluginIndexSchema = z.object({
|
||||
|
||||
export function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null {
|
||||
const parsed = safeParseWithSchema(InstalledPluginIndexSchema, value) as
|
||||
| (Omit<InstalledPluginIndex, "installRecords"> & {
|
||||
| (Omit<InstalledPluginIndex, "installRecords" | "plugins"> & {
|
||||
installRecords?: unknown;
|
||||
plugins: Array<
|
||||
InstalledPluginIndex["plugins"][number] & {
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
}
|
||||
>;
|
||||
})
|
||||
| null;
|
||||
if (!parsed) {
|
||||
@@ -182,7 +196,9 @@ export function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex
|
||||
generatedAtMs: parsed.generatedAtMs,
|
||||
...(parsed.refreshReason ? { refreshReason: parsed.refreshReason } : {}),
|
||||
installRecords,
|
||||
plugins: parsed.plugins,
|
||||
plugins: parsed.plugins.map(({ installOwner, installOwnerAmbiguous, ...plugin }) =>
|
||||
recordInstalledPluginIndexInstallOwner(plugin, installOwner, installOwnerAmbiguous === true),
|
||||
),
|
||||
diagnostics: parsed.diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -317,7 +333,18 @@ function writePersistedInstalledPluginIndexRow(
|
||||
generated_at_ms: index.generatedAtMs,
|
||||
refresh_reason: index.refreshReason ?? null,
|
||||
install_records_json: serializePluginInstallRecordMap(index.installRecords),
|
||||
plugins_json: JSON.stringify(index.plugins),
|
||||
plugins_json: JSON.stringify(
|
||||
index.plugins.map((plugin) => {
|
||||
const installOwner = resolveInstalledPluginIndexInstallOwner(plugin);
|
||||
return {
|
||||
...plugin,
|
||||
...(installOwner ? { installOwner } : {}),
|
||||
...(isInstalledPluginIndexInstallOwnerAmbiguous(plugin)
|
||||
? { installOwnerAmbiguous: true }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
),
|
||||
diagnostics_json: JSON.stringify(index.diagnostics),
|
||||
warning: index.warning ?? INSTALLED_PLUGIN_INDEX_WARNING,
|
||||
updated_at_ms: revision,
|
||||
@@ -488,7 +515,8 @@ function canRefreshPersistedPolicyState(
|
||||
persisted.hostContractVersion !== resolveCompatibilityHostVersion(env) ||
|
||||
persisted.compatRegistryVersion !== resolveCompatRegistryVersion() ||
|
||||
persisted.migrationVersion !== INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION ||
|
||||
hasMissingConfigPathActivationMetadata(persisted)
|
||||
hasMissingConfigPathActivationMetadata(persisted) ||
|
||||
hasMissingInstalledPluginOwnerMetadata(persisted, env)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import path from "node:path";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous,
|
||||
resolveInstalledPluginIndexInstallOwner,
|
||||
} from "./installed-plugin-index-install-owner.js";
|
||||
import type {
|
||||
InstalledPluginIndex,
|
||||
InstalledPluginInstallRecordInfo,
|
||||
} from "./installed-plugin-index-types.js";
|
||||
import { safeRealpathSync } from "./path-safety.js";
|
||||
|
||||
function collectDuplicateInstallRecordOwners(
|
||||
index: InstalledPluginIndex,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Set<string> {
|
||||
const ownersByPath = new Map<string, string>();
|
||||
const duplicateOwners = new Set<string>();
|
||||
const realpathCache = new Map<string, string>();
|
||||
for (const [installOwner, record] of Object.entries(index.installRecords)) {
|
||||
const rawPath = record.installPath?.trim() || record.sourcePath?.trim();
|
||||
if (!rawPath) {
|
||||
continue;
|
||||
}
|
||||
const resolved = path.resolve(resolveUserPath(rawPath, env));
|
||||
const pathKey = safeRealpathSync(resolved, realpathCache) ?? resolved;
|
||||
const existingOwner = ownersByPath.get(pathKey);
|
||||
if (existingOwner && existingOwner !== installOwner) {
|
||||
duplicateOwners.add(existingOwner);
|
||||
duplicateOwners.add(installOwner);
|
||||
}
|
||||
ownersByPath.set(pathKey, installOwner);
|
||||
}
|
||||
return duplicateOwners;
|
||||
}
|
||||
|
||||
export type InstalledPluginPackageOwnership = {
|
||||
installOwner: string;
|
||||
installRecord: InstalledPluginInstallRecordInfo;
|
||||
pluginIds: string[];
|
||||
};
|
||||
|
||||
type InstalledPluginPackageOwnershipResult =
|
||||
| { ok: true; value: InstalledPluginPackageOwnership }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function ownershipError(pluginId: string, detail: string): InstalledPluginPackageOwnershipResult {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Plugin "${pluginId}" ${detail}. ` +
|
||||
"Refresh the plugin registry, then reinstall the package or run openclaw doctor before retrying.",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginPackageOwnership(
|
||||
index: InstalledPluginIndex,
|
||||
pluginId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): InstalledPluginPackageOwnershipResult {
|
||||
const target = index.plugins.find((entry) => entry.pluginId === pluginId);
|
||||
if (target && isInstalledPluginIndexInstallOwnerAmbiguous(target)) {
|
||||
return ownershipError(pluginId, "has ambiguous package ownership");
|
||||
}
|
||||
const ownerFromTarget = target ? resolveInstalledPluginIndexInstallOwner(target) : undefined;
|
||||
if (target && !ownerFromTarget) {
|
||||
return ownershipError(pluginId, "has no authoritative package-owner metadata");
|
||||
}
|
||||
|
||||
const ownerFromRecord = Object.hasOwn(index.installRecords, pluginId) ? pluginId : undefined;
|
||||
const installOwner = ownerFromTarget ?? ownerFromRecord;
|
||||
if (!installOwner) {
|
||||
return ownershipError(pluginId, "is not associated with a tracked package install");
|
||||
}
|
||||
if (ownerFromTarget && ownerFromRecord && ownerFromTarget !== ownerFromRecord) {
|
||||
return ownershipError(pluginId, "matches conflicting package owners");
|
||||
}
|
||||
const installRecord = index.installRecords[installOwner];
|
||||
if (!installRecord) {
|
||||
return ownershipError(pluginId, `references missing package owner "${installOwner}"`);
|
||||
}
|
||||
if (collectDuplicateInstallRecordOwners(index, env).has(installOwner)) {
|
||||
return ownershipError(pluginId, `shares package path ownership with "${installOwner}"`);
|
||||
}
|
||||
|
||||
const pluginIds = index.plugins
|
||||
.filter(
|
||||
(entry) =>
|
||||
resolveInstalledPluginIndexInstallOwner(entry) === installOwner &&
|
||||
!isInstalledPluginIndexInstallOwnerAmbiguous(entry),
|
||||
)
|
||||
.map((entry) => entry.pluginId)
|
||||
.toSorted();
|
||||
if (pluginIds.length === 0) {
|
||||
return ownershipError(
|
||||
pluginId,
|
||||
`package owner "${installOwner}" has no authoritative runtime child list`,
|
||||
);
|
||||
}
|
||||
if (target && !pluginIds.includes(target.pluginId)) {
|
||||
return ownershipError(pluginId, `does not belong to package owner "${installOwner}"`);
|
||||
}
|
||||
|
||||
const hasUnsafePackageEntry = index.plugins.some(
|
||||
(entry) =>
|
||||
installRecordPathMatchesPluginRoot(installRecord, entry.rootDir, env) &&
|
||||
(isInstalledPluginIndexInstallOwnerAmbiguous(entry) ||
|
||||
resolveInstalledPluginIndexInstallOwner(entry) !== installOwner),
|
||||
);
|
||||
if (hasUnsafePackageEntry) {
|
||||
return ownershipError(pluginId, `package owner "${installOwner}" has conflicting child rows`);
|
||||
}
|
||||
return { ok: true, value: { installOwner, installRecord, pluginIds } };
|
||||
}
|
||||
|
||||
function installRecordPathMatchesPluginRoot(
|
||||
record: InstalledPluginInstallRecordInfo,
|
||||
rootDir: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
const realpathCache = new Map<string, string>();
|
||||
const resolvedRoot =
|
||||
safeRealpathSync(path.resolve(rootDir), realpathCache) ?? path.resolve(rootDir);
|
||||
return [record.installPath, record.sourcePath].some((candidate) => {
|
||||
if (!candidate?.trim()) {
|
||||
return false;
|
||||
}
|
||||
const candidatePath = path.resolve(resolveUserPath(candidate, env));
|
||||
const resolvedCandidate = safeRealpathSync(candidatePath, realpathCache) ?? candidatePath;
|
||||
const relative = path.relative(resolvedCandidate, resolvedRoot);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
});
|
||||
}
|
||||
|
||||
export function hasMissingInstalledPluginOwnerMetadata(
|
||||
index: InstalledPluginIndex,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (collectDuplicateInstallRecordOwners(index, env).size > 0) {
|
||||
return true;
|
||||
}
|
||||
const installRecords = Object.entries(index.installRecords);
|
||||
if (
|
||||
index.plugins.some(
|
||||
(plugin) =>
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous(plugin) ||
|
||||
(!resolveInstalledPluginIndexInstallOwner(plugin) &&
|
||||
installRecords.some(([, record]) =>
|
||||
installRecordPathMatchesPluginRoot(record, plugin.rootDir, env),
|
||||
)),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// An orphaned owner record (for example, package code removed out of band) is
|
||||
// already closed by the lifecycle resolver. It must not make every unrelated
|
||||
// config read attempt an impossible registry migration with no discoverable rows.
|
||||
return false;
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-
|
||||
import { quoteCliArg } from "../cli/quote-cli-arg.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
isPluginCandidateInstallOwnerAmbiguous,
|
||||
resolvePluginCandidateInstallOwner,
|
||||
resolvePluginInstallOwnerLookup,
|
||||
} from "./candidate-install-owner.js";
|
||||
import { isBundledPluginInsideDevSourceRoot } from "./dev-source-root.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js";
|
||||
@@ -142,17 +147,17 @@ function matchesExplicitInstallRule(params: {
|
||||
|
||||
function resolveCandidateDuplicateRank(params: {
|
||||
candidate: PluginCandidate;
|
||||
manifestBySource: Map<string, PluginManifestRecord>;
|
||||
provenance: PluginProvenanceIndex;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): number {
|
||||
const manifestRecord = params.manifestBySource.get(params.candidate.source);
|
||||
const pluginId = manifestRecord?.id;
|
||||
const installOwner = isPluginCandidateInstallOwnerAmbiguous(params.candidate)
|
||||
? undefined
|
||||
: resolvePluginCandidateInstallOwner(params.candidate);
|
||||
const isExplicitInstall =
|
||||
params.candidate.origin === "global" &&
|
||||
pluginId !== undefined &&
|
||||
installOwner !== undefined &&
|
||||
matchesExplicitInstallRule({
|
||||
pluginId,
|
||||
pluginId: installOwner,
|
||||
source: params.candidate.source,
|
||||
index: params.provenance,
|
||||
env: params.env,
|
||||
@@ -199,13 +204,11 @@ export function compareDuplicateCandidateOrder(params: {
|
||||
return (
|
||||
resolveCandidateDuplicateRank({
|
||||
candidate: params.left,
|
||||
manifestBySource: params.manifestBySource,
|
||||
provenance: params.provenance,
|
||||
env: params.env,
|
||||
}) -
|
||||
resolveCandidateDuplicateRank({
|
||||
candidate: params.right,
|
||||
manifestBySource: params.manifestBySource,
|
||||
provenance: params.provenance,
|
||||
env: params.env,
|
||||
})
|
||||
@@ -301,9 +304,11 @@ export function warnAboutUntrackedLoadedPlugins(params: {
|
||||
if (allowSet.has(plugin.id)) {
|
||||
continue;
|
||||
}
|
||||
const installOwner = resolvePluginInstallOwnerLookup(params)?.get(plugin.id);
|
||||
if (
|
||||
installOwner &&
|
||||
isTrackedByProvenance({
|
||||
pluginId: plugin.id,
|
||||
pluginId: installOwner,
|
||||
source: plugin.source,
|
||||
index: params.provenance,
|
||||
env: params.env,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
import { normalizeAgentToolResultMiddlewareRuntimeIds } from "./agent-tool-result-middleware.js";
|
||||
import {
|
||||
recordPluginInstallOwnerLookup,
|
||||
resolvePluginCandidateInstallOwner,
|
||||
} from "./candidate-install-owner.js";
|
||||
import { resolveEffectivePluginActivationState } from "./config-state.js";
|
||||
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
|
||||
import {
|
||||
@@ -247,14 +251,25 @@ function loadOpenClawPluginsInternal(
|
||||
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
|
||||
});
|
||||
}
|
||||
warnAboutUntrackedLoadedPlugins({
|
||||
registry,
|
||||
provenance,
|
||||
allowlist: context.normalized.allow,
|
||||
emitWarning: context.shouldActivate,
|
||||
logger,
|
||||
env: context.env,
|
||||
});
|
||||
warnAboutUntrackedLoadedPlugins(
|
||||
recordPluginInstallOwnerLookup(
|
||||
{
|
||||
registry,
|
||||
provenance,
|
||||
allowlist: context.normalized.allow,
|
||||
emitWarning: context.shouldActivate,
|
||||
logger,
|
||||
env: context.env,
|
||||
},
|
||||
new Map(
|
||||
orderedCandidates.flatMap((candidate) => {
|
||||
const pluginId = manifestBySource.get(candidate.source)?.id;
|
||||
const installOwner = resolvePluginCandidateInstallOwner(candidate);
|
||||
return pluginId && installOwner ? [[pluginId, installOwner] as const] : [];
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
maybeThrowOnPluginLoadError(registry, options.throwOnLoadError);
|
||||
if (context.shouldActivate && options.mode !== "validate") {
|
||||
const failedPlugins = registry.plugins.filter((plugin) => plugin.failedAt != null);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePluginCandidateInstallOwner } from "./candidate-install-owner.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import { createManifestPluginRecord, validatePluginConfig } from "./loader-shared.js";
|
||||
import {
|
||||
createManifestPluginRecord,
|
||||
createPluginCandidatesFromManifestRegistry,
|
||||
validatePluginConfig,
|
||||
} from "./loader-shared.js";
|
||||
import { recordPluginManifestInstallOwner } from "./manifest-install-owner.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
|
||||
const emptyObjectSchema = {
|
||||
@@ -57,6 +63,25 @@ describe("createManifestPluginRecord", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPluginCandidatesFromManifestRegistry", () => {
|
||||
it("preserves runtime child identity and package ownership", () => {
|
||||
const childRecord = recordPluginManifestInstallOwner(
|
||||
{ ...manifestRecord, id: "example/child" },
|
||||
"example",
|
||||
);
|
||||
|
||||
const candidate = createPluginCandidatesFromManifestRegistry({
|
||||
plugins: [childRecord],
|
||||
diagnostics: [],
|
||||
})[0];
|
||||
expect(candidate).toMatchObject({
|
||||
idHint: "example/child",
|
||||
effectivePluginId: "example/child",
|
||||
});
|
||||
expect(resolvePluginCandidateInstallOwner(candidate!)).toBe("example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePluginConfig empty schema classification", () => {
|
||||
it("validates pattern properties instead of requiring empty config", () => {
|
||||
const schema = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingPluginConfig,
|
||||
} from "../memory-host-sdk/dreaming.js";
|
||||
import { recordPluginCandidateInstallOwner } from "./candidate-install-owner.js";
|
||||
import {
|
||||
resolveEffectiveEnableState,
|
||||
type NormalizedPluginsConfig,
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
import { collectPluginManifestCompatCodes } from "./installed-plugin-index-record-builder.js";
|
||||
import { createPluginRecord } from "./loader-records.js";
|
||||
import type { PluginLoadOptions, PluginRuntimeSubagentMode } from "./loader-types.js";
|
||||
import {
|
||||
isPluginManifestInstallOwnerAmbiguous,
|
||||
resolvePluginManifestInstallOwner,
|
||||
} from "./manifest-install-owner.js";
|
||||
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import type { PluginRecord, PluginRegistry } from "./registry.js";
|
||||
@@ -154,17 +159,27 @@ export function matchesScopedPluginOrDreamingSidecar(params: {
|
||||
export function createPluginCandidatesFromManifestRegistry(
|
||||
manifestRegistry: PluginManifestRegistry,
|
||||
): PluginCandidate[] {
|
||||
return manifestRegistry.plugins.map((record) => ({
|
||||
idHint: record.id,
|
||||
rootDir: record.rootDir,
|
||||
source: record.source,
|
||||
...(record.setupSource !== undefined ? { setupSource: record.setupSource } : {}),
|
||||
origin: record.origin,
|
||||
...(record.workspaceDir !== undefined ? { workspaceDir: record.workspaceDir } : {}),
|
||||
...(record.format !== undefined ? { format: record.format } : {}),
|
||||
...(record.bundleFormat !== undefined ? { bundleFormat: record.bundleFormat } : {}),
|
||||
...(record.packageManifest !== undefined ? { packageManifest: record.packageManifest } : {}),
|
||||
}));
|
||||
return manifestRegistry.plugins.map((record) => {
|
||||
const installOwner = resolvePluginManifestInstallOwner(record);
|
||||
return recordPluginCandidateInstallOwner(
|
||||
{
|
||||
idHint: record.id,
|
||||
effectivePluginId: record.id,
|
||||
rootDir: record.rootDir,
|
||||
source: record.source,
|
||||
...(record.setupSource !== undefined ? { setupSource: record.setupSource } : {}),
|
||||
origin: record.origin,
|
||||
...(record.workspaceDir !== undefined ? { workspaceDir: record.workspaceDir } : {}),
|
||||
...(record.format !== undefined ? { format: record.format } : {}),
|
||||
...(record.bundleFormat !== undefined ? { bundleFormat: record.bundleFormat } : {}),
|
||||
...(record.packageManifest !== undefined
|
||||
? { packageManifest: record.packageManifest }
|
||||
: {}),
|
||||
},
|
||||
installOwner,
|
||||
isPluginManifestInstallOwnerAmbiguous(record),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class PluginLoadFailureError extends Error {
|
||||
|
||||
@@ -133,6 +133,10 @@ function metadataSnapshot(params: {
|
||||
icon?: string;
|
||||
}) {
|
||||
const id = params.id ?? "workboard";
|
||||
const origin = params.origin ?? "bundled";
|
||||
const installRecord =
|
||||
params.installRecord ??
|
||||
(origin === "global" ? { source: "path", installPath: `/tmp/${id}` } : undefined);
|
||||
const manifest = {
|
||||
id,
|
||||
name: params.name ?? "Workboard",
|
||||
@@ -144,7 +148,7 @@ function metadataSnapshot(params: {
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: params.origin ?? "bundled",
|
||||
origin,
|
||||
rootDir: `/tmp/${id}`,
|
||||
source: `/tmp/${id}/index.ts`,
|
||||
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
|
||||
@@ -154,12 +158,14 @@ function metadataSnapshot(params: {
|
||||
plugins: [
|
||||
{
|
||||
pluginId: id,
|
||||
...(origin === "global" ? { installOwner: id } : {}),
|
||||
packageName: `@openclaw/${id}`,
|
||||
origin: params.origin ?? "bundled",
|
||||
origin,
|
||||
enabled: params.enabled,
|
||||
rootDir: `/tmp/${id}`,
|
||||
},
|
||||
],
|
||||
installRecords: params.installRecord ? { [id]: params.installRecord } : {},
|
||||
installRecords: installRecord ? { [id]: installRecord } : {},
|
||||
},
|
||||
byPluginId: new Map([[id, manifest]]),
|
||||
plugins: [manifest],
|
||||
@@ -213,8 +219,7 @@ const hostedDiffsEntry = {
|
||||
},
|
||||
};
|
||||
|
||||
// Mirrors the current default ClawHub feed shape: package identity lives in a
|
||||
// source candidate while runtime/editorial metadata remains local.
|
||||
// Mirrors the ClawHub feed: package identity is remote, while runtime metadata stays local.
|
||||
const hostedFeedDiffsEntry = {
|
||||
id: "@openclaw/diffs",
|
||||
title: "Diffs",
|
||||
@@ -818,8 +823,7 @@ describe("plugin management service", () => {
|
||||
env,
|
||||
}),
|
||||
).rejects.toBe(conflict);
|
||||
expect(mocks.installRecords).toHaveBeenCalledWith({ env });
|
||||
expect(mocks.planUninstall).toHaveBeenCalledWith({
|
||||
expect(mocks.planUninstall.mock.calls[0]?.[0]).toMatchObject({
|
||||
config: {
|
||||
plugins: {
|
||||
installs: {
|
||||
@@ -833,7 +837,6 @@ describe("plugin management service", () => {
|
||||
},
|
||||
pluginId: "demo",
|
||||
deleteFiles: true,
|
||||
extensionsDir: expect.any(String),
|
||||
});
|
||||
expect(mocks.applyUninstall).toHaveBeenCalledWith({ target: targetDir });
|
||||
});
|
||||
@@ -913,6 +916,7 @@ describe("plugin management service", () => {
|
||||
mocks.replaceConfig.mockResolvedValue({});
|
||||
mocks.refreshRegistry.mockResolvedValue(undefined);
|
||||
mocks.metadata
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: true, id: "demo", origin: "global" }))
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: true, id: "demo", origin: "global" }))
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: false }))
|
||||
.mockReturnValueOnce(metadataSnapshot({ enabled: true }));
|
||||
@@ -1050,7 +1054,6 @@ describe("plugin management service", () => {
|
||||
writeOptions: prepared.writeOptions,
|
||||
}),
|
||||
);
|
||||
// Transient install records never persist into the written config document.
|
||||
expect(
|
||||
expectDefined(
|
||||
mocks.commitRecords.mock.calls[0],
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
withPluginInstallRecords,
|
||||
withoutPluginInstallRecords,
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import { resolveInstalledPluginPackageOwnership } from "./installed-plugin-package-ownership.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import {
|
||||
@@ -81,12 +82,15 @@ import { refreshPluginRegistryAfterConfigMutation } from "./registry-refresh.js"
|
||||
import { applySlotSelectionForPlugin } from "./slot-selection.js";
|
||||
import { setPluginEnabledInConfig } from "./toggle-config.js";
|
||||
import { collectClawPluginUninstallWarnings } from "./uninstall-claw-references.js";
|
||||
import {
|
||||
prepareConfigForPendingPluginDirectoryRemovalSet,
|
||||
recordPluginPackageUninstallPlan,
|
||||
} from "./uninstall-package-plan.js";
|
||||
import {
|
||||
applyPluginUninstallDirectoryRemoval,
|
||||
formatUninstallActionLabels,
|
||||
planPluginUninstall,
|
||||
pluginUninstallTargetExists,
|
||||
prepareConfigForPendingPluginDirectoryRemoval,
|
||||
} from "./uninstall.js";
|
||||
|
||||
type ManagedPluginCatalogEntry = {
|
||||
@@ -536,6 +540,7 @@ type PluginIndexRecord = PluginMetadataSnapshot["index"]["plugins"][number];
|
||||
|
||||
function resolveInstalledHostedOfficialEntry(params: {
|
||||
record: PluginIndexRecord;
|
||||
installOwner?: string;
|
||||
installRecord?: PluginInstallRecord;
|
||||
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
||||
bundledOfficialEntries: readonly OfficialExternalPluginCatalogEntry[];
|
||||
@@ -543,15 +548,16 @@ function resolveInstalledHostedOfficialEntry(params: {
|
||||
entry?: OfficialExternalPluginCatalogEntry;
|
||||
hasPublishedIdentity: boolean;
|
||||
} {
|
||||
const identityPluginId = params.installOwner ?? params.record.pluginId;
|
||||
const trustedOfficialClawHubSpec = params.installRecord
|
||||
? resolveTrustedSourceLinkedOfficialClawHubSpec({
|
||||
pluginId: params.record.pluginId,
|
||||
pluginId: identityPluginId,
|
||||
record: params.installRecord,
|
||||
})
|
||||
: undefined;
|
||||
const trustedOfficialNpmSpec = params.installRecord
|
||||
? resolveTrustedSourceLinkedOfficialNpmSpec({
|
||||
pluginId: params.record.pluginId,
|
||||
pluginId: identityPluginId,
|
||||
record: params.installRecord,
|
||||
})
|
||||
: undefined;
|
||||
@@ -630,9 +636,12 @@ function resolvePluginIconUrlFromCatalogFacts(params: {
|
||||
if (!record) {
|
||||
return resolveOfficialCatalogIconUrl(params.officialEntries, normalizedPluginId);
|
||||
}
|
||||
const ownership = resolveInstalledPluginPackageOwnership(params.metadata.index, record.pluginId);
|
||||
const installOwner = ownership.ok ? ownership.value.installOwner : undefined;
|
||||
const { entry: officialEntry } = resolveInstalledHostedOfficialEntry({
|
||||
record,
|
||||
installRecord: params.metadata.index.installRecords[record.pluginId],
|
||||
...(installOwner ? { installOwner } : {}),
|
||||
installRecord: installOwner ? params.metadata.index.installRecords[installOwner] : undefined,
|
||||
officialEntries: params.officialEntries,
|
||||
bundledOfficialEntries:
|
||||
params.bundledOfficialEntries ?? listOfficialExternalPluginCatalogEntries(),
|
||||
@@ -723,9 +732,12 @@ export async function listManagedPlugins(params: {
|
||||
const plugins = metadata.index.plugins.map((record): ManagedPluginCatalogEntry => {
|
||||
const manifest = metadata.byPluginId.get(record.pluginId);
|
||||
const localCatalog = normalizeCatalogMetadata(manifest?.catalog);
|
||||
const installRecord = metadata.index.installRecords[record.pluginId];
|
||||
const ownership = resolveInstalledPluginPackageOwnership(metadata.index, record.pluginId);
|
||||
const installOwner = ownership.ok ? ownership.value.installOwner : undefined;
|
||||
const installRecord = installOwner ? metadata.index.installRecords[installOwner] : undefined;
|
||||
const { entry: officialEntry, hasPublishedIdentity } = resolveInstalledHostedOfficialEntry({
|
||||
record,
|
||||
...(installOwner ? { installOwner } : {}),
|
||||
installRecord,
|
||||
officialEntries: officialCatalog.entries,
|
||||
bundledOfficialEntries,
|
||||
@@ -750,8 +762,7 @@ export async function listManagedPlugins(params: {
|
||||
const kind = normalizeKinds(manifest?.kind);
|
||||
const category = derivePluginCategory(manifest);
|
||||
// Only externally installed plugins (tracked install record, non-bundled) can be removed.
|
||||
const removable =
|
||||
record.origin !== "bundled" && Boolean(metadata.index.installRecords[record.pluginId]);
|
||||
const removable = record.origin !== "bundled" && Boolean(installOwner);
|
||||
// Prefer human labels over package specifiers: the registry backfills a
|
||||
// missing manifest name with the npm package name, which is an install
|
||||
// spec rather than a display name.
|
||||
@@ -1017,14 +1028,19 @@ async function cleanupFailedManagedPluginInstall(params: {
|
||||
];
|
||||
}
|
||||
|
||||
const plan = planPluginUninstall({
|
||||
config: {
|
||||
plugins: { installs: { [params.pluginId]: params.install } },
|
||||
},
|
||||
pluginId: params.pluginId,
|
||||
deleteFiles: true,
|
||||
extensionsDir: params.extensionsDir,
|
||||
});
|
||||
const plan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: {
|
||||
plugins: { installs: { [params.pluginId]: params.install } },
|
||||
},
|
||||
pluginId: params.pluginId,
|
||||
deleteFiles: true,
|
||||
extensionsDir: params.extensionsDir,
|
||||
},
|
||||
{ runtimePluginIds: [] },
|
||||
),
|
||||
);
|
||||
if (!plan.ok) {
|
||||
return [`Could not plan cleanup for failed plugin install: ${plan.error}`];
|
||||
}
|
||||
@@ -1436,7 +1452,20 @@ export async function installManagedPlugin(params: {
|
||||
env,
|
||||
officialCatalog,
|
||||
});
|
||||
const plugin = catalog.plugins.find((entry) => entry.id === installed.pluginId);
|
||||
const installedMetadata = resolvePluginMetadataSnapshot(
|
||||
resolveManagedPluginMetadataParams(installed.config, env),
|
||||
);
|
||||
const installedOwnership = resolveInstalledPluginPackageOwnership(
|
||||
installedMetadata.index,
|
||||
installed.pluginId,
|
||||
env,
|
||||
);
|
||||
if (!installedOwnership.ok) {
|
||||
throw new ManagedPluginLifecycleError(installedOwnership.error);
|
||||
}
|
||||
const installedPluginIds = installedOwnership.value.pluginIds;
|
||||
const representativePluginId = installedPluginIds[0]!;
|
||||
const plugin = catalog.plugins.find((entry) => entry.id === representativePluginId);
|
||||
if (!plugin) {
|
||||
throw new ManagedPluginLifecycleError(
|
||||
`installed plugin missing from refreshed registry: ${installed.pluginId}`,
|
||||
@@ -1444,7 +1473,18 @@ export async function installManagedPlugin(params: {
|
||||
}
|
||||
return {
|
||||
plugin,
|
||||
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}),
|
||||
...(installedPluginIds.length > 1 || warnings.length > 0
|
||||
? {
|
||||
warnings: [
|
||||
...(installedPluginIds.length > 1
|
||||
? [
|
||||
`Installed package "${installed.pluginId}" with plugin entries: ${installedPluginIds.join(", ")}.`,
|
||||
]
|
||||
: []),
|
||||
...new Set(warnings),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1546,16 +1586,40 @@ export async function uninstallManagedPlugin(params: {
|
||||
`bundled plugin cannot be uninstalled: ${pluginId}; disable it instead`,
|
||||
);
|
||||
}
|
||||
// Preserve manifest ownership exactly; only missing metadata uses the plugin-id fallback.
|
||||
const channelIds = metadata.byPluginId.get(pluginId)?.channels;
|
||||
const extensionsDir = resolveDefaultPluginExtensionsDir(env);
|
||||
const initialPlan = planPluginUninstall({
|
||||
config: configWithRecords,
|
||||
pluginId,
|
||||
...(channelIds ? { channelIds } : {}),
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
if (!record && !Object.hasOwn(installRecords, pluginId)) {
|
||||
throw new ManagedPluginLifecycleError(`Plugin not found: ${pluginId}`);
|
||||
}
|
||||
const ownership = resolveInstalledPluginPackageOwnership(metadata.index, pluginId, env);
|
||||
if (!ownership.ok) {
|
||||
throw new ManagedPluginLifecycleError(ownership.error);
|
||||
}
|
||||
const { installOwner, pluginIds: ownedPluginIds } = ownership.value;
|
||||
const ownedManifests = ownedPluginIds.flatMap((entryId) => {
|
||||
const manifest = metadata.byPluginId.get(entryId);
|
||||
return manifest ? [manifest] : [];
|
||||
});
|
||||
const channelIds =
|
||||
ownedManifests.length > 0
|
||||
? uniqueStrings(ownedManifests.flatMap((manifest) => manifest.channels))
|
||||
: undefined;
|
||||
const extensionsDir = resolveDefaultPluginExtensionsDir(env);
|
||||
const initialPlan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: configWithRecords,
|
||||
pluginId: installOwner,
|
||||
...(channelIds !== undefined ? { channelIds } : {}),
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
},
|
||||
{
|
||||
runtimePluginIds: ownedPluginIds,
|
||||
runtimeLoadPaths: ownedPluginIds.flatMap(
|
||||
(entryId) => metadata.byPluginId.get(entryId)?.source ?? [],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!initialPlan.ok) {
|
||||
throw new ManagedPluginLifecycleError(initialPlan.error);
|
||||
}
|
||||
@@ -1563,9 +1627,9 @@ export async function uninstallManagedPlugin(params: {
|
||||
let finalSnapshot = snapshot;
|
||||
let directoryResult = { directoryRemoved: false, warnings: [] as string[] };
|
||||
if (plan.directoryRemoval) {
|
||||
const disabledConfig = prepareConfigForPendingPluginDirectoryRemoval(
|
||||
const disabledConfig = prepareConfigForPendingPluginDirectoryRemovalSet(
|
||||
snapshot.config,
|
||||
pluginId,
|
||||
ownedPluginIds,
|
||||
);
|
||||
await replaceConfigFile({
|
||||
nextConfig: disabledConfig,
|
||||
@@ -1587,20 +1651,30 @@ export async function uninstallManagedPlugin(params: {
|
||||
finalSnapshot.config,
|
||||
installRecords,
|
||||
);
|
||||
const refreshedPlan = planPluginUninstall({
|
||||
config: refreshedConfigWithRecords,
|
||||
pluginId,
|
||||
...(channelIds ? { channelIds } : {}),
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
const refreshedPlan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: refreshedConfigWithRecords,
|
||||
pluginId: installOwner,
|
||||
...(channelIds !== undefined ? { channelIds } : {}),
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
},
|
||||
{
|
||||
runtimePluginIds: ownedPluginIds,
|
||||
runtimeLoadPaths: ownedPluginIds.flatMap(
|
||||
(entryId) => metadata.byPluginId.get(entryId)?.source ?? [],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!refreshedPlan.ok) {
|
||||
throw new ManagedPluginLifecycleError(refreshedPlan.error);
|
||||
}
|
||||
plan = refreshedPlan;
|
||||
}
|
||||
const nextConfig = withoutPluginInstallRecords(plan.config);
|
||||
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
|
||||
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, installOwner);
|
||||
await commitPluginInstallRecordsWithConfig({
|
||||
previousInstallRecords: installRecords,
|
||||
nextInstallRecords,
|
||||
@@ -1610,10 +1684,15 @@ export async function uninstallManagedPlugin(params: {
|
||||
});
|
||||
const warnings = [
|
||||
...collectClawPluginUninstallWarnings({
|
||||
pluginId,
|
||||
installRecord: installRecords[pluginId],
|
||||
pluginId: installOwner,
|
||||
installRecord: installRecords[installOwner],
|
||||
env,
|
||||
}),
|
||||
...(pluginId !== installOwner || ownedPluginIds.length > 1
|
||||
? [
|
||||
`Uninstalled package "${installOwner}" and all owned plugin entries: ${ownedPluginIds.join(", ")}.`,
|
||||
]
|
||||
: []),
|
||||
...directoryResult.warnings,
|
||||
];
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
@@ -1629,7 +1708,7 @@ export async function uninstallManagedPlugin(params: {
|
||||
directory: directoryResult.directoryRemoved,
|
||||
});
|
||||
return {
|
||||
pluginId,
|
||||
pluginId: installOwner,
|
||||
removed,
|
||||
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {}),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { recordInstalledPluginIndexInstallOwner } from "./installed-plugin-index-install-owner.js";
|
||||
import { recordPluginManifestInstallOwner } from "./manifest-install-owner.js";
|
||||
import { resolvePluginPackageUninstallPlan } from "./uninstall-package-plan.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
commitRecords: vi.fn(),
|
||||
@@ -46,7 +49,7 @@ vi.mock("./uninstall.js", async (importOriginal) => {
|
||||
return { ...original, planPluginUninstall: vi.fn(original.planPluginUninstall) };
|
||||
});
|
||||
|
||||
const { uninstallManagedPlugin } = await import("./management-service.js");
|
||||
const { listManagedPlugins, uninstallManagedPlugin } = await import("./management-service.js");
|
||||
const { planPluginUninstall } = await import("./uninstall.js");
|
||||
|
||||
describe("plugin management uninstall channel ownership", () => {
|
||||
@@ -63,7 +66,6 @@ describe("plugin management uninstall channel ownership", () => {
|
||||
channelIds: ["owned-channel", "owned-channel-backup"],
|
||||
},
|
||||
{ label: "an enabled channel plugin", enabled: true, channelIds: ["owned-channel"] },
|
||||
{ label: "a plugin with unavailable manifest metadata", enabled: false, channelIds: undefined },
|
||||
])(
|
||||
"preserves manifest channel ownership when uninstalling $label",
|
||||
async ({ enabled, channelIds }) => {
|
||||
@@ -87,29 +89,33 @@ describe("plugin management uninstall channel ownership", () => {
|
||||
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
|
||||
});
|
||||
mocks.installRecords.mockResolvedValue({ [pluginId]: installRecord });
|
||||
const manifest =
|
||||
channelIds === undefined ? undefined : { id: pluginId, channels: channelIds };
|
||||
const manifest = recordPluginManifestInstallOwner(
|
||||
{ id: pluginId, channels: channelIds },
|
||||
pluginId,
|
||||
);
|
||||
mocks.metadata.mockReturnValue({
|
||||
index: {
|
||||
plugins: manifest ? [{ pluginId, origin: "global", enabled }] : [],
|
||||
plugins: [
|
||||
recordInstalledPluginIndexInstallOwner(
|
||||
{ pluginId, origin: "global", enabled, rootDir: installPath },
|
||||
pluginId,
|
||||
),
|
||||
],
|
||||
installRecords: { [pluginId]: installRecord },
|
||||
},
|
||||
byPluginId: new Map(manifest ? [[pluginId, manifest]] : []),
|
||||
byPluginId: new Map([[pluginId, manifest]]),
|
||||
normalizePluginId: (rawPluginId: string) => rawPluginId,
|
||||
});
|
||||
|
||||
const result = await uninstallManagedPlugin({ pluginId, env: {} });
|
||||
|
||||
const ownedChannelIds = channelIds ?? [pluginId];
|
||||
const ownedChannelIds = channelIds;
|
||||
expect(planPluginUninstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId,
|
||||
...(channelIds === undefined ? {} : { channelIds }),
|
||||
channelIds,
|
||||
}),
|
||||
);
|
||||
if (channelIds === undefined) {
|
||||
expect(vi.mocked(planPluginUninstall).mock.calls[0]?.[0]).not.toHaveProperty("channelIds");
|
||||
}
|
||||
expect(mocks.commitRecords).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nextConfig: expect.objectContaining({
|
||||
@@ -129,4 +135,170 @@ describe("plugin management uninstall channel ownership", () => {
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("fails closed when an owner record has no authoritative child metadata", async () => {
|
||||
const pluginId = "custom-plugin";
|
||||
const installPath = "/tmp/openclaw-managed-missing-children";
|
||||
const installRecord = { source: "path", sourcePath: installPath, installPath } as const;
|
||||
mocks.readConfig.mockResolvedValue({
|
||||
snapshot: {
|
||||
valid: true,
|
||||
parsed: {},
|
||||
path: "/tmp/openclaw.json",
|
||||
sourceConfig: { plugins: { entries: { [pluginId]: { enabled: true } } } },
|
||||
hash: "base-hash",
|
||||
},
|
||||
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
|
||||
});
|
||||
mocks.installRecords.mockResolvedValue({ [pluginId]: installRecord });
|
||||
mocks.metadata.mockReturnValue({
|
||||
index: {
|
||||
plugins: [{ pluginId, origin: "global", enabled: true, rootDir: installPath }],
|
||||
installRecords: { [pluginId]: installRecord },
|
||||
},
|
||||
byPluginId: new Map(),
|
||||
normalizePluginId: (rawPluginId: string) => rawPluginId,
|
||||
});
|
||||
|
||||
await expect(uninstallManagedPlugin({ pluginId, env: {} })).rejects.toThrow(
|
||||
"no authoritative package-owner metadata",
|
||||
);
|
||||
expect(mocks.commitRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a child request to one package owner and removes every sibling policy", async () => {
|
||||
const installPath = "/tmp/openclaw-managed-linked-pack";
|
||||
const installRecord = { source: "path", sourcePath: installPath, installPath } as const;
|
||||
mocks.readConfig.mockResolvedValue({
|
||||
snapshot: {
|
||||
valid: true,
|
||||
parsed: {},
|
||||
path: "/tmp/openclaw.json",
|
||||
sourceConfig: {
|
||||
plugins: {
|
||||
allow: ["pack/one", "pack/two", "other"],
|
||||
entries: {
|
||||
"pack/one": { enabled: true },
|
||||
"pack/two": { enabled: false },
|
||||
other: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
hash: "pack-hash",
|
||||
},
|
||||
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
|
||||
});
|
||||
mocks.installRecords.mockResolvedValue({ pack: installRecord });
|
||||
const manifests: Array<[string, { id: string; channels: string[] }]> = [
|
||||
["pack/one", recordPluginManifestInstallOwner({ id: "pack/one", channels: [] }, "pack")],
|
||||
["pack/two", recordPluginManifestInstallOwner({ id: "pack/two", channels: [] }, "pack")],
|
||||
];
|
||||
mocks.metadata.mockReturnValue({
|
||||
index: {
|
||||
plugins: [
|
||||
recordInstalledPluginIndexInstallOwner(
|
||||
{
|
||||
pluginId: "pack/one",
|
||||
origin: "global",
|
||||
enabled: true,
|
||||
rootDir: installPath,
|
||||
},
|
||||
"pack",
|
||||
),
|
||||
recordInstalledPluginIndexInstallOwner(
|
||||
{
|
||||
pluginId: "pack/two",
|
||||
origin: "global",
|
||||
enabled: false,
|
||||
rootDir: installPath,
|
||||
},
|
||||
"pack",
|
||||
),
|
||||
],
|
||||
installRecords: { pack: installRecord },
|
||||
},
|
||||
byPluginId: new Map(manifests),
|
||||
normalizePluginId: (pluginId: string) => pluginId,
|
||||
});
|
||||
|
||||
const result = await uninstallManagedPlugin({ pluginId: "pack/two", env: {} });
|
||||
|
||||
expect(planPluginUninstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginId: "pack",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
resolvePluginPackageUninstallPlan(vi.mocked(planPluginUninstall).mock.calls[0]![0]),
|
||||
).toEqual({
|
||||
runtimePluginIds: ["pack/one", "pack/two"],
|
||||
runtimeLoadPaths: [],
|
||||
});
|
||||
expect(mocks.commitRecords).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nextInstallRecords: {},
|
||||
nextConfig: {
|
||||
plugins: {
|
||||
allow: ["other"],
|
||||
entries: { other: { enabled: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.pluginId).toBe("pack");
|
||||
expect(result.warnings).toContain(
|
||||
'Uninstalled package "pack" and all owned plugin entries: pack/one, pack/two.',
|
||||
);
|
||||
});
|
||||
|
||||
it("marks every child removable through its package install owner", async () => {
|
||||
const installRecord = {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/pack",
|
||||
installPath: "/tmp/pack",
|
||||
};
|
||||
const manifests = ["pack/one", "pack/two"].map((id) => ({
|
||||
id,
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "global",
|
||||
rootDir: "/tmp/pack",
|
||||
source: `/tmp/pack/${id.endsWith("one") ? "one" : "two"}.js`,
|
||||
manifestPath: "/tmp/pack/openclaw.plugin.json",
|
||||
}));
|
||||
mocks.metadata.mockReturnValue({
|
||||
index: {
|
||||
plugins: manifests.map((manifest, index) =>
|
||||
recordInstalledPluginIndexInstallOwner(
|
||||
{
|
||||
pluginId: manifest.id,
|
||||
packageName: "@acme/pack",
|
||||
origin: "global",
|
||||
enabled: index === 0,
|
||||
rootDir: "/tmp/pack",
|
||||
},
|
||||
"pack",
|
||||
),
|
||||
),
|
||||
installRecords: { pack: installRecord },
|
||||
},
|
||||
byPluginId: new Map(manifests.map((manifest) => [manifest.id, manifest])),
|
||||
diagnostics: [],
|
||||
normalizePluginId: (pluginId: string) => pluginId,
|
||||
});
|
||||
|
||||
const catalog = await listManagedPlugins({
|
||||
config: {},
|
||||
env: {},
|
||||
officialCatalog: { entries: [] },
|
||||
});
|
||||
|
||||
expect(catalog.plugins.map(({ id, removable }) => ({ id, removable }))).toEqual([
|
||||
{ id: "pack/one", removable: true },
|
||||
{ id: "pack/two", removable: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
const PLUGIN_MANIFEST_INSTALL_OWNER = Symbol.for("openclaw.pluginManifestInstallOwner");
|
||||
|
||||
type PluginManifestInstallOwner = { installOwner?: string; ambiguous?: true };
|
||||
|
||||
export function recordPluginManifestInstallOwner<T extends object>(
|
||||
record: T,
|
||||
installOwner: string | undefined,
|
||||
ambiguous = false,
|
||||
): T {
|
||||
if (!installOwner && !ambiguous) {
|
||||
return record;
|
||||
}
|
||||
Object.defineProperty(record, PLUGIN_MANIFEST_INSTALL_OWNER, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: ambiguous ? { ambiguous: true } : { installOwner },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
function readPluginManifestInstallOwner(record: object): PluginManifestInstallOwner | undefined {
|
||||
return (record as { [PLUGIN_MANIFEST_INSTALL_OWNER]?: PluginManifestInstallOwner })[
|
||||
PLUGIN_MANIFEST_INSTALL_OWNER
|
||||
];
|
||||
}
|
||||
|
||||
export function resolvePluginManifestInstallOwner(record: object): string | undefined {
|
||||
return readPluginManifestInstallOwner(record)?.installOwner;
|
||||
}
|
||||
|
||||
export function isPluginManifestInstallOwnerAmbiguous(record: object): boolean {
|
||||
return readPluginManifestInstallOwner(record)?.ambiguous === true;
|
||||
}
|
||||
@@ -3,11 +3,20 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
recordInstalledPluginIndexInstallOwner,
|
||||
resolveInstalledPluginIndexInstallOwner,
|
||||
} from "./installed-plugin-index-install-owner.js";
|
||||
import {
|
||||
readPersistedInstalledPluginIndex,
|
||||
writePersistedInstalledPluginIndex,
|
||||
} from "./installed-plugin-index-store.js";
|
||||
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import { loadInstalledPluginIndex, type InstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import {
|
||||
hasMissingInstalledPluginOwnerMetadata,
|
||||
resolveInstalledPluginPackageOwnership,
|
||||
} from "./installed-plugin-package-ownership.js";
|
||||
import { resolvePluginManifestInstallOwner } from "./manifest-install-owner.js";
|
||||
import {
|
||||
loadPluginManifestRegistryForInstalledIndex,
|
||||
resolveInstalledManifestRegistryIndexFingerprint,
|
||||
@@ -375,6 +384,190 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves package entry identities through a persisted cold reload", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const packageDir = path.join(stateDir, "extensions", "pack");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "pack",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./one.cjs", "./two.cjs"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: "pack", configSchema: { type: "object" } }),
|
||||
"utf8",
|
||||
);
|
||||
for (const entry of ["one", "two"]) {
|
||||
fs.writeFileSync(
|
||||
path.join(packageDir, `${entry}.cjs`),
|
||||
`module.exports = { id: "pack/${entry}", register() {} };\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"pack/one": { enabled: true },
|
||||
"pack/two": { enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_VERSION: "2026.4.25",
|
||||
VITEST: "true",
|
||||
};
|
||||
const installRecords = {
|
||||
pack: {
|
||||
source: "path" as const,
|
||||
sourcePath: packageDir,
|
||||
installPath: packageDir,
|
||||
},
|
||||
};
|
||||
const index = loadInstalledPluginIndex({ config, env, installRecords, stateDir });
|
||||
|
||||
expect(
|
||||
index.plugins.map((plugin) => ({
|
||||
pluginId: plugin.pluginId,
|
||||
installOwner: resolveInstalledPluginIndexInstallOwner(plugin),
|
||||
enabled: plugin.enabled,
|
||||
})),
|
||||
).toEqual([
|
||||
{ pluginId: "pack/one", installOwner: "pack", enabled: true },
|
||||
{ pluginId: "pack/two", installOwner: "pack", enabled: false },
|
||||
]);
|
||||
await writePersistedInstalledPluginIndex(index, { stateDir });
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
const persisted = await readPersistedInstalledPluginIndex({ stateDir });
|
||||
if (!persisted) {
|
||||
throw new Error("expected persisted package plugin index");
|
||||
}
|
||||
expect(resolveInstalledPluginPackageOwnership(persisted, "pack/one")).toMatchObject({
|
||||
ok: true,
|
||||
value: { installOwner: "pack", pluginIds: ["pack/one", "pack/two"] },
|
||||
});
|
||||
|
||||
const allEntries = loadPluginManifestRegistryForInstalledIndex({
|
||||
index: persisted,
|
||||
config,
|
||||
env,
|
||||
includeDisabled: true,
|
||||
});
|
||||
expect(
|
||||
allEntries.plugins.map(({ id, source }) => ({ id, source: path.basename(source) })),
|
||||
).toEqual([
|
||||
{ id: "pack/one", source: "one.cjs" },
|
||||
{ id: "pack/two", source: "two.cjs" },
|
||||
]);
|
||||
expect(allEntries.plugins.map(resolvePluginManifestInstallOwner)).toEqual(["pack", "pack"]);
|
||||
|
||||
const enabledEntries = loadPluginManifestRegistryForInstalledIndex({
|
||||
index: persisted,
|
||||
config,
|
||||
env,
|
||||
});
|
||||
expect(enabledEntries.plugins.map(({ id }) => id)).toEqual(["pack/one"]);
|
||||
|
||||
const ownerless = {
|
||||
...persisted,
|
||||
plugins: persisted.plugins.map((plugin) => {
|
||||
const {
|
||||
installOwner: _installOwner,
|
||||
installOwnerAmbiguous: _installOwnerAmbiguous,
|
||||
...ownerlessPlugin
|
||||
} = plugin as typeof plugin & {
|
||||
installOwner?: string;
|
||||
installOwnerAmbiguous?: true;
|
||||
};
|
||||
return ownerlessPlugin;
|
||||
}),
|
||||
};
|
||||
expect(resolveInstalledPluginPackageOwnership(ownerless, "pack/one").ok).toBe(false);
|
||||
|
||||
const orphanedOwner = {
|
||||
...persisted,
|
||||
installRecords: {
|
||||
...persisted.installRecords,
|
||||
orphaned: {
|
||||
source: "path" as const,
|
||||
sourcePath: path.join(stateDir, "removed-orphan"),
|
||||
installPath: path.join(stateDir, "removed-orphan"),
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveInstalledPluginPackageOwnership(orphanedOwner, "orphaned").ok).toBe(false);
|
||||
expect(hasMissingInstalledPluginOwnerMetadata(orphanedOwner, env)).toBe(false);
|
||||
|
||||
const legacyAmbiguous = {
|
||||
...ownerless,
|
||||
installRecords: {
|
||||
"pack/one": installRecords.pack,
|
||||
"pack/two": installRecords.pack,
|
||||
},
|
||||
};
|
||||
expect(resolveInstalledPluginPackageOwnership(legacyAmbiguous, "pack/one").ok).toBe(false);
|
||||
expect(hasMissingInstalledPluginOwnerMetadata(legacyAmbiguous, env)).toBe(true);
|
||||
|
||||
const packageAlias = path.join(stateDir, "pack-alias");
|
||||
fs.symlinkSync(packageDir, packageAlias, process.platform === "win32" ? "junction" : "dir");
|
||||
const aliasedAmbiguous = {
|
||||
...ownerless,
|
||||
installRecords: {
|
||||
"pack/one": installRecords.pack,
|
||||
"pack/two": {
|
||||
...installRecords.pack,
|
||||
sourcePath: packageAlias,
|
||||
installPath: packageAlias,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveInstalledPluginPackageOwnership(aliasedAmbiguous, "pack/one").ok).toBe(false);
|
||||
expect(hasMissingInstalledPluginOwnerMetadata(aliasedAmbiguous, env)).toBe(true);
|
||||
|
||||
const unrelatedOwner = "unrelated";
|
||||
const relationScoped = {
|
||||
...aliasedAmbiguous,
|
||||
plugins: [
|
||||
...aliasedAmbiguous.plugins,
|
||||
recordInstalledPluginIndexInstallOwner(
|
||||
{
|
||||
...aliasedAmbiguous.plugins[0]!,
|
||||
pluginId: unrelatedOwner,
|
||||
rootDir: path.join(stateDir, unrelatedOwner),
|
||||
},
|
||||
unrelatedOwner,
|
||||
),
|
||||
],
|
||||
installRecords: {
|
||||
...aliasedAmbiguous.installRecords,
|
||||
[unrelatedOwner]: {
|
||||
source: "path" as const,
|
||||
sourcePath: path.join(stateDir, unrelatedOwner),
|
||||
installPath: path.join(stateDir, unrelatedOwner),
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveInstalledPluginPackageOwnership(relationScoped, unrelatedOwner)).toMatchObject({
|
||||
ok: true,
|
||||
value: { installOwner: unrelatedOwner, pluginIds: [unrelatedOwner] },
|
||||
});
|
||||
|
||||
const ambiguous = {
|
||||
...legacyAmbiguous,
|
||||
plugins: ownerless.plugins.map((plugin) =>
|
||||
recordInstalledPluginIndexInstallOwner(plugin, undefined, true),
|
||||
),
|
||||
};
|
||||
expect(resolveInstalledPluginPackageOwnership(ambiguous, "pack/one").ok).toBe(false);
|
||||
expect(hasMissingInstalledPluginOwnerMetadata(ambiguous, env)).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses a prepared manifest graph without reopening plugin manifests", () => {
|
||||
const rootDir = makeTempDir();
|
||||
writePlugin(rootDir, "installed", "installed-");
|
||||
|
||||
@@ -11,8 +11,13 @@ import {
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { tryReadJsonSync } from "../infra/json-files.js";
|
||||
import { pruneMapToMaxSize } from "../infra/map-size.js";
|
||||
import { recordPluginCandidateInstallOwner } from "./candidate-install-owner.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import { hashJson } from "./installed-plugin-index-hash.js";
|
||||
import {
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous,
|
||||
resolveInstalledPluginIndexInstallOwner,
|
||||
} from "./installed-plugin-index-install-owner.js";
|
||||
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
|
||||
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import {
|
||||
@@ -508,27 +513,32 @@ function toPluginCandidate(
|
||||
): PluginCandidate {
|
||||
const rootDir = resolveInstalledPluginRootDir(record);
|
||||
const packageMetadata = resolveInstalledPackageMetadata(record, realpathCache);
|
||||
return {
|
||||
idHint: record.pluginId,
|
||||
source: record.source ?? resolveFallbackPluginSource(record),
|
||||
...(record.setupSource ? { setupSource: record.setupSource } : {}),
|
||||
rootDir,
|
||||
origin: record.origin,
|
||||
...(record.format ? { format: record.format } : {}),
|
||||
...(record.bundleFormat ? { bundleFormat: record.bundleFormat } : {}),
|
||||
...(record.packageName ? { packageName: record.packageName } : {}),
|
||||
...(record.packageVersion ? { packageVersion: record.packageVersion } : {}),
|
||||
...(packageMetadata.packageManifest
|
||||
? { packageManifest: packageMetadata.packageManifest }
|
||||
: {}),
|
||||
...(packageMetadata.packageDependencies
|
||||
? { packageDependencies: packageMetadata.packageDependencies }
|
||||
: {}),
|
||||
...(packageMetadata.packageOptionalDependencies
|
||||
? { packageOptionalDependencies: packageMetadata.packageOptionalDependencies }
|
||||
: {}),
|
||||
packageDir: rootDir,
|
||||
};
|
||||
return recordPluginCandidateInstallOwner(
|
||||
{
|
||||
idHint: record.pluginId,
|
||||
effectivePluginId: record.pluginId,
|
||||
source: record.source ?? resolveFallbackPluginSource(record),
|
||||
...(record.setupSource ? { setupSource: record.setupSource } : {}),
|
||||
rootDir,
|
||||
origin: record.origin,
|
||||
...(record.format ? { format: record.format } : {}),
|
||||
...(record.bundleFormat ? { bundleFormat: record.bundleFormat } : {}),
|
||||
...(record.packageName ? { packageName: record.packageName } : {}),
|
||||
...(record.packageVersion ? { packageVersion: record.packageVersion } : {}),
|
||||
...(packageMetadata.packageManifest
|
||||
? { packageManifest: packageMetadata.packageManifest }
|
||||
: {}),
|
||||
...(packageMetadata.packageDependencies
|
||||
? { packageDependencies: packageMetadata.packageDependencies }
|
||||
: {}),
|
||||
...(packageMetadata.packageOptionalDependencies
|
||||
? { packageOptionalDependencies: packageMetadata.packageOptionalDependencies }
|
||||
: {}),
|
||||
packageDir: rootDir,
|
||||
},
|
||||
resolveInstalledPluginIndexInstallOwner(record),
|
||||
isInstalledPluginIndexInstallOwnerAmbiguous(record),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadPluginManifestRegistryForInstalledIndex(params: {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { collectChannelSchemaMetadataCore } from "../config/channel-config-metadata.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { collectBundledChannelConfigsCore } from "./bundled-channel-config-metadata.js";
|
||||
import { recordPluginCandidateInstallOwner } from "./candidate-install-owner.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import { resolvePluginManifestInstallOwner } from "./manifest-install-owner.js";
|
||||
import { loadPluginManifestRegistryCore } from "./manifest-registry.js";
|
||||
import type { OpenClawPackageManifest } from "./manifest.js";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||
@@ -82,21 +84,25 @@ function createPluginCandidate(params: {
|
||||
packageDir?: string;
|
||||
bundledManifest?: PluginCandidate["bundledManifest"];
|
||||
bundledManifestPath?: string;
|
||||
installOwner?: string;
|
||||
}): PluginCandidate {
|
||||
return {
|
||||
idHint: params.idHint,
|
||||
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
|
||||
rootDir: params.rootDir,
|
||||
origin: params.origin,
|
||||
format: params.format,
|
||||
bundleFormat: params.bundleFormat,
|
||||
packageName: params.packageName,
|
||||
packageVersion: params.packageVersion,
|
||||
packageManifest: params.packageManifest,
|
||||
packageDir: params.packageDir,
|
||||
bundledManifest: params.bundledManifest,
|
||||
bundledManifestPath: params.bundledManifestPath,
|
||||
};
|
||||
return recordPluginCandidateInstallOwner(
|
||||
{
|
||||
idHint: params.idHint,
|
||||
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
|
||||
rootDir: params.rootDir,
|
||||
origin: params.origin,
|
||||
format: params.format,
|
||||
bundleFormat: params.bundleFormat,
|
||||
packageName: params.packageName,
|
||||
packageVersion: params.packageVersion,
|
||||
packageManifest: params.packageManifest,
|
||||
packageDir: params.packageDir,
|
||||
bundledManifest: params.bundledManifest,
|
||||
bundledManifestPath: params.bundledManifestPath,
|
||||
},
|
||||
params.installOwner,
|
||||
);
|
||||
}
|
||||
|
||||
function createMsteamsClawHubInstallRecord(
|
||||
@@ -127,6 +133,7 @@ function resolveMsteamsClawHubTrust(overrides: Partial<PluginInstallRecord> = {}
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/msteams",
|
||||
origin: "global",
|
||||
installOwner: "msteams",
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -154,6 +161,7 @@ function resolveDiffsNpmTrust(overrides: Partial<PluginInstallRecord> = {}) {
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diffs",
|
||||
origin: "global",
|
||||
installOwner: "diffs",
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -828,6 +836,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
idHint: "zalouser",
|
||||
rootDir: globalDir,
|
||||
origin: "global",
|
||||
installOwner: "zalouser",
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -891,6 +900,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
idHint: "zalouser",
|
||||
rootDir: globalDir,
|
||||
origin: "global",
|
||||
installOwner: "zalouser",
|
||||
}),
|
||||
createPluginCandidate({
|
||||
idHint: "zalouser",
|
||||
@@ -909,6 +919,55 @@ describe("loadPluginManifestRegistry", () => {
|
||||
expect(resolveDiffsNpmTrust()).toBe(true);
|
||||
});
|
||||
|
||||
it("associates official trust with every child owned by the installed package", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, { id: "diffs", configSchema: { type: "object" } });
|
||||
const registry = loadPluginManifestRegistryCore({
|
||||
installRecords: {
|
||||
diffs: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/diffs",
|
||||
installPath: dir,
|
||||
resolvedName: "@openclaw/diffs",
|
||||
resolvedSpec: "@openclaw/diffs@2026.7.16",
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
...createPluginCandidate({
|
||||
idHint: "diffs/two",
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diffs",
|
||||
origin: "global",
|
||||
installOwner: "diffs",
|
||||
}),
|
||||
effectivePluginId: "diffs/two",
|
||||
},
|
||||
{
|
||||
...createPluginCandidate({
|
||||
idHint: "diffs/one",
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diffs",
|
||||
origin: "global",
|
||||
installOwner: "diffs",
|
||||
}),
|
||||
effectivePluginId: "diffs/one",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
registry.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
trustedOfficialInstall: plugin.trustedOfficialInstall,
|
||||
installOwner: resolvePluginManifestInstallOwner(plugin),
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "diffs/two", trustedOfficialInstall: true, installOwner: "diffs" },
|
||||
{ id: "diffs/one", trustedOfficialInstall: true, installOwner: "diffs" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "npm-pack archive metadata",
|
||||
@@ -1091,6 +1150,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diagnostics-otel",
|
||||
origin: "global",
|
||||
installOwner: "diagnostics-otel",
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1116,6 +1176,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diagnostics-otel",
|
||||
origin: "global",
|
||||
installOwner: "diagnostics-otel",
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1144,6 +1205,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diagnostics-otel",
|
||||
origin: "config",
|
||||
installOwner: "diagnostics-otel",
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1174,12 +1236,14 @@ describe("loadPluginManifestRegistry", () => {
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diagnostics-prometheus",
|
||||
origin: "global",
|
||||
installOwner: "diagnostics-prometheus",
|
||||
}),
|
||||
createPluginCandidate({
|
||||
idHint: "diagnostics-prometheus",
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diagnostics-prometheus",
|
||||
origin: "config",
|
||||
installOwner: "diagnostics-prometheus",
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -2844,18 +2908,21 @@ describe("loadPluginManifestRegistry", () => {
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "codex",
|
||||
rootDir: dir,
|
||||
packageDir: dir,
|
||||
origin: "global",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@openclaw/codex",
|
||||
minHostVersion: "2026.3.22",
|
||||
{
|
||||
...createPluginCandidate({
|
||||
idHint: "codex",
|
||||
rootDir: dir,
|
||||
packageDir: dir,
|
||||
origin: "global",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@openclaw/codex",
|
||||
minHostVersion: "2026.3.22",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
installOwner: "codex",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { loadBundleManifest } from "./bundle-manifest.js";
|
||||
import {
|
||||
isPluginCandidateInstallOwnerAmbiguous,
|
||||
resolvePluginCandidateInstallOwner,
|
||||
} from "./candidate-install-owner.js";
|
||||
import { normalizePluginsConfigWithResolver } from "./config-policy.js";
|
||||
import { isBundledPluginInsideDevSourceRoot } from "./dev-source-root.js";
|
||||
import {
|
||||
@@ -24,6 +28,7 @@ import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-
|
||||
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
|
||||
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
|
||||
import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js";
|
||||
import { recordPluginManifestInstallOwner } from "./manifest-install-owner.js";
|
||||
import type {
|
||||
PluginBundleFormat,
|
||||
PluginConfigUiHint,
|
||||
@@ -779,6 +784,21 @@ function dedupePluginDiagnostics(diagnostics: PluginDiagnostic[]): PluginDiagnos
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function resolveCandidateInstallOwner(params: {
|
||||
pluginId: string;
|
||||
candidate: PluginCandidate;
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
}): string | undefined {
|
||||
if (isPluginCandidateInstallOwnerAmbiguous(params.candidate)) {
|
||||
return undefined;
|
||||
}
|
||||
const installOwner = resolvePluginCandidateInstallOwner(params.candidate);
|
||||
if (installOwner) {
|
||||
return Object.hasOwn(params.installRecords, installOwner) ? installOwner : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchesInstalledPluginRecord(params: {
|
||||
pluginId: string;
|
||||
candidate: PluginCandidate;
|
||||
@@ -790,7 +810,8 @@ function matchesInstalledPluginRecord(params: {
|
||||
if (params.candidate.origin !== "global" && params.candidate.origin !== "config") {
|
||||
return false;
|
||||
}
|
||||
const record = params.installRecords[params.pluginId];
|
||||
const installOwner = resolveCandidateInstallOwner(params);
|
||||
const record = installOwner ? params.installRecords[installOwner] : undefined;
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
@@ -845,7 +866,9 @@ function isTrustedOfficialPluginInstall(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
}): boolean {
|
||||
const installOwner = resolveCandidateInstallOwner(params);
|
||||
if (
|
||||
!installOwner ||
|
||||
(params.candidate.origin !== "global" && params.candidate.origin !== "config") ||
|
||||
!matchesInstalledPluginRecord({
|
||||
pluginId: params.pluginId,
|
||||
@@ -862,18 +885,18 @@ function isTrustedOfficialPluginInstall(params: {
|
||||
return false;
|
||||
}
|
||||
const catalogEntry = getOfficialExternalPluginCatalogEntryForPackage(packageName);
|
||||
if (!catalogEntry || resolveOfficialExternalPluginId(catalogEntry) !== params.pluginId) {
|
||||
if (!catalogEntry || resolveOfficialExternalPluginId(catalogEntry) !== installOwner) {
|
||||
return false;
|
||||
}
|
||||
const officialInstall = resolveOfficialExternalPluginInstall(catalogEntry);
|
||||
const installRecord = params.installRecords[params.pluginId];
|
||||
const installRecord = params.installRecords[installOwner];
|
||||
if (!installRecord) {
|
||||
return false;
|
||||
}
|
||||
const officialClawHubInstall =
|
||||
installRecord.source === "clawhub"
|
||||
? resolveTrustedSourceLinkedOfficialClawHubInstall({
|
||||
pluginId: params.pluginId,
|
||||
pluginId: installOwner,
|
||||
record: installRecord,
|
||||
})
|
||||
: undefined;
|
||||
@@ -1190,7 +1213,11 @@ export function loadPluginManifestRegistryCore(
|
||||
? { bundledChannelConfigCollector: params.bundledChannelConfigCollector }
|
||||
: {}),
|
||||
});
|
||||
|
||||
recordPluginManifestInstallOwner(
|
||||
record,
|
||||
resolvePluginCandidateInstallOwner(candidate),
|
||||
isPluginCandidateInstallOwnerAmbiguous(candidate),
|
||||
);
|
||||
const existing = seenIds.get(effectivePluginId);
|
||||
if (existing) {
|
||||
// Check whether both candidates point to the same physical directory
|
||||
|
||||
+28
-25
@@ -19,6 +19,7 @@ import type { InstallPolicySource } from "../security/install-policy.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { isImmutableGitCommitRef } from "./git-install.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import { installPluginFromPath, type InstallPluginResult } from "./install.js";
|
||||
|
||||
const DEFAULT_GIT_TIMEOUT_MS = 120_000;
|
||||
@@ -1326,31 +1327,33 @@ export async function installPluginFromMarketplace(
|
||||
}
|
||||
installCleanup = resolved.cleanup;
|
||||
|
||||
const result = await installPluginFromPath({
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
config: params.config,
|
||||
path: resolved.path,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
installPolicyRequest: {
|
||||
kind: marketplaceInstallPolicyRequestKind({
|
||||
marketplaceOrigin: loaded.marketplace.origin,
|
||||
resolvedPath: resolved.path,
|
||||
source: entry.source,
|
||||
}),
|
||||
requestedSpecifier: `${entry.name}@${params.marketplace}`,
|
||||
source: marketplaceInstallPolicySource({
|
||||
marketplaceOrigin: loaded.marketplace.origin,
|
||||
marketplaceRef: loaded.marketplace.remoteRef,
|
||||
resolvedPath: resolved.path,
|
||||
source: entry.source,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await installPluginFromPath(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
config: params.config,
|
||||
path: resolved.path,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
installPolicyRequest: {
|
||||
kind: marketplaceInstallPolicyRequestKind({
|
||||
marketplaceOrigin: loaded.marketplace.origin,
|
||||
resolvedPath: resolved.path,
|
||||
source: entry.source,
|
||||
}),
|
||||
requestedSpecifier: `${entry.name}@${params.marketplace}`,
|
||||
source: marketplaceInstallPolicySource({
|
||||
marketplaceOrigin: loaded.marketplace.origin,
|
||||
marketplaceRef: loaded.marketplace.remoteRef,
|
||||
resolvedPath: resolved.path,
|
||||
source: entry.source,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { recordInstalledPluginIndexInstallOwner } from "./installed-plugin-index-install-owner.js";
|
||||
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
|
||||
import {
|
||||
capturePluginPackageUpdateSnapshot,
|
||||
pluginPackageUpdateMayMutateConfig,
|
||||
reconcilePluginPackageUpdateConfig,
|
||||
} from "./plugin-package-update.js";
|
||||
|
||||
function record(
|
||||
pluginId: string,
|
||||
rootDir: string,
|
||||
contributions: { channels?: string[]; channelConfigs?: string[] } = {},
|
||||
): InstalledPluginIndexRecord {
|
||||
return recordInstalledPluginIndexInstallOwner(
|
||||
{
|
||||
pluginId,
|
||||
manifestPath: `${rootDir}/openclaw.plugin.json`,
|
||||
manifestHash: pluginId,
|
||||
source: `${rootDir}/${pluginId.split("/").at(-1)}.js`,
|
||||
rootDir,
|
||||
origin: "global",
|
||||
enabled: true,
|
||||
startup: { sidecar: false, memory: false, agentHarnesses: [] },
|
||||
contributions: {
|
||||
channels: contributions.channels ?? [],
|
||||
channelConfigs: contributions.channelConfigs ?? [],
|
||||
providers: [],
|
||||
modelCatalogProviders: [],
|
||||
modelSupportPrefixes: [],
|
||||
modelSupportPatterns: [],
|
||||
autoEnableProviderIds: [],
|
||||
commandAliases: [],
|
||||
contracts: {},
|
||||
},
|
||||
compat: [],
|
||||
},
|
||||
"pack",
|
||||
);
|
||||
}
|
||||
|
||||
function index(rootDir: string, plugins: InstalledPluginIndexRecord[]): InstalledPluginIndex {
|
||||
return {
|
||||
version: 1,
|
||||
hostContractVersion: "test",
|
||||
compatRegistryVersion: "test",
|
||||
migrationVersion: 1,
|
||||
policyHash: "test",
|
||||
generatedAtMs: 1,
|
||||
installRecords: {
|
||||
pack: { source: "npm", installPath: rootDir, spec: "@openclaw/pack@latest" },
|
||||
},
|
||||
plugins,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin package update policy reconciliation", () => {
|
||||
it("removes retired child policy while preserving retained, new, and unrelated state", () => {
|
||||
const beforeRoot = "/packages/pack-v1";
|
||||
const afterRoot = "/packages/pack-v2";
|
||||
const before = index(beforeRoot, [
|
||||
record("pack/one", beforeRoot, { channels: ["shared"] }),
|
||||
record("pack/two", beforeRoot, { channels: ["two-channel", "shared"] }),
|
||||
record("pack/old", beforeRoot, { channelConfigs: ["old-config"] }),
|
||||
]);
|
||||
const after = index(afterRoot, [
|
||||
record("pack/one", afterRoot, { channels: ["shared"] }),
|
||||
record("pack/renamed", afterRoot),
|
||||
]);
|
||||
const snapshot = capturePluginPackageUpdateSnapshot({
|
||||
index: before,
|
||||
installOwners: ["pack"],
|
||||
});
|
||||
expect(snapshot.ok).toBe(true);
|
||||
if (!snapshot.ok) {
|
||||
throw new Error(snapshot.error);
|
||||
}
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
allow: ["pack/one", "pack/two", "pack/old", "other"],
|
||||
deny: ["pack/two", "pack/old", "other-denied"],
|
||||
entries: {
|
||||
"pack/one": { enabled: true },
|
||||
"pack/two": { enabled: false },
|
||||
"pack/old": { enabled: true },
|
||||
other: { enabled: true },
|
||||
},
|
||||
load: {
|
||||
paths: [`${beforeRoot}/two.js`, `${beforeRoot}/old.js`, "/plugins/unrelated.js"],
|
||||
},
|
||||
slots: { memory: "pack/two", contextEngine: "pack/old" },
|
||||
},
|
||||
channels: {
|
||||
"two-channel": { enabled: true },
|
||||
"old-config": { enabled: true },
|
||||
shared: { enabled: true },
|
||||
discord: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
const result = reconcilePluginPackageUpdateConfig({
|
||||
config,
|
||||
beforeIndex: before,
|
||||
afterIndex: after,
|
||||
snapshot: snapshot.value,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
expect(result.config.plugins).toEqual({
|
||||
allow: ["pack/one", "other"],
|
||||
deny: ["other-denied"],
|
||||
entries: { "pack/one": { enabled: true }, other: { enabled: true } },
|
||||
load: { paths: ["/plugins/unrelated.js"] },
|
||||
slots: { memory: "memory-core", contextEngine: "legacy" },
|
||||
});
|
||||
expect(result.config.channels).toEqual({
|
||||
shared: { enabled: true },
|
||||
discord: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when the replacement package has no authoritative child rows", () => {
|
||||
const before = index("/packages/pack-v1", [record("pack/one", "/packages/pack-v1")]);
|
||||
const snapshot = capturePluginPackageUpdateSnapshot({
|
||||
index: before,
|
||||
installOwners: ["pack"],
|
||||
});
|
||||
expect(snapshot.ok).toBe(true);
|
||||
if (!snapshot.ok) {
|
||||
throw new Error(snapshot.error);
|
||||
}
|
||||
const result = reconcilePluginPackageUpdateConfig({
|
||||
config: { plugins: { entries: { "pack/one": { enabled: true } } } },
|
||||
beforeIndex: before,
|
||||
afterIndex: index("/packages/pack-v2", []),
|
||||
snapshot: snapshot.value,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("detects exact child load-path cleanup before an update starts", () => {
|
||||
const rootDir = "/packages/pack-v1";
|
||||
const before = index(rootDir, [record("pack/one", rootDir)]);
|
||||
const snapshot = capturePluginPackageUpdateSnapshot({
|
||||
index: before,
|
||||
installOwners: ["pack"],
|
||||
});
|
||||
expect(snapshot.ok).toBe(true);
|
||||
if (!snapshot.ok) {
|
||||
throw new Error(snapshot.error);
|
||||
}
|
||||
expect(
|
||||
pluginPackageUpdateMayMutateConfig({
|
||||
config: { plugins: { load: { paths: [`${rootDir}/one.js`] } } },
|
||||
index: before,
|
||||
snapshot: snapshot.value,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import {
|
||||
resolveInstalledPluginPackageOwnership,
|
||||
type InstalledPluginPackageOwnership,
|
||||
} from "./installed-plugin-package-ownership.js";
|
||||
import {
|
||||
hasMatchingPluginLoadPath,
|
||||
removePluginRuntimePolicyFromConfig,
|
||||
} from "./uninstall-package-config.js";
|
||||
|
||||
type PluginPackageUpdateSnapshot = ReadonlyMap<string, InstalledPluginPackageOwnership>;
|
||||
|
||||
export function capturePluginPackageUpdateSnapshot(params: {
|
||||
index: InstalledPluginIndex;
|
||||
installOwners: readonly string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { ok: true; value: PluginPackageUpdateSnapshot } | { ok: false; error: string } {
|
||||
const snapshot = new Map<string, InstalledPluginPackageOwnership>();
|
||||
for (const installOwner of new Set(params.installOwners)) {
|
||||
const ownership = resolveInstalledPluginPackageOwnership(
|
||||
params.index,
|
||||
installOwner,
|
||||
params.env,
|
||||
);
|
||||
if (!ownership.ok) {
|
||||
return ownership;
|
||||
}
|
||||
snapshot.set(installOwner, ownership.value);
|
||||
}
|
||||
return { ok: true, value: snapshot };
|
||||
}
|
||||
|
||||
function contributionKeys(
|
||||
index: InstalledPluginIndex,
|
||||
pluginIds: ReadonlySet<string>,
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const plugin of index.plugins) {
|
||||
if (!pluginIds.has(plugin.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
for (const key of [
|
||||
...(plugin.contributions?.channels ?? []),
|
||||
...(plugin.contributions?.channelConfigs ?? []),
|
||||
]) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Reconcile policy for children removed by a package update. */
|
||||
export function reconcilePluginPackageUpdateConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
beforeIndex: InstalledPluginIndex;
|
||||
afterIndex: InstalledPluginIndex;
|
||||
snapshot: PluginPackageUpdateSnapshot;
|
||||
installOwnerMigrations?: Readonly<Record<string, string>>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): { ok: true; config: OpenClawConfig } | { ok: false; error: string } {
|
||||
let config = params.config;
|
||||
for (const [installOwner, before] of params.snapshot) {
|
||||
const nextInstallOwner = params.installOwnerMigrations?.[installOwner] ?? installOwner;
|
||||
const after = resolveInstalledPluginPackageOwnership(
|
||||
params.afterIndex,
|
||||
nextInstallOwner,
|
||||
params.env,
|
||||
);
|
||||
if (!after.ok) {
|
||||
return after;
|
||||
}
|
||||
const afterPluginIds = new Set(after.value.pluginIds);
|
||||
const removedPluginIds = before.pluginIds.filter((pluginId) => !afterPluginIds.has(pluginId));
|
||||
if (removedPluginIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const retainedContributionKeys = contributionKeys(params.afterIndex, afterPluginIds);
|
||||
for (const pluginId of removedPluginIds) {
|
||||
const oldRecord = params.beforeIndex.plugins.find((plugin) => plugin.pluginId === pluginId);
|
||||
const channelIds = [
|
||||
...(oldRecord?.contributions?.channels ?? []),
|
||||
...(oldRecord?.contributions?.channelConfigs ?? []),
|
||||
].filter((channelId) => !retainedContributionKeys.has(channelId));
|
||||
config = removePluginRuntimePolicyFromConfig(config, pluginId, {
|
||||
channelIds,
|
||||
loadPaths: oldRecord?.source ? [oldRecord.source] : [],
|
||||
}).config;
|
||||
}
|
||||
}
|
||||
return { ok: true, config };
|
||||
}
|
||||
|
||||
export function pluginPackageUpdateMayMutateConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
index: InstalledPluginIndex;
|
||||
snapshot: PluginPackageUpdateSnapshot;
|
||||
}): boolean {
|
||||
const plugins = params.config.plugins;
|
||||
const channels = params.config.channels as Record<string, unknown> | undefined;
|
||||
for (const ownership of params.snapshot.values()) {
|
||||
const pluginIds = new Set(ownership.pluginIds);
|
||||
const ownedSources = params.index.plugins
|
||||
.filter((plugin) => pluginIds.has(plugin.pluginId) && plugin.source)
|
||||
.map((plugin) => plugin.source!);
|
||||
if (hasMatchingPluginLoadPath(params.config, ownedSources)) {
|
||||
return true;
|
||||
}
|
||||
for (const pluginId of ownership.pluginIds) {
|
||||
if (
|
||||
plugins?.allow?.includes(pluginId) ||
|
||||
plugins?.deny?.includes(pluginId) ||
|
||||
Object.hasOwn(plugins?.entries ?? {}, pluginId) ||
|
||||
plugins?.slots?.memory === pluginId ||
|
||||
plugins?.slots?.contextEngine === pluginId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
channels &&
|
||||
[...contributionKeys(params.index, pluginIds)].some((key) => Object.hasOwn(channels, key))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -628,6 +628,38 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
expect(result.diagnostics).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("keeps unrelated installed plugins usable beside a vanished package owner", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const demoDir = path.join(stateDir, "extensions", "demo");
|
||||
const goneDir = path.join(stateDir, "extensions", "gone");
|
||||
const env = {
|
||||
...createHermeticEnv(tempRoot),
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
};
|
||||
writePackagePlugin(demoDir, { pluginId: "demo" });
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: { enabled: true },
|
||||
gone: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const installRecords = {
|
||||
demo: { source: "path" as const, sourcePath: demoDir, installPath: demoDir },
|
||||
gone: { source: "path" as const, sourcePath: goneDir, installPath: goneDir },
|
||||
};
|
||||
const index = loadInstalledPluginIndex({ config, env, stateDir, installRecords });
|
||||
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toContain("demo");
|
||||
});
|
||||
|
||||
it("keeps persisted manifestless Claude bundles on the fast path", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type LoadInstalledPluginIndexParams,
|
||||
type RefreshInstalledPluginIndexParams,
|
||||
} from "./installed-plugin-index.js";
|
||||
import { hasMissingInstalledPluginOwnerMetadata } from "./installed-plugin-package-ownership.js";
|
||||
import {
|
||||
loadPluginManifestRegistryCore,
|
||||
type PluginManifestRegistry,
|
||||
@@ -386,10 +387,10 @@ function hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
||||
? { filePath: params.pluginIndexFilePath }
|
||||
: {}),
|
||||
});
|
||||
const pluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
|
||||
return Object.keys(installRecords).some(
|
||||
(pluginId) => !index.installRecords?.[pluginId] || !pluginIds.has(pluginId),
|
||||
);
|
||||
// A durable owner can outlive removed package bytes. Lifecycle mutations fail
|
||||
// closed without child rows; registry recovery only needs to detect records
|
||||
// that are absent from the persisted top-level ledger.
|
||||
return Object.keys(installRecords).some((pluginId) => !index.installRecords?.[pluginId]);
|
||||
}
|
||||
|
||||
function requiresDerivedRegistryValidation(
|
||||
@@ -405,6 +406,7 @@ function requiresDerivedRegistryValidation(
|
||||
params.installRecords !== undefined ||
|
||||
normalizePluginsConfig(params.config?.plugins).loadPaths.length > 0 ||
|
||||
hasMissingConfigPathActivationMetadata(index) ||
|
||||
hasMissingInstalledPluginOwnerMetadata(index, env) ||
|
||||
index.diagnostics.some(({ pluginId, source }) =>
|
||||
Boolean(pluginId && source && path.isAbsolute(source) && !fs.existsSync(source)),
|
||||
) ||
|
||||
|
||||
+29
-164
@@ -1,52 +1,24 @@
|
||||
// Pure plugin config cleanup shared by doctor repair and full uninstall flows.
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resetPluginSlotsToDefaults } from "./slots.js";
|
||||
import {
|
||||
isUninstallPathInsideOrEqualInternal,
|
||||
removePluginInstallOwnerFromConfig,
|
||||
removePluginRuntimePolicyFromConfig,
|
||||
resolveComparableUninstallPathInternal,
|
||||
resolveUninstallChannelConfigKeysInternal,
|
||||
} from "./uninstall-package-config.js";
|
||||
import type { PluginConfigUninstallActions } from "./uninstall-package-config.js";
|
||||
|
||||
export type PluginConfigUninstallActions = {
|
||||
entry: boolean;
|
||||
install: boolean;
|
||||
allowlist: boolean;
|
||||
denylist: boolean;
|
||||
loadPath: boolean;
|
||||
memorySlot: boolean;
|
||||
contextEngineSlot: boolean;
|
||||
channelConfig: boolean;
|
||||
};
|
||||
|
||||
const SHARED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
|
||||
|
||||
function createEmptyConfigUninstallActions(): PluginConfigUninstallActions {
|
||||
return {
|
||||
entry: false,
|
||||
install: false,
|
||||
allowlist: false,
|
||||
denylist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
channelConfig: false,
|
||||
};
|
||||
}
|
||||
export type { PluginConfigUninstallActions } from "./uninstall-package-config.js";
|
||||
|
||||
/** Resolve a path through existing ancestors while preserving missing targets. */
|
||||
export function resolveComparableUninstallPath(value: string): string {
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return realpathSync(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
return resolveComparableUninstallPathInternal(value);
|
||||
}
|
||||
|
||||
/** Check whether a managed uninstall target stays inside its owning root. */
|
||||
export function isUninstallPathInsideOrEqual(parent: string, child: string): boolean {
|
||||
const relative = path.relative(
|
||||
resolveComparableUninstallPath(parent),
|
||||
resolveComparableUninstallPath(child),
|
||||
);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
return isUninstallPathInsideOrEqualInternal(parent, child);
|
||||
}
|
||||
|
||||
/** Resolve channel config keys owned by a plugin during uninstall. */
|
||||
@@ -54,24 +26,20 @@ export function resolveUninstallChannelConfigKeys(
|
||||
pluginId: string,
|
||||
opts?: { channelIds?: string[] },
|
||||
): string[] {
|
||||
const rawKeys = opts?.channelIds ?? [pluginId];
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const key of rawKeys) {
|
||||
if (SHARED_CHANNEL_CONFIG_KEYS.has(key) || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
return resolveUninstallChannelConfigKeysInternal(pluginId, opts);
|
||||
}
|
||||
|
||||
function loadPathMatchesInstallPath(loadPath: string, installPath: string): boolean {
|
||||
return (
|
||||
loadPath === installPath ||
|
||||
resolveComparableUninstallPath(loadPath) === resolveComparableUninstallPath(installPath)
|
||||
);
|
||||
function mergeUninstallActions(
|
||||
left: PluginConfigUninstallActions,
|
||||
right: PluginConfigUninstallActions,
|
||||
): PluginConfigUninstallActions {
|
||||
return Object.fromEntries(
|
||||
Object.keys(left).map((key) => [
|
||||
key,
|
||||
left[key as keyof PluginConfigUninstallActions] ||
|
||||
right[key as keyof PluginConfigUninstallActions],
|
||||
]),
|
||||
) as PluginConfigUninstallActions;
|
||||
}
|
||||
|
||||
/** Remove plugin references from config without loading uninstall process/runtime dependencies. */
|
||||
@@ -80,113 +48,10 @@ export function removePluginFromConfig(
|
||||
pluginId: string,
|
||||
opts?: { channelIds?: string[] },
|
||||
): { config: OpenClawConfig; actions: PluginConfigUninstallActions } {
|
||||
const actions = createEmptyConfigUninstallActions();
|
||||
const pluginsConfig = cfg.plugins ?? {};
|
||||
|
||||
let entries = pluginsConfig.entries;
|
||||
if (entries && Object.hasOwn(entries, pluginId)) {
|
||||
const { [pluginId]: _, ...rest } = entries;
|
||||
entries = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.entry = true;
|
||||
}
|
||||
|
||||
let installs = pluginsConfig.installs;
|
||||
const hasInstallRecord = Object.hasOwn(installs ?? {}, pluginId);
|
||||
const installRecord = hasInstallRecord ? installs?.[pluginId] : undefined;
|
||||
if (installs && hasInstallRecord) {
|
||||
const { [pluginId]: _, ...rest } = installs;
|
||||
installs = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.install = true;
|
||||
}
|
||||
|
||||
let allow = pluginsConfig.allow;
|
||||
if (Array.isArray(allow) && allow.includes(pluginId)) {
|
||||
allow = allow.filter((id) => id !== pluginId);
|
||||
allow = allow.length > 0 ? allow : undefined;
|
||||
actions.allowlist = true;
|
||||
}
|
||||
|
||||
let deny = pluginsConfig.deny;
|
||||
if (Array.isArray(deny) && deny.includes(pluginId)) {
|
||||
deny = deny.filter((id) => id !== pluginId);
|
||||
deny = deny.length > 0 ? deny : undefined;
|
||||
actions.denylist = true;
|
||||
}
|
||||
|
||||
let load = pluginsConfig.load;
|
||||
const trackedInstallPaths = [
|
||||
installRecord?.installPath,
|
||||
installRecord?.source === "path" ? installRecord.sourcePath : undefined,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (trackedInstallPaths.length > 0) {
|
||||
const loadPaths = load?.paths;
|
||||
if (
|
||||
Array.isArray(loadPaths) &&
|
||||
loadPaths.some((candidate) =>
|
||||
trackedInstallPaths.some((installPath) =>
|
||||
loadPathMatchesInstallPath(candidate, installPath),
|
||||
),
|
||||
)
|
||||
) {
|
||||
const nextLoadPaths = loadPaths.filter(
|
||||
(candidate) =>
|
||||
!trackedInstallPaths.some((installPath) =>
|
||||
loadPathMatchesInstallPath(candidate, installPath),
|
||||
),
|
||||
);
|
||||
load = nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined;
|
||||
actions.loadPath = true;
|
||||
}
|
||||
}
|
||||
|
||||
let slots = pluginsConfig.slots;
|
||||
if (slots?.memory === pluginId) {
|
||||
actions.memorySlot = true;
|
||||
}
|
||||
if (slots?.contextEngine === pluginId) {
|
||||
actions.contextEngineSlot = true;
|
||||
}
|
||||
slots = resetPluginSlotsToDefaults(slots, pluginId);
|
||||
if (slots && Object.keys(slots).length === 0) {
|
||||
slots = undefined;
|
||||
}
|
||||
|
||||
const cleanedPlugins = {
|
||||
...pluginsConfig,
|
||||
entries,
|
||||
installs,
|
||||
allow,
|
||||
deny,
|
||||
load,
|
||||
slots,
|
||||
};
|
||||
for (const key of ["entries", "installs", "allow", "deny", "load", "slots"] as const) {
|
||||
if (cleanedPlugins[key] === undefined) {
|
||||
delete cleanedPlugins[key];
|
||||
}
|
||||
}
|
||||
|
||||
let channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (hasInstallRecord && channels) {
|
||||
for (const key of resolveUninstallChannelConfigKeys(pluginId, opts)) {
|
||||
if (!Object.hasOwn(channels, key)) {
|
||||
continue;
|
||||
}
|
||||
const { [key]: _removed, ...rest } = channels;
|
||||
channels = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.channelConfig = true;
|
||||
if (!channels) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined,
|
||||
channels: channels as OpenClawConfig["channels"],
|
||||
},
|
||||
actions,
|
||||
};
|
||||
const hasInstallRecord = Object.hasOwn(cfg.plugins?.installs ?? {}, pluginId);
|
||||
const policy = removePluginRuntimePolicyFromConfig(cfg, pluginId, {
|
||||
...(hasInstallRecord ? opts : { channelIds: [] }),
|
||||
});
|
||||
const owner = removePluginInstallOwnerFromConfig(policy.config, pluginId);
|
||||
return { config: owner.config, actions: mergeUninstallActions(policy.actions, owner.actions) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resetPluginSlotsToDefaults } from "./slots.js";
|
||||
|
||||
export type PluginConfigUninstallActions = {
|
||||
entry: boolean;
|
||||
install: boolean;
|
||||
allowlist: boolean;
|
||||
denylist: boolean;
|
||||
loadPath: boolean;
|
||||
memorySlot: boolean;
|
||||
contextEngineSlot: boolean;
|
||||
channelConfig: boolean;
|
||||
};
|
||||
|
||||
const SHARED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
|
||||
|
||||
function createEmptyConfigUninstallActions(): PluginConfigUninstallActions {
|
||||
return {
|
||||
entry: false,
|
||||
install: false,
|
||||
allowlist: false,
|
||||
denylist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
channelConfig: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveComparableUninstallPathInternal(value: string): string {
|
||||
const resolved = path.resolve(value);
|
||||
try {
|
||||
return realpathSync(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
export function isUninstallPathInsideOrEqualInternal(parent: string, child: string): boolean {
|
||||
const relative = path.relative(
|
||||
resolveComparableUninstallPathInternal(parent),
|
||||
resolveComparableUninstallPathInternal(child),
|
||||
);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function resolveUninstallChannelConfigKeysInternal(
|
||||
pluginId: string,
|
||||
opts?: { channelIds?: string[] },
|
||||
): string[] {
|
||||
const rawKeys = opts?.channelIds ?? [pluginId];
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const key of rawKeys) {
|
||||
if (SHARED_CHANNEL_CONFIG_KEYS.has(key) || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function loadPathMatchesInstallPath(loadPath: string, installPath: string): boolean {
|
||||
return (
|
||||
loadPath === installPath ||
|
||||
resolveComparableUninstallPathInternal(loadPath) ===
|
||||
resolveComparableUninstallPathInternal(installPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMatchingPluginLoadPath(
|
||||
config: OpenClawConfig,
|
||||
ownedPaths: readonly string[],
|
||||
): boolean {
|
||||
return Boolean(
|
||||
config.plugins?.load?.paths?.some((candidate) =>
|
||||
ownedPaths.some((ownedPath) => loadPathMatchesInstallPath(candidate, ownedPath)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function removeMatchingLoadPaths(
|
||||
load: NonNullable<OpenClawConfig["plugins"]>["load"],
|
||||
ownedPaths: readonly string[],
|
||||
): { load: NonNullable<OpenClawConfig["plugins"]>["load"] | undefined; changed: boolean } {
|
||||
const loadPaths = load?.paths;
|
||||
if (
|
||||
ownedPaths.length === 0 ||
|
||||
!Array.isArray(loadPaths) ||
|
||||
!loadPaths.some((candidate) =>
|
||||
ownedPaths.some((ownedPath) => loadPathMatchesInstallPath(candidate, ownedPath)),
|
||||
)
|
||||
) {
|
||||
return { load, changed: false };
|
||||
}
|
||||
const nextLoadPaths = loadPaths.filter(
|
||||
(candidate) =>
|
||||
!ownedPaths.some((ownedPath) => loadPathMatchesInstallPath(candidate, ownedPath)),
|
||||
);
|
||||
return {
|
||||
load: nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined,
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function removePluginRuntimePolicyFromConfig(
|
||||
cfg: OpenClawConfig,
|
||||
pluginId: string,
|
||||
opts?: { channelIds?: string[]; loadPaths?: string[] },
|
||||
): { config: OpenClawConfig; actions: PluginConfigUninstallActions } {
|
||||
const actions = createEmptyConfigUninstallActions();
|
||||
const pluginsConfig = cfg.plugins ?? {};
|
||||
|
||||
let entries = pluginsConfig.entries;
|
||||
if (entries && Object.hasOwn(entries, pluginId)) {
|
||||
const { [pluginId]: _, ...rest } = entries;
|
||||
entries = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.entry = true;
|
||||
}
|
||||
|
||||
let allow = pluginsConfig.allow;
|
||||
if (Array.isArray(allow) && allow.includes(pluginId)) {
|
||||
allow = allow.filter((id) => id !== pluginId);
|
||||
allow = allow.length > 0 ? allow : undefined;
|
||||
actions.allowlist = true;
|
||||
}
|
||||
|
||||
let deny = pluginsConfig.deny;
|
||||
if (Array.isArray(deny) && deny.includes(pluginId)) {
|
||||
deny = deny.filter((id) => id !== pluginId);
|
||||
deny = deny.length > 0 ? deny : undefined;
|
||||
actions.denylist = true;
|
||||
}
|
||||
|
||||
const loadResult = removeMatchingLoadPaths(pluginsConfig.load, opts?.loadPaths ?? []);
|
||||
actions.loadPath = loadResult.changed;
|
||||
|
||||
let slots = pluginsConfig.slots;
|
||||
if (slots?.memory === pluginId) {
|
||||
actions.memorySlot = true;
|
||||
}
|
||||
if (slots?.contextEngine === pluginId) {
|
||||
actions.contextEngineSlot = true;
|
||||
}
|
||||
slots = resetPluginSlotsToDefaults(slots, pluginId);
|
||||
if (slots && Object.keys(slots).length === 0) {
|
||||
slots = undefined;
|
||||
}
|
||||
|
||||
const cleanedPlugins = {
|
||||
...pluginsConfig,
|
||||
entries,
|
||||
allow,
|
||||
deny,
|
||||
load: loadResult.load,
|
||||
slots,
|
||||
};
|
||||
for (const key of ["entries", "allow", "deny", "load", "slots"] as const) {
|
||||
if (cleanedPlugins[key] === undefined) {
|
||||
delete cleanedPlugins[key];
|
||||
}
|
||||
}
|
||||
|
||||
let channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
for (const key of resolveUninstallChannelConfigKeysInternal(pluginId, opts)) {
|
||||
if (!channels || !Object.hasOwn(channels, key)) {
|
||||
continue;
|
||||
}
|
||||
const { [key]: _removed, ...rest } = channels;
|
||||
channels = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.channelConfig = true;
|
||||
}
|
||||
|
||||
if (!Object.values(actions).some(Boolean)) {
|
||||
return { config: cfg, actions };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined,
|
||||
channels: channels as OpenClawConfig["channels"],
|
||||
},
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
export function removePluginInstallOwnerFromConfig(
|
||||
cfg: OpenClawConfig,
|
||||
installOwner: string,
|
||||
): { config: OpenClawConfig; actions: PluginConfigUninstallActions } {
|
||||
const actions = createEmptyConfigUninstallActions();
|
||||
const pluginsConfig = cfg.plugins ?? {};
|
||||
let installs = pluginsConfig.installs;
|
||||
const installRecord = Object.hasOwn(installs ?? {}, installOwner)
|
||||
? installs?.[installOwner]
|
||||
: undefined;
|
||||
if (installs && installRecord) {
|
||||
const { [installOwner]: _, ...rest } = installs;
|
||||
installs = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
actions.install = true;
|
||||
}
|
||||
const trackedPaths = [
|
||||
installRecord?.installPath,
|
||||
installRecord?.source === "path" ? installRecord.sourcePath : undefined,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
const loadResult = removeMatchingLoadPaths(pluginsConfig.load, trackedPaths);
|
||||
actions.loadPath = loadResult.changed;
|
||||
const cleanedPlugins = { ...pluginsConfig, installs, load: loadResult.load };
|
||||
for (const key of ["installs", "load"] as const) {
|
||||
if (cleanedPlugins[key] === undefined) {
|
||||
delete cleanedPlugins[key];
|
||||
}
|
||||
}
|
||||
if (!Object.values(actions).some(Boolean)) {
|
||||
return { config: cfg, actions };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined,
|
||||
},
|
||||
actions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
const PLUGIN_PACKAGE_UNINSTALL_PLAN = Symbol.for("openclaw.pluginPackageUninstallPlan");
|
||||
|
||||
type PluginPackageUninstallPlanMetadata = {
|
||||
runtimePluginIds: readonly string[];
|
||||
runtimeLoadPaths?: readonly string[];
|
||||
};
|
||||
|
||||
export function recordPluginPackageUninstallPlan<T extends object>(
|
||||
params: T,
|
||||
metadata: PluginPackageUninstallPlanMetadata,
|
||||
): T {
|
||||
Object.defineProperty(params, PLUGIN_PACKAGE_UNINSTALL_PLAN, {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: metadata,
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
export function resolvePluginPackageUninstallPlan(
|
||||
params: object,
|
||||
): PluginPackageUninstallPlanMetadata | undefined {
|
||||
return (params as { [PLUGIN_PACKAGE_UNINSTALL_PLAN]?: PluginPackageUninstallPlanMetadata })[
|
||||
PLUGIN_PACKAGE_UNINSTALL_PLAN
|
||||
];
|
||||
}
|
||||
|
||||
export function prepareConfigForPendingPluginDirectoryRemovalSet(
|
||||
config: OpenClawConfig,
|
||||
pluginIds: readonly string[],
|
||||
): OpenClawConfig {
|
||||
const entries = { ...config.plugins?.entries };
|
||||
for (const entryId of new Set(pluginIds)) {
|
||||
entries[entryId] = {
|
||||
...entries[entryId],
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
plugins: {
|
||||
...config.plugins,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
+128
-36
@@ -13,6 +13,10 @@ import {
|
||||
} from "./test-helpers/fs-fixtures.js";
|
||||
import { removePluginFromConfig } from "./uninstall-config.js";
|
||||
import { pruneManagedNpmPeerDependenciesAfterUninstall } from "./uninstall-managed-npm.js";
|
||||
import {
|
||||
prepareConfigForPendingPluginDirectoryRemovalSet,
|
||||
recordPluginPackageUninstallPlan,
|
||||
} from "./uninstall-package-plan.js";
|
||||
import {
|
||||
applyPluginUninstallDirectoryRemoval,
|
||||
planPluginUninstall,
|
||||
@@ -28,8 +32,19 @@ vi.mock("../process/exec.js", () => ({
|
||||
type PluginConfig = NonNullable<OpenClawConfig["plugins"]>;
|
||||
type PluginInstallRecord = NonNullable<PluginConfig["installs"]>[string];
|
||||
|
||||
async function uninstallPlugin(params: Parameters<typeof planPluginUninstall>[0]) {
|
||||
const plan = planPluginUninstall(params);
|
||||
async function uninstallPlugin(
|
||||
params: Parameters<typeof planPluginUninstall>[0] & {
|
||||
runtimePluginIds?: readonly string[];
|
||||
runtimeLoadPaths?: readonly string[];
|
||||
},
|
||||
) {
|
||||
const { runtimePluginIds, runtimeLoadPaths, ...planParams } = params;
|
||||
const plan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(planParams, {
|
||||
runtimePluginIds: runtimePluginIds ?? [params.pluginId],
|
||||
...(runtimeLoadPaths ? { runtimeLoadPaths } : {}),
|
||||
}),
|
||||
);
|
||||
if (!plan.ok) {
|
||||
return plan;
|
||||
}
|
||||
@@ -249,6 +264,26 @@ function createSingleNpmInstallConfig(installPath: string): OpenClawConfig {
|
||||
});
|
||||
}
|
||||
|
||||
it("stages only runtime child entries while a package directory removal is pending", () => {
|
||||
const staged = prepareConfigForPendingPluginDirectoryRemovalSet(
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"pack/one": { enabled: true },
|
||||
"pack/two": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
["pack/one", "pack/two"],
|
||||
);
|
||||
|
||||
expect(staged.plugins?.entries).toEqual({
|
||||
"pack/one": { enabled: false },
|
||||
"pack/two": { enabled: false },
|
||||
});
|
||||
expect(staged.plugins?.entries).not.toHaveProperty("pack");
|
||||
});
|
||||
|
||||
async function createPluginDirFixture(baseDir: string, pluginId = "my-plugin") {
|
||||
const pluginDir = path.join(baseDir, pluginId);
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
@@ -315,6 +350,53 @@ describe("resolveUninstallChannelConfigKeys", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("planPluginUninstall package ownership", () => {
|
||||
it("removes every owned child policy while planning one owner install removal", () => {
|
||||
const result = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["pack/one", "pack/two", "other"],
|
||||
deny: ["pack/two"],
|
||||
entries: {
|
||||
"pack/one": { enabled: true },
|
||||
"pack/two": { enabled: false },
|
||||
other: { enabled: true },
|
||||
},
|
||||
installs: {
|
||||
pack: { source: "path", installPath: "/managed/pack" },
|
||||
},
|
||||
slots: { memory: "pack/two" },
|
||||
},
|
||||
},
|
||||
pluginId: "pack",
|
||||
deleteFiles: false,
|
||||
},
|
||||
{ runtimePluginIds: ["pack/one", "pack/two"] },
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
expect(result.directoryRemoval).toBeNull();
|
||||
expect(result.config.plugins).toEqual({
|
||||
allow: ["other"],
|
||||
entries: { other: { enabled: true } },
|
||||
slots: { memory: "memory-core" },
|
||||
});
|
||||
expect(result.actions).toMatchObject({
|
||||
entry: true,
|
||||
install: true,
|
||||
allowlist: true,
|
||||
denylist: true,
|
||||
memorySlot: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePluginFromConfig", () => {
|
||||
it("removes plugin from entries", () => {
|
||||
const config = createPluginConfig({
|
||||
@@ -1068,12 +1150,12 @@ describe("uninstallPlugin", () => {
|
||||
baseDir: tempDir,
|
||||
});
|
||||
|
||||
const plan = planPluginUninstall({
|
||||
config,
|
||||
pluginId,
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
const plan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{ config, pluginId, deleteFiles: true, extensionsDir },
|
||||
{ runtimePluginIds: [pluginId] },
|
||||
),
|
||||
);
|
||||
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
@@ -1113,21 +1195,26 @@ describe("uninstallPlugin", () => {
|
||||
await fs.writeFile(path.join(pluginDir, "package.json"), "{}");
|
||||
await fs.writeFile(path.join(hoistedDir, "package.json"), "{}");
|
||||
|
||||
const plan = planPluginUninstall({
|
||||
config: createPluginConfig({
|
||||
entries: createSinglePluginEntries("openclaw-kitchen-sink-fixture"),
|
||||
installs: {
|
||||
"openclaw-kitchen-sink-fixture": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/kitchen-sink@1.0.0",
|
||||
installPath: pluginDir,
|
||||
},
|
||||
const plan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: createPluginConfig({
|
||||
entries: createSinglePluginEntries("openclaw-kitchen-sink-fixture"),
|
||||
installs: {
|
||||
"openclaw-kitchen-sink-fixture": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/kitchen-sink@1.0.0",
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
}),
|
||||
pluginId: "openclaw-kitchen-sink-fixture",
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
},
|
||||
}),
|
||||
pluginId: "openclaw-kitchen-sink-fixture",
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
{ runtimePluginIds: ["openclaw-kitchen-sink-fixture"] },
|
||||
),
|
||||
);
|
||||
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
@@ -1178,21 +1265,26 @@ describe("uninstallPlugin", () => {
|
||||
await fs.writeFile(path.join(pluginDir, "package.json"), "{}");
|
||||
await fs.writeFile(path.join(hoistedDir, "package.json"), "{}");
|
||||
|
||||
const plan = planPluginUninstall({
|
||||
config: createPluginConfig({
|
||||
entries: createSinglePluginEntries("openclaw-kitchen-sink-fixture"),
|
||||
installs: {
|
||||
"openclaw-kitchen-sink-fixture": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/kitchen-sink@1.0.0",
|
||||
installPath: pluginDir,
|
||||
},
|
||||
const plan = planPluginUninstall(
|
||||
recordPluginPackageUninstallPlan(
|
||||
{
|
||||
config: createPluginConfig({
|
||||
entries: createSinglePluginEntries("openclaw-kitchen-sink-fixture"),
|
||||
installs: {
|
||||
"openclaw-kitchen-sink-fixture": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/kitchen-sink@1.0.0",
|
||||
installPath: pluginDir,
|
||||
},
|
||||
},
|
||||
}),
|
||||
pluginId: "openclaw-kitchen-sink-fixture",
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
},
|
||||
}),
|
||||
pluginId: "openclaw-kitchen-sink-fixture",
|
||||
deleteFiles: true,
|
||||
extensionsDir,
|
||||
});
|
||||
{ runtimePluginIds: ["openclaw-kitchen-sink-fixture"] },
|
||||
),
|
||||
);
|
||||
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
|
||||
+36
-26
@@ -18,11 +18,15 @@ import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-li
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import {
|
||||
isUninstallPathInsideOrEqual,
|
||||
removePluginFromConfig,
|
||||
resolveComparableUninstallPath,
|
||||
type PluginConfigUninstallActions,
|
||||
} from "./uninstall-config.js";
|
||||
import { pruneManagedNpmPeerDependenciesAfterUninstall } from "./uninstall-managed-npm.js";
|
||||
import {
|
||||
removePluginInstallOwnerFromConfig,
|
||||
removePluginRuntimePolicyFromConfig,
|
||||
} from "./uninstall-package-config.js";
|
||||
import { resolvePluginPackageUninstallPlan } from "./uninstall-package-plan.js";
|
||||
|
||||
export { resolveUninstallChannelConfigKeys } from "./uninstall-config.js";
|
||||
|
||||
@@ -60,26 +64,6 @@ export function formatUninstallActionLabels(actions: UninstallActions): string[]
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep a staged plugin disabled until its managed directory is removed. */
|
||||
export function prepareConfigForPendingPluginDirectoryRemoval(
|
||||
config: OpenClawConfig,
|
||||
pluginId: string,
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
plugins: {
|
||||
...config.plugins,
|
||||
entries: {
|
||||
...config.plugins?.entries,
|
||||
[pluginId]: {
|
||||
...config.plugins?.entries?.[pluginId],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasUninstallAction(actions: PluginConfigUninstallActions): boolean {
|
||||
return Object.values(actions).some(Boolean);
|
||||
}
|
||||
@@ -321,6 +305,7 @@ function isLinkedPathInstallRecord(installRecord: PluginInstallRecord | undefine
|
||||
|
||||
type UninstallPluginParams = {
|
||||
config: OpenClawConfig;
|
||||
/** Package install-record key whose record and shared directory are removed once. */
|
||||
pluginId: string;
|
||||
channelIds?: string[];
|
||||
deleteFiles?: boolean;
|
||||
@@ -334,18 +319,43 @@ type UninstallPluginParams = {
|
||||
*/
|
||||
export function planPluginUninstall(params: UninstallPluginParams): PluginUninstallPlanResult {
|
||||
const { config, pluginId, channelIds, deleteFiles = true, extensionsDir } = params;
|
||||
const packagePlan = resolvePluginPackageUninstallPlan(params);
|
||||
const runtimePluginIds = packagePlan?.runtimePluginIds ?? [pluginId];
|
||||
|
||||
const entries = config.plugins?.entries ?? {};
|
||||
const installs = config.plugins?.installs ?? {};
|
||||
const hasEntry = Object.hasOwn(entries, pluginId);
|
||||
const hasEntry = runtimePluginIds.some((entryId) => Object.hasOwn(entries, entryId));
|
||||
const hasInstall = Object.hasOwn(installs, pluginId);
|
||||
const installRecord = hasInstall ? installs[pluginId] : undefined;
|
||||
const isLinked = isLinkedPathInstallRecord(installRecord);
|
||||
|
||||
// Remove from config
|
||||
const { config: newConfig, actions: configActions } = removePluginFromConfig(config, pluginId, {
|
||||
channelIds,
|
||||
});
|
||||
// Package lifecycle removes every child policy while the owner record/directory is handled once.
|
||||
let newConfig = config;
|
||||
const configActions: PluginConfigUninstallActions = {
|
||||
entry: false,
|
||||
install: false,
|
||||
allowlist: false,
|
||||
denylist: false,
|
||||
loadPath: false,
|
||||
memorySlot: false,
|
||||
contextEngineSlot: false,
|
||||
channelConfig: false,
|
||||
};
|
||||
for (const configPluginId of new Set(runtimePluginIds)) {
|
||||
const removal = removePluginRuntimePolicyFromConfig(newConfig, configPluginId, {
|
||||
channelIds,
|
||||
loadPaths: packagePlan?.runtimeLoadPaths ? [...packagePlan.runtimeLoadPaths] : undefined,
|
||||
});
|
||||
newConfig = removal.config;
|
||||
for (const key of Object.keys(configActions) as Array<keyof PluginConfigUninstallActions>) {
|
||||
configActions[key] ||= removal.actions[key];
|
||||
}
|
||||
}
|
||||
const ownerRemoval = removePluginInstallOwnerFromConfig(newConfig, pluginId);
|
||||
newConfig = ownerRemoval.config;
|
||||
for (const key of Object.keys(configActions) as Array<keyof PluginConfigUninstallActions>) {
|
||||
configActions[key] ||= ownerRemoval.actions[key];
|
||||
}
|
||||
|
||||
if (!hasEntry && !hasInstall && !hasUninstallAction(configActions)) {
|
||||
return { ok: false, error: `Plugin not found: ${pluginId}` };
|
||||
|
||||
+110
-93
@@ -4,6 +4,7 @@ import type { UpdateChannel } from "../infra/update-channels.js";
|
||||
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
|
||||
import { installPluginFromClawHub, type ClawHubRiskAcknowledgementRequest } from "./clawhub.js";
|
||||
import { installPluginFromGitSpec } from "./git-install.js";
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
|
||||
import { installPluginFromMarketplace } from "./marketplace.js";
|
||||
import { shouldFallbackClawHubBridgeToNpm } from "./update-config.js";
|
||||
@@ -345,68 +346,78 @@ export async function runPluginUpdateAttempt(params: {
|
||||
const dryRunOption = params.dryRun ? { dryRun: true } : {};
|
||||
const phase = params.dryRun ? "check" : "update";
|
||||
const installNpmSpec = params.dryRun ? installPluginFromNpmSpec : params.installNpmSpecForUpdate;
|
||||
const installParams = <T extends object>(value: T): T =>
|
||||
copyPluginInstallTransactionRequest(params, value);
|
||||
let result: PluginUpdateInstallResult;
|
||||
try {
|
||||
result =
|
||||
params.record.source === "npm"
|
||||
? await installNpmSpec({
|
||||
spec: params.effectiveSpec!,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
||||
pluginId: params.pluginId,
|
||||
dryRun: params.dryRun,
|
||||
logger: params.logger,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
}),
|
||||
logger: params.logger,
|
||||
})
|
||||
: params.record.source === "clawhub"
|
||||
? await installPluginFromClawHub({
|
||||
spec: params.effectiveSpec ?? `clawhub:${params.record.clawhubPackage!}`,
|
||||
? await installNpmSpec(
|
||||
installParams({
|
||||
spec: params.effectiveSpec!,
|
||||
config: params.config,
|
||||
baseUrl: params.record.clawhubUrl,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
...params.clawHubRiskAcknowledgementOptions,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
expectedIntegrity: params.expectedIntegrity,
|
||||
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
||||
pluginId: params.pluginId,
|
||||
dryRun: params.dryRun,
|
||||
logger: params.logger,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
}),
|
||||
logger: params.logger,
|
||||
})
|
||||
}),
|
||||
)
|
||||
: params.record.source === "clawhub"
|
||||
? await installPluginFromClawHub(
|
||||
installParams({
|
||||
spec: params.effectiveSpec ?? `clawhub:${params.record.clawhubPackage!}`,
|
||||
config: params.config,
|
||||
baseUrl: params.record.clawhubUrl,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
...params.clawHubRiskAcknowledgementOptions,
|
||||
logger: params.logger,
|
||||
}),
|
||||
)
|
||||
: params.record.source === "git"
|
||||
? await installPluginFromGitSpec({
|
||||
spec: params.effectiveSpec!,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
logger: params.logger,
|
||||
})
|
||||
: await installPluginFromMarketplace({
|
||||
marketplace: params.record.marketplaceSource!,
|
||||
plugin: params.record.marketplacePlugin!,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
logger: params.logger,
|
||||
});
|
||||
? await installPluginFromGitSpec(
|
||||
installParams({
|
||||
spec: params.effectiveSpec!,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
logger: params.logger,
|
||||
}),
|
||||
)
|
||||
: await installPluginFromMarketplace(
|
||||
installParams({
|
||||
marketplace: params.record.marketplaceSource!,
|
||||
plugin: params.record.marketplacePlugin!,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
logger: params.logger,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "exception",
|
||||
@@ -445,26 +456,28 @@ export async function runPluginUpdateAttempt(params: {
|
||||
fallbackSpec: params.npmSpecs.fallbackSpec,
|
||||
verb: params.dryRun ? "would use" : "used",
|
||||
});
|
||||
result = await installNpmSpec({
|
||||
spec: params.npmSpecs.fallbackSpec,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
expectedIntegrity: await params.getFallbackExpectedIntegrity(),
|
||||
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
||||
pluginId: params.pluginId,
|
||||
dryRun: params.dryRun,
|
||||
result = await installNpmSpec(
|
||||
installParams({
|
||||
spec: params.npmSpecs.fallbackSpec,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
expectedIntegrity: await params.getFallbackExpectedIntegrity(),
|
||||
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
||||
pluginId: params.pluginId,
|
||||
dryRun: params.dryRun,
|
||||
logger: params.logger,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
}),
|
||||
logger: params.logger,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
}),
|
||||
logger: params.logger,
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -481,19 +494,21 @@ export async function runPluginUpdateAttempt(params: {
|
||||
params.logger.warn?.(
|
||||
`Plugin "${params.pluginId}" has no beta ClawHub release for ${params.clawhubSpecs.fallbackLabel ?? params.effectiveSpec}; using ${params.clawhubSpecs.fallbackSpec} instead. Core update can still complete.`,
|
||||
);
|
||||
result = await installPluginFromClawHub({
|
||||
spec: params.clawhubSpecs.fallbackSpec,
|
||||
config: params.config,
|
||||
baseUrl: params.record.clawhubUrl,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
...params.clawHubRiskAcknowledgementOptions,
|
||||
logger: params.logger,
|
||||
});
|
||||
result = await installPluginFromClawHub(
|
||||
installParams({
|
||||
spec: params.clawhubSpecs.fallbackSpec,
|
||||
config: params.config,
|
||||
baseUrl: params.record.clawhubUrl,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedPluginId: params.pluginId,
|
||||
...params.clawHubRiskAcknowledgementOptions,
|
||||
logger: params.logger,
|
||||
}),
|
||||
);
|
||||
activeClawHubInstallSpec = params.clawhubSpecs.fallbackSpec;
|
||||
if (params.officialNpmFallbackSpecs?.fallbackSpec) {
|
||||
officialNpmFallbackInstallSpec = params.officialNpmFallbackSpecs.fallbackSpec;
|
||||
@@ -519,19 +534,21 @@ export async function runPluginUpdateAttempt(params: {
|
||||
channelFallbackSuffix = params.dryRun
|
||||
? ` (warning: official ClawHub artifact fallback would use ${officialNpmFallbackInstallSpec}).`
|
||||
: ` (warning: official ClawHub artifact fallback used ${officialNpmFallbackInstallSpec}).`;
|
||||
result = await installNpmSpec({
|
||||
spec: officialNpmFallbackInstallSpec,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
expectedPluginId: params.pluginId,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
logger: params.logger,
|
||||
});
|
||||
result = await installNpmSpec(
|
||||
installParams({
|
||||
spec: officialNpmFallbackInstallSpec,
|
||||
config: params.config,
|
||||
mode: "update",
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...dryRunOption,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
expectedPluginId: params.pluginId,
|
||||
expectedReplacementPluginId: params.expectedReplacementPluginId,
|
||||
logger: params.logger,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { resolveBundledPluginSources } from "./bundled-sources.js";
|
||||
import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
|
||||
import type { ClawHubRiskAcknowledgementRequest } from "./clawhub.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
|
||||
import { copyPluginInstallTransactionRequest } from "./install-transaction.js";
|
||||
import { PLUGIN_INSTALL_ERROR_CODE, resolvePluginInstallDir } from "./install.js";
|
||||
import {
|
||||
buildNpmResolutionInstallFields,
|
||||
@@ -47,10 +48,8 @@ import {
|
||||
runPluginUpdateWithClawHubLease,
|
||||
} from "./update-claw-lifecycle.js";
|
||||
import {
|
||||
disablePluginAfterUpdateFailure,
|
||||
hasRunnableInstalledNpmPayload,
|
||||
migratePluginConfigId,
|
||||
repairOpenClawPeerLinksForNpmInstalls,
|
||||
repairRegisteredOpenClawHostLink,
|
||||
resolveRecordedExtensionsDir,
|
||||
withoutPluginInstallRecord,
|
||||
@@ -77,6 +76,12 @@ import {
|
||||
type PluginUpdateOutcome,
|
||||
type PluginUpdateSummary,
|
||||
} from "./update-source.js";
|
||||
import {
|
||||
createPluginUpdateTransactionState,
|
||||
finalizePluginUpdateSummary,
|
||||
recordPluginUpdateFailure,
|
||||
recordPluginUpdateTransaction,
|
||||
} from "./update-summary.js";
|
||||
|
||||
export async function updateNpmInstalledPlugins(params: {
|
||||
config: OpenClawConfig;
|
||||
@@ -105,6 +110,7 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
: undefined;
|
||||
const bundled = resolveBundledPluginSources({});
|
||||
const outcomes: PluginUpdateOutcome[] = [];
|
||||
const transactionState = createPluginUpdateTransactionState(params);
|
||||
let next = params.config;
|
||||
let changed = false;
|
||||
let ranNpmInstaller = false;
|
||||
@@ -125,32 +131,18 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
installedPayloadRunnable?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
// Metadata failure is advisory only when a runnable payload is still installed.
|
||||
// Missing-payload repair must keep disabling the broken config entry.
|
||||
const preserveInstalledPayload =
|
||||
options.code === PLUGIN_INSTALL_ERROR_CODE.NPM_METADATA_FAILURE &&
|
||||
options.installedPayloadRunnable === true;
|
||||
if (params.disableOnFailure && !params.dryRun && !preserveInstalledPayload) {
|
||||
const disabledMessage =
|
||||
`Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` +
|
||||
message;
|
||||
logger.warn?.(disabledMessage);
|
||||
next = disablePluginAfterUpdateFailure(next, pluginId);
|
||||
changed = true;
|
||||
outcomes.push({
|
||||
pluginId,
|
||||
status: "skipped",
|
||||
message: disabledMessage,
|
||||
...(options.channelFallback ? { channelFallback: options.channelFallback } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
outcomes.push({
|
||||
const failure = recordPluginUpdateFailure({
|
||||
config: next,
|
||||
disableOnFailure: params.disableOnFailure,
|
||||
dryRun: params.dryRun,
|
||||
logger,
|
||||
outcomes,
|
||||
pluginId,
|
||||
status: "error",
|
||||
message,
|
||||
...(options.channelFallback ? { channelFallback: options.channelFallback } : {}),
|
||||
options,
|
||||
});
|
||||
next = failure.config;
|
||||
changed ||= failure.changed;
|
||||
};
|
||||
|
||||
for (const pluginId of targets) {
|
||||
@@ -505,27 +497,29 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
}
|
||||
|
||||
const runAttempt = () =>
|
||||
runPluginUpdateAttempt({
|
||||
pluginId,
|
||||
record,
|
||||
config: params.config,
|
||||
dryRun: params.dryRun === true,
|
||||
effectiveSpec,
|
||||
extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedIntegrity,
|
||||
npmSpecs,
|
||||
clawhubSpecs,
|
||||
officialNpmFallbackSpecs,
|
||||
trustedSourceLinkedOfficialInstall,
|
||||
expectedReplacementPluginId: replacementPluginId,
|
||||
getFallbackExpectedIntegrity,
|
||||
installNpmSpecForUpdate,
|
||||
logger,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
clawHubRiskAcknowledgementOptions,
|
||||
});
|
||||
runPluginUpdateAttempt(
|
||||
copyPluginInstallTransactionRequest(params, {
|
||||
pluginId,
|
||||
record,
|
||||
config: params.config,
|
||||
dryRun: params.dryRun === true,
|
||||
effectiveSpec,
|
||||
extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
||||
expectedIntegrity,
|
||||
npmSpecs,
|
||||
clawhubSpecs,
|
||||
officialNpmFallbackSpecs,
|
||||
trustedSourceLinkedOfficialInstall,
|
||||
expectedReplacementPluginId: replacementPluginId,
|
||||
getFallbackExpectedIntegrity,
|
||||
installNpmSpecForUpdate,
|
||||
logger,
|
||||
onIntegrityDrift: params.onIntegrityDrift,
|
||||
clawHubRiskAcknowledgementOptions,
|
||||
}),
|
||||
);
|
||||
const attempt = await runPluginUpdateWithClawHubLease({
|
||||
pluginId,
|
||||
clawhubPackage: recordClawHubPackage,
|
||||
@@ -638,6 +632,7 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
}
|
||||
|
||||
const resolvedPluginId = result.pluginId;
|
||||
recordPluginUpdateTransaction(transactionState, result, pluginId, resolvedPluginId);
|
||||
if (resolvedPluginId !== pluginId) {
|
||||
next = migratePluginConfigId(next, pluginId, resolvedPluginId);
|
||||
}
|
||||
@@ -711,10 +706,12 @@ export async function updateNpmInstalledPlugins(params: {
|
||||
);
|
||||
}
|
||||
|
||||
if (ranNpmInstaller) {
|
||||
const repairedPeerLinks = await repairOpenClawPeerLinksForNpmInstalls({ config: next, logger });
|
||||
changed = repairedPeerLinks || changed;
|
||||
}
|
||||
|
||||
return { config: next, changed, outcomes };
|
||||
return await finalizePluginUpdateSummary({
|
||||
config: next,
|
||||
changed,
|
||||
outcomes,
|
||||
ranNpmInstaller,
|
||||
logger,
|
||||
transactionState,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
attachPluginInstallOwnerMigrations,
|
||||
resolvePluginInstallTransaction,
|
||||
resolvePluginInstallTransactionSink,
|
||||
settlePluginInstallTransactions,
|
||||
type PluginInstallTransaction,
|
||||
} from "./install-transaction.js";
|
||||
import { PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
|
||||
import {
|
||||
disablePluginAfterUpdateFailure,
|
||||
repairOpenClawPeerLinksForNpmInstalls,
|
||||
} from "./update-config.js";
|
||||
import type {
|
||||
PluginUpdateChannelFallback,
|
||||
PluginUpdateLogger,
|
||||
PluginUpdateOutcome,
|
||||
PluginUpdateSummary,
|
||||
} from "./update-source.js";
|
||||
|
||||
export function recordPluginUpdateFailure(params: {
|
||||
config: OpenClawConfig;
|
||||
disableOnFailure?: boolean;
|
||||
dryRun?: boolean;
|
||||
logger: PluginUpdateLogger;
|
||||
outcomes: PluginUpdateOutcome[];
|
||||
pluginId: string;
|
||||
message: string;
|
||||
options?: {
|
||||
channelFallback?: PluginUpdateChannelFallback;
|
||||
code?: string;
|
||||
installedPayloadRunnable?: boolean;
|
||||
};
|
||||
}): { config: OpenClawConfig; changed: boolean } {
|
||||
const options = params.options ?? {};
|
||||
const preserveInstalledPayload =
|
||||
options.code === PLUGIN_INSTALL_ERROR_CODE.NPM_METADATA_FAILURE &&
|
||||
options.installedPayloadRunnable === true;
|
||||
if (params.disableOnFailure && !params.dryRun && !preserveInstalledPayload) {
|
||||
const message =
|
||||
`Disabled "${params.pluginId}" after plugin update failure; OpenClaw will continue without it. ` +
|
||||
params.message;
|
||||
params.logger.warn?.(message);
|
||||
params.outcomes.push({
|
||||
pluginId: params.pluginId,
|
||||
status: "skipped",
|
||||
message,
|
||||
...(options.channelFallback ? { channelFallback: options.channelFallback } : {}),
|
||||
});
|
||||
return {
|
||||
config: disablePluginAfterUpdateFailure(params.config, params.pluginId),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
params.outcomes.push({
|
||||
pluginId: params.pluginId,
|
||||
status: "error",
|
||||
message: params.message,
|
||||
...(options.channelFallback ? { channelFallback: options.channelFallback } : {}),
|
||||
});
|
||||
return { config: params.config, changed: false };
|
||||
}
|
||||
|
||||
export function createPluginUpdateTransactionState(params: object) {
|
||||
return {
|
||||
transactions: [] as PluginInstallTransaction[],
|
||||
installOwnerMigrations: {} as Record<string, string>,
|
||||
transactionSink: resolvePluginInstallTransactionSink(params),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordPluginUpdateTransaction(
|
||||
state: ReturnType<typeof createPluginUpdateTransactionState>,
|
||||
result: object,
|
||||
pluginId: string,
|
||||
resolvedPluginId: string,
|
||||
): void {
|
||||
const transaction = resolvePluginInstallTransaction(result);
|
||||
if (transaction) {
|
||||
state.transactions.push(transaction);
|
||||
state.transactionSink?.push(transaction);
|
||||
}
|
||||
if (resolvedPluginId !== pluginId) {
|
||||
state.installOwnerMigrations[pluginId] = resolvedPluginId;
|
||||
}
|
||||
}
|
||||
|
||||
export async function finalizePluginUpdateSummary(params: {
|
||||
config: OpenClawConfig;
|
||||
changed: boolean;
|
||||
outcomes: PluginUpdateOutcome[];
|
||||
ranNpmInstaller: boolean;
|
||||
logger: PluginUpdateLogger;
|
||||
transactionState: ReturnType<typeof createPluginUpdateTransactionState>;
|
||||
}): Promise<PluginUpdateSummary> {
|
||||
let changed = params.changed;
|
||||
if (params.ranNpmInstaller) {
|
||||
try {
|
||||
changed =
|
||||
(await repairOpenClawPeerLinksForNpmInstalls({
|
||||
config: params.config,
|
||||
logger: params.logger,
|
||||
})) || changed;
|
||||
} catch (error) {
|
||||
await settlePluginInstallTransactions(params.transactionState.transactions, "rollback");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const summary = { config: params.config, changed, outcomes: params.outcomes };
|
||||
return Object.keys(params.transactionState.installOwnerMigrations).length > 0
|
||||
? attachPluginInstallOwnerMigrations(summary, params.transactionState.installOwnerMigrations)
|
||||
: summary;
|
||||
}
|
||||
@@ -88,6 +88,10 @@ function requiredConfig(env: ProbeEnv = process.env) {
|
||||
return readRequiredJson(configPath(env));
|
||||
}
|
||||
|
||||
function writeConfig(config: Record<string, unknown>, env: ProbeEnv = process.env) {
|
||||
fs.writeFileSync(configPath(env), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function assertProbe(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
@@ -216,6 +220,38 @@ export function assertUninstalled(pluginId: string, env: ProbeEnv = process.env)
|
||||
);
|
||||
}
|
||||
|
||||
function assertRemovedChildPolicy(pluginId: string, env: ProbeEnv = process.env) {
|
||||
const cfg = requiredConfig(env) as {
|
||||
plugins?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
entries?: Record<string, unknown>;
|
||||
load?: { paths?: unknown[] };
|
||||
slots?: { memory?: string; contextEngine?: string };
|
||||
};
|
||||
};
|
||||
assertProbe(!cfg.plugins?.entries?.[pluginId], `plugin entry survived for ${pluginId}`);
|
||||
assertProbe(
|
||||
!(cfg.plugins?.allow ?? []).includes(pluginId),
|
||||
`allow policy survived for ${pluginId}`,
|
||||
);
|
||||
assertProbe(
|
||||
!(cfg.plugins?.deny ?? []).includes(pluginId),
|
||||
`deny policy survived for ${pluginId}`,
|
||||
);
|
||||
assertProbe(
|
||||
!(cfg.plugins?.load?.paths ?? []).some((entry) =>
|
||||
String(entry).endsWith(`/${pluginId.split("/").at(-1)}.js`),
|
||||
),
|
||||
`load path survived for ${pluginId}`,
|
||||
);
|
||||
assertProbe(cfg.plugins?.slots?.memory !== pluginId, `memory slot survived for ${pluginId}`);
|
||||
assertProbe(
|
||||
cfg.plugins?.slots?.contextEngine !== pluginId,
|
||||
`context engine slot survived for ${pluginId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDurationMs(value: string | undefined, fallback: string) {
|
||||
const text = (value || fallback).trim();
|
||||
if (text === "0") {
|
||||
@@ -439,6 +475,26 @@ async function packFixturePlugin(
|
||||
await runCommand("tar", ["-czf", outputTgz, "-C", packDir, "package"]);
|
||||
}
|
||||
|
||||
async function packFixturePluginPack(
|
||||
packDir: string,
|
||||
outputTgz: string,
|
||||
pluginId: string,
|
||||
version: string,
|
||||
entries: readonly string[],
|
||||
) {
|
||||
const packageDir = path.join(packDir, "package");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
await runCommand("node", [
|
||||
"scripts/e2e/lib/fixture.mjs",
|
||||
"plugin-pack",
|
||||
packageDir,
|
||||
pluginId,
|
||||
version,
|
||||
entries.join(","),
|
||||
]);
|
||||
await runCommand("tar", ["-czf", outputTgz, "-C", packDir, "package"]);
|
||||
}
|
||||
|
||||
async function startNpmFixtureRegistry(
|
||||
registryRoot: string,
|
||||
packages: readonly [packageName: string, version: string, tarball: string][],
|
||||
@@ -446,6 +502,7 @@ async function startNpmFixtureRegistry(
|
||||
): Promise<RegistryServer> {
|
||||
const serverLog = path.join(registryRoot, "npm-registry.log");
|
||||
const serverPortFile = path.join(registryRoot, "npm-registry-port");
|
||||
fs.rmSync(serverPortFile, { force: true });
|
||||
const logFd = fs.openSync(serverLog, "a");
|
||||
const child = spawn(
|
||||
"node",
|
||||
@@ -535,16 +592,28 @@ async function runRuntimeInspect(params: {
|
||||
async function runPluginLifecycleMatrix() {
|
||||
const pluginId = "lifecycle-claw";
|
||||
const packageName = "@openclaw/lifecycle-claw";
|
||||
const packOwner = "lifecycle-pack";
|
||||
const packPackageName = "@openclaw/lifecycle-pack";
|
||||
const packOne = `${packOwner}/one`;
|
||||
const packTwo = `${packOwner}/two`;
|
||||
const packOld = `${packOwner}/old`;
|
||||
const packRenamed = `${packOwner}/renamed`;
|
||||
const resourceDir = tempDirs.make("openclaw-plugin-lifecycle-matrix-");
|
||||
const npmPrefix = "/tmp/npm-prefix";
|
||||
const env = createMatrixStateEnv(resourceDir);
|
||||
const tarballV1 = path.join(resourceDir, "lifecycle-claw-1.0.0.tgz");
|
||||
const tarballV2 = path.join(resourceDir, "lifecycle-claw-2.0.0.tgz");
|
||||
const packTarballV1 = path.join(resourceDir, "lifecycle-pack-1.0.0.tgz");
|
||||
const packTarballV2 = path.join(resourceDir, "lifecycle-pack-2.0.0.tgz");
|
||||
const inspectV1 = path.join(resourceDir, "plugin-lifecycle-inspect-v1.json");
|
||||
const inspectDisabled = path.join(resourceDir, "plugin-lifecycle-inspect-disabled.json");
|
||||
const inspectReenabled = path.join(resourceDir, "plugin-lifecycle-inspect-reenabled.json");
|
||||
const inspectV2 = path.join(resourceDir, "plugin-lifecycle-inspect-v2.json");
|
||||
const inspectDowngradeV1 = path.join(resourceDir, "plugin-lifecycle-inspect-downgrade-v1.json");
|
||||
const inspectPackOneV1 = path.join(resourceDir, "plugin-pack-one-v1.json");
|
||||
const inspectPackTwoDisabled = path.join(resourceDir, "plugin-pack-two-disabled.json");
|
||||
const inspectPackOneV2 = path.join(resourceDir, "plugin-pack-one-v2.json");
|
||||
const inspectPackRenamedV2 = path.join(resourceDir, "plugin-pack-renamed-v2.json");
|
||||
const summaryTsv = path.join(resourceDir, "resource-summary.tsv");
|
||||
let registry: RegistryServer | undefined;
|
||||
|
||||
@@ -575,6 +644,15 @@ async function runPluginLifecycleMatrix() {
|
||||
"lifecycle.v1",
|
||||
"Lifecycle Claw",
|
||||
);
|
||||
await packFixturePluginPack(path.join(packRoot, "pack-v1"), packTarballV1, packOwner, "1.0.0", [
|
||||
"one",
|
||||
"two",
|
||||
"old",
|
||||
]);
|
||||
await packFixturePluginPack(path.join(packRoot, "pack-v2"), packTarballV2, packOwner, "2.0.0", [
|
||||
"one",
|
||||
"renamed",
|
||||
]);
|
||||
await packFixturePlugin(
|
||||
path.join(packRoot, "v2"),
|
||||
tarballV2,
|
||||
@@ -588,10 +666,11 @@ async function runPluginLifecycleMatrix() {
|
||||
[
|
||||
[packageName, "1.0.0", tarballV1],
|
||||
[packageName, "2.0.0", tarballV2],
|
||||
[packPackageName, "1.0.0", packTarballV1],
|
||||
],
|
||||
matrixEnv,
|
||||
);
|
||||
const runEnv = registry.env as MatrixEnv;
|
||||
let runEnv = registry.env as MatrixEnv;
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
@@ -688,14 +767,144 @@ async function runPluginLifecycleMatrix() {
|
||||
`failed to remove plugin code before missing-code uninstall: ${installedPath}`,
|
||||
);
|
||||
|
||||
let missingCodeUninstallFailed = false;
|
||||
try {
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"missing-code-uninstall",
|
||||
"node",
|
||||
[entry, "plugins", "uninstall", pluginId, "--force"],
|
||||
runEnv,
|
||||
);
|
||||
} catch {
|
||||
missingCodeUninstallFailed = true;
|
||||
}
|
||||
assertProbe(
|
||||
missingCodeUninstallFailed,
|
||||
"missing-code uninstall must fail closed without authoritative child metadata",
|
||||
);
|
||||
assertProbe(recordFor(pluginId, runEnv), "missing-code uninstall removed the install record");
|
||||
assertEnabled(pluginId, true, runEnv);
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"missing-code-uninstall",
|
||||
"pack-install-v1",
|
||||
"node",
|
||||
[entry, "plugins", "uninstall", pluginId, "--force"],
|
||||
[entry, "plugins", "install", `npm:${packPackageName}@latest`, "--force"],
|
||||
runEnv,
|
||||
);
|
||||
assertUninstalled(pluginId, runEnv);
|
||||
assertVersion(packOwner, "1.0.0", runEnv);
|
||||
assertEnabled(packOne, true, runEnv);
|
||||
assertEnabled(packTwo, true, runEnv);
|
||||
assertEnabled(packOld, true, runEnv);
|
||||
await runRuntimeInspect({
|
||||
summaryTsv,
|
||||
phase: "pack-inspect-one-v1",
|
||||
entry,
|
||||
pluginId: packOne,
|
||||
inspectPath: inspectPackOneV1,
|
||||
env: runEnv,
|
||||
});
|
||||
assertInspectLoaded(packOne, inspectPackOneV1);
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"pack-disable-two",
|
||||
"node",
|
||||
[entry, "plugins", "disable", packTwo],
|
||||
runEnv,
|
||||
);
|
||||
assertEnabled(packTwo, false, runEnv);
|
||||
await runRuntimeInspect({
|
||||
summaryTsv,
|
||||
phase: "pack-inspect-two-disabled",
|
||||
entry,
|
||||
pluginId: packTwo,
|
||||
inspectPath: inspectPackTwoDisabled,
|
||||
env: runEnv,
|
||||
});
|
||||
assertInspectDisabled(packTwo, inspectPackTwoDisabled);
|
||||
|
||||
const policyConfig = requiredConfig(runEnv) as {
|
||||
plugins?: Record<string, unknown> & {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
entries?: Record<string, unknown>;
|
||||
load?: { paths?: string[] };
|
||||
slots?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
policyConfig.plugins = {
|
||||
...policyConfig.plugins,
|
||||
allow: [packOne, packTwo, packOld],
|
||||
deny: [packOld],
|
||||
entries: {
|
||||
...policyConfig.plugins?.entries,
|
||||
[packOld]: { enabled: true },
|
||||
},
|
||||
};
|
||||
writeConfig(policyConfig, runEnv);
|
||||
|
||||
registry.stop();
|
||||
registry = await startNpmFixtureRegistry(
|
||||
registryRoot,
|
||||
[
|
||||
[packageName, "1.0.0", tarballV1],
|
||||
[packageName, "2.0.0", tarballV2],
|
||||
[packPackageName, "1.0.0", packTarballV1],
|
||||
[packPackageName, "2.0.0", packTarballV2],
|
||||
],
|
||||
matrixEnv,
|
||||
);
|
||||
runEnv = registry.env as MatrixEnv;
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"pack-child-update-v2",
|
||||
"node",
|
||||
[entry, "plugins", "update", packTwo],
|
||||
runEnv,
|
||||
);
|
||||
assertVersion(packOwner, "2.0.0", runEnv);
|
||||
assertEnabled(packOne, true, runEnv);
|
||||
assertRemovedChildPolicy(packTwo, runEnv);
|
||||
assertRemovedChildPolicy(packOld, runEnv);
|
||||
await runRuntimeInspect({
|
||||
summaryTsv,
|
||||
phase: "pack-inspect-one-v2",
|
||||
entry,
|
||||
pluginId: packOne,
|
||||
inspectPath: inspectPackOneV2,
|
||||
env: runEnv,
|
||||
});
|
||||
assertInspectLoaded(packOne, inspectPackOneV2);
|
||||
await runRuntimeInspect({
|
||||
summaryTsv,
|
||||
phase: "pack-inspect-renamed-v2",
|
||||
entry,
|
||||
pluginId: packRenamed,
|
||||
inspectPath: inspectPackRenamedV2,
|
||||
env: runEnv,
|
||||
});
|
||||
assertInspectDisabled(packRenamed, inspectPackRenamedV2);
|
||||
|
||||
const packInstallPath = installPath(packOwner, runEnv);
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"pack-child-uninstall",
|
||||
"node",
|
||||
[entry, "plugins", "uninstall", packOne, "--force"],
|
||||
runEnv,
|
||||
);
|
||||
assertUninstalled(packOwner, runEnv);
|
||||
assertUninstalled(packOne, runEnv);
|
||||
assertUninstalled(packTwo, runEnv);
|
||||
assertUninstalled(packOld, runEnv);
|
||||
assertUninstalled(packRenamed, runEnv);
|
||||
assertProbe(
|
||||
!fs.existsSync(packInstallPath),
|
||||
`pack install directory still exists after child-addressed uninstall: ${packInstallPath}`,
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`Plugin lifecycle resource summary:\n${fs.readFileSync(summaryTsv, "utf8")}`,
|
||||
|
||||
@@ -2775,7 +2775,7 @@ export const en: TranslationMap = {
|
||||
remove: "Remove",
|
||||
removing: "Removing…",
|
||||
removeNamed: "Remove {name}",
|
||||
removeConfirm: "Remove this plugin?",
|
||||
removeConfirm: "Remove this plugin package and all of its entries?",
|
||||
cancel: "Cancel",
|
||||
removedRestart: "Removed {name}. A Gateway restart is required to apply the change.",
|
||||
verifiedSource: "Verified source",
|
||||
|
||||
@@ -325,7 +325,7 @@ describe("renderPlugins", () => {
|
||||
);
|
||||
|
||||
const confirm = container.querySelector<HTMLElement>(".plugins-remove-confirm");
|
||||
expect(normalizedText(confirm)).toContain("Remove this plugin?");
|
||||
expect(normalizedText(confirm)).toContain("Remove this plugin package and all of its entries?");
|
||||
confirm?.querySelector<HTMLButtonElement>(".btn.danger")?.click();
|
||||
expect(onUninstall).toHaveBeenCalledWith("community-thing", rowKey);
|
||||
confirm?.querySelectorAll<HTMLButtonElement>("button")[1]?.click();
|
||||
|
||||
Reference in New Issue
Block a user