feat(plugins): record local onboarding installs

Record onboarding plugin install source metadata for npm and local paths, while keeping local path install records portable and preserving uninstall cleanup for relative source paths.
This commit is contained in:
Vincent Koc
2026-04-24 00:27:09 -07:00
committed by GitHub
parent 69196670b7
commit 1e8dc2389e
5 changed files with 260 additions and 6 deletions

View File

@@ -897,6 +897,13 @@ Official external npm entries should prefer an exact `npmSpec` plus
`expectedIntegrity`. Bare package names and dist-tags still work for
compatibility, but they surface source-plane warnings so the catalog can move
toward pinned, integrity-checked installs without breaking existing plugins.
When onboarding installs from a local catalog path, it records a
`plugins.installs` entry with `source: "path"` and a workspace-relative
`sourcePath` when possible. The absolute operational load path stays in
`plugins.load.paths`; the install record avoids duplicating local workstation
paths into long-lived config. This keeps local development installs visible to
source-plane diagnostics without adding a second raw filesystem-path disclosure
surface.
## Context engine plugins

View File

@@ -53,6 +53,23 @@ describe("ensureOnboardingPluginInstalled", () => {
});
it("passes npm specs and optional expected integrity to npm installs with progress", async () => {
const npmResolution = {
name: "@wecom/wecom-openclaw-plugin",
version: "1.2.3",
resolvedSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
integrity: "sha512-wecom",
shasum: "deadbeef",
resolvedAt: "2026-04-24T00:00:00.000Z",
};
const installFields = {
resolvedName: npmResolution.name,
resolvedVersion: npmResolution.version,
resolvedSpec: npmResolution.resolvedSpec,
integrity: npmResolution.integrity,
shasum: npmResolution.shasum,
resolvedAt: npmResolution.resolvedAt,
};
buildNpmResolutionInstallFields.mockReturnValueOnce(installFields);
installPluginFromNpmSpec.mockImplementation(async (params) => {
params.logger?.info?.("Downloading demo-plugin…");
return {
@@ -60,10 +77,7 @@ describe("ensureOnboardingPluginInstalled", () => {
pluginId: "demo-plugin",
targetDir: "/tmp/demo-plugin",
version: "1.2.3",
npmResolution: {
resolvedSpec: "@wecom/wecom-openclaw-plugin@1.2.3",
integrity: "sha512-wecom",
},
npmResolution,
};
});
const stop = vi.fn();
@@ -95,6 +109,18 @@ describe("ensureOnboardingPluginInstalled", () => {
);
expect(update).toHaveBeenCalledWith("Downloading demo-plugin…");
expect(stop).toHaveBeenCalledWith("Installed WeCom plugin");
expect(buildNpmResolutionInstallFields).toHaveBeenCalledWith(npmResolution);
expect(recordPluginInstall).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
pluginId: "demo-plugin",
source: "npm",
spec: "@wecom/wecom-openclaw-plugin@1.2.3",
installPath: "/tmp/demo-plugin",
version: "1.2.3",
...installFields,
}),
);
expect(result.installed).toBe(true);
expect(result.status).toBe("installed");
});
@@ -375,6 +401,143 @@ describe("ensureOnboardingPluginInstalled", () => {
});
});
it("records local install source metadata when a local path is selected", async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-local-record-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace");
const pluginDir = path.join(workspaceDir, "plugins", "demo");
await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
const result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin",
label: "Demo Plugin",
install: {
npmSpec: "@demo/plugin@1.2.3",
localPath: "plugins/demo",
},
},
prompter: {
select: vi.fn(async () => "local"),
} as never,
runtime: {} as never,
workspaceDir,
});
const realPluginDir = await fs.realpath(pluginDir);
expect(recordPluginInstall).toHaveBeenCalledWith(
expect.objectContaining({
plugins: {
load: {
paths: [realPluginDir],
},
},
}),
{
pluginId: "demo-plugin",
source: "path",
sourcePath: "./plugins/demo",
spec: "@demo/plugin@1.2.3",
},
);
expect(result.installed).toBe(true);
expect(result.status).toBe("installed");
});
});
it("records local install source metadata when npm install falls back to local", async () => {
await withTempDir(
{ prefix: "openclaw-onboarding-install-npm-fallback-record-" },
async (temp) => {
const workspaceDir = path.join(temp, "workspace");
const pluginDir = path.join(workspaceDir, "plugins", "demo");
await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
installPluginFromNpmSpec.mockResolvedValueOnce({
ok: false,
error: "registry unavailable",
});
const note = vi.fn(async () => {});
const result = await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin",
label: "Demo Plugin",
install: {
npmSpec: "@demo/plugin@1.2.3",
localPath: "plugins/demo",
},
},
prompter: {
select: vi.fn(async () => "npm"),
note,
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
} as never,
runtime: {} as never,
workspaceDir,
});
const realPluginDir = await fs.realpath(pluginDir);
expect(note).toHaveBeenCalledWith(
"Failed to install @demo/plugin@1.2.3: registry unavailable\nReturning to selection.",
"Plugin install",
);
expect(recordPluginInstall).toHaveBeenCalledWith(
expect.objectContaining({
plugins: {
load: {
paths: [realPluginDir],
},
},
}),
{
pluginId: "demo-plugin",
source: "path",
sourcePath: "./plugins/demo",
spec: "@demo/plugin@1.2.3",
},
);
expect(result.installed).toBe(true);
expect(result.status).toBe("installed");
},
);
});
it("records absolute local catalog paths as workspace-relative source metadata", async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-portable-record-" }, async (temp) => {
const workspaceDir = path.join(temp, "workspace");
const pluginDir = path.join(workspaceDir, "plugins", "demo");
await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true });
await fs.mkdir(pluginDir, { recursive: true });
const realPluginDir = await fs.realpath(pluginDir);
await ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin",
label: "Demo Plugin",
install: {
localPath: realPluginDir,
},
},
prompter: {
select: vi.fn(async () => "local"),
} as never,
runtime: {} as never,
workspaceDir,
});
expect(recordPluginInstall).toHaveBeenCalledWith(expect.anything(), {
pluginId: "demo-plugin",
source: "path",
sourcePath: "./plugins/demo",
});
});
});
it("keeps local installs available when cwd is a git repo but workspaceDir is not", async () => {
await withTempDir({ prefix: "openclaw-onboarding-install-cwd-git-" }, async (temp) => {
const repoDir = path.join(temp, "repo");

View File

@@ -116,6 +116,41 @@ function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawCon
};
}
function formatPortableLocalPath(localPath: string, workspaceDir?: string): string | undefined {
const bases = [workspaceDir, process.cwd()].filter((entry): entry is string => Boolean(entry));
for (const base of bases) {
const realBase = resolveRealDirectory(base);
if (!realBase) {
continue;
}
const relative = path.relative(realBase, localPath);
if (
relative === "" ||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
) {
const portable = relative.split(path.sep).join("/");
return portable ? `./${portable}` : ".";
}
}
return undefined;
}
function recordLocalPluginInstall(params: {
cfg: OpenClawConfig;
entry: OnboardingPluginInstallEntry;
localPath: string;
npmSpec?: string | null;
workspaceDir?: string;
}): OpenClawConfig {
const sourcePath = formatPortableLocalPath(params.localPath, params.workspaceDir);
return recordPluginInstall(params.cfg, {
pluginId: params.entry.pluginId,
source: "path",
...(sourcePath ? { sourcePath } : {}),
...(params.npmSpec ? { spec: params.npmSpec } : {}),
});
}
function resolveLocalPath(params: {
entry: OnboardingPluginInstallEntry;
workspaceDir?: string;
@@ -439,6 +474,7 @@ export async function ensureOnboardingPluginInstalled(params: {
};
}
next = addPluginLoadPath(enableResult.config, localPath);
next = recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir });
return {
cfg: next,
installed: true,
@@ -554,6 +590,7 @@ export async function ensureOnboardingPluginInstalled(params: {
};
}
next = addPluginLoadPath(enableResult.config, localPath);
next = recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir });
return {
cfg: next,
installed: true,

View File

@@ -306,6 +306,31 @@ describe("removePluginFromConfig", () => {
expect(actions.loadPath).toBe(true);
});
it("removes absolute load path for a workspace-relative install source path", async () => {
const tempRoot = path.join(process.cwd(), ".tmp");
await fs.mkdir(tempRoot, { recursive: true });
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-uninstall-portable-source-"));
try {
const pluginDir = path.join(tempDir, "plugins", "demo");
await fs.mkdir(pluginDir, { recursive: true });
const realPluginDir = await fs.realpath(pluginDir);
const sourcePath = `./${path.relative(process.cwd(), realPluginDir).split(path.sep).join("/")}`;
const config = createPluginConfig({
installs: {
"my-plugin": createPathInstallRecord(undefined, sourcePath),
},
loadPaths: [realPluginDir, "/other/path"],
});
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
expect(result.plugins?.load?.paths).toEqual(["/other/path"]);
expect(actions.loadPath).toBe(true);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it.each([
{
name: "clears memory slot when uninstalling active memory plugin",

View File

@@ -1,3 +1,4 @@
import { realpathSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -84,6 +85,22 @@ export function resolveUninstallChannelConfigKeys(
return keys;
}
function loadPathMatchesInstallSourcePath(loadPath: string, sourcePath: string): boolean {
if (loadPath === sourcePath) {
return true;
}
return resolveComparablePath(loadPath) === resolveComparablePath(sourcePath);
}
function resolveComparablePath(value: string): string {
const resolved = path.resolve(value);
try {
return realpathSync(resolved);
} catch {
return resolved;
}
}
/**
* Remove plugin references from config (pure config mutation).
* Returns a new config with the plugin removed from entries, installs, allow, load.paths, slots,
@@ -137,8 +154,13 @@ export function removePluginFromConfig(
if (installRecord?.source === "path" && installRecord.sourcePath) {
const sourcePath = installRecord.sourcePath;
const loadPaths = load?.paths;
if (Array.isArray(loadPaths) && loadPaths.includes(sourcePath)) {
const nextLoadPaths = loadPaths.filter((p) => p !== sourcePath);
if (
Array.isArray(loadPaths) &&
loadPaths.some((p) => loadPathMatchesInstallSourcePath(p, sourcePath))
) {
const nextLoadPaths = loadPaths.filter(
(p) => !loadPathMatchesInstallSourcePath(p, sourcePath),
);
load = nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined;
actions.loadPath = true;
}