diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index b874b77fe00..d699bf7b54e 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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 diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 528c278478a..d99c767781f 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -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"); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index ced610a7c8a..4af74577b6d 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -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, diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index fcb747b6ec7..509c92e52ff 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -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", diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 29065c13935..b59509b69bc 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -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; }