diff --git a/.crabbox.yaml b/.crabbox.yaml index 7fbb9963751..4b954337716 100644 --- a/.crabbox.yaml +++ b/.crabbox.yaml @@ -29,6 +29,11 @@ actions: - openclaw runnerVersion: latest ephemeral: true +blacksmith: + org: openclaw + workflow: .github/workflows/ci-check-testbox.yml + job: check + ref: main aws: region: eu-west-1 rootGB: 400 diff --git a/.github/actions/setup-pnpm-store-cache/ensure-node.sh b/.github/actions/setup-pnpm-store-cache/ensure-node.sh index cb8ee4f817c..d8f7fdf8686 100644 --- a/.github/actions/setup-pnpm-store-cache/ensure-node.sh +++ b/.github/actions/setup-pnpm-store-cache/ensure-node.sh @@ -95,7 +95,7 @@ openclaw_find_toolcache_node() { done local node_root candidate candidate_version - for node_root in "${roots[@]}"; do + for node_root in ${roots[@]+"${roots[@]}"}; do while IFS= read -r candidate; do candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)" if openclaw_node_version_matches "$candidate_version" "$requested_node"; then diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 90e3e2df8f1..32bc36f3fc0 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -169,7 +169,7 @@ is available, then fall back to `latest`. `plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation. - Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm roots inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too. + Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run in one managed npm project per plugin with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm projects inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too. Use `npm:` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover unless they match an official plugin id. @@ -192,10 +192,10 @@ is available, then fall back to `latest`. Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records. Use `npm-pack:` when the file is an npm-pack tarball and you want - to test the same managed npm-root install path used by registry installs, - including `package-lock.json` verification, hoisted dependency scanning, and - npm install records. Plain archive paths still install as local archives - under the plugin extensions root. + to test the same per-plugin managed npm project path used by registry + installs, including `package-lock.json` verification, hoisted dependency + scanning, and npm install records. Plain archive paths still install as local + archives under the plugin extensions root. Claude marketplace installs are also supported. @@ -435,7 +435,7 @@ The local plugin registry is OpenClaw's persisted cold read model for installed Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path. -`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs. +`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under a managed plugin npm project or the legacy flat managed npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs. `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out. diff --git a/docs/help/testing-updates-plugins.md b/docs/help/testing-updates-plugins.md index 16f898e8fda..7d3139555ec 100644 --- a/docs/help/testing-updates-plugins.md +++ b/docs/help/testing-updates-plugins.md @@ -30,9 +30,9 @@ Update and plugin tests protect these contracts: plugin state. - Plugin installs work from local directories, git repos, npm packages, and the ClawHub registry path. -- Plugin npm dependencies are installed in the managed npm root, scanned before - trust, and removed through npm during uninstall so hoisted dependencies do not - linger. +- Plugin npm dependencies are installed in one managed npm project per plugin, + scanned before trust, and removed through npm during uninstall so hoisted + dependencies do not linger. - Plugin update is stable when nothing changed: install records, resolved source, installed dependency layout, and enabled state stay intact. @@ -276,9 +276,9 @@ can fail for the right reason: - Registry/package source behavior: `test:docker:plugins` fixture or ClawHub fixture server. - Dependency layout or cleanup behavior: assert both runtime execution and the - filesystem boundary. npm dependencies may be hoisted under the managed npm - root, so tests should prove the root is scanned/cleaned instead of assuming a - package-local `node_modules` tree. + filesystem boundary. npm dependencies may be hoisted inside the plugin's + managed npm project, so tests should prove that project is scanned/cleaned + instead of assuming only the plugin package-local `node_modules` tree. Keep new Docker fixtures hermetic by default. Use local fixture registries and fake packages unless the point of the test is live registry behavior. diff --git a/docs/help/testing.md b/docs/help/testing.md index 333e1a2ef56..e7b0f6d031e 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -84,11 +84,12 @@ When debugging real providers/models (requires real creds): - Codex on-demand install smoke: `pnpm test:docker:codex-on-demand` - Installs the packaged OpenClaw tarball in Docker, runs OpenAI API-key onboarding, and verifies the Codex plugin plus `@openai/codex` dependency - were downloaded into the managed npm root on demand. + were downloaded into the managed npm project root on demand. - Live plugin tool dependency smoke: `pnpm test:docker:live-plugin-tool` - Packs a fixture plugin with a real `slugify` dependency, installs it through - `npm-pack:`, verifies the dependency under the managed npm root, then asks a - live OpenAI model to call the plugin tool and return the hidden slug. + `npm-pack:`, verifies the dependency under the managed npm project root, + then asks a live OpenAI model to call the plugin tool and return the hidden + slug. - Crestodian rescue command smoke: `pnpm test:live:crestodian-rescue-channel` - Opt-in belt-and-suspenders check for the message-channel rescue command surface. It exercises `/crestodian status`, queues a persistent model diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index fd9c5768c6b..2921361dd0e 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -34,34 +34,36 @@ OpenClaw owns only the plugin lifecycle: OpenClaw uses stable per-source roots: -- npm packages install under `~/.openclaw/npm` +- npm packages install into per-plugin projects under + `~/.openclaw/npm/projects/` - git packages clone under `~/.openclaw/git` - local/path/archive installs are copied or referenced without dependency repair -npm installs run in the npm root with: +npm installs run in that per-plugin project root with: ```bash -cd ~/.openclaw/npm +cd ~/.openclaw/npm/projects/ npm install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund ``` -`openclaw plugins install npm-pack:` uses that same managed npm root -for a local npm-pack tarball. OpenClaw reads the tarball's npm metadata, adds it -to the managed root as a copied `file:` dependency, runs the normal npm install, -and then verifies the installed lockfile metadata before trusting the plugin. +`openclaw plugins install npm-pack:` uses that same per-plugin npm +project root for a local npm-pack tarball. OpenClaw reads the tarball's npm +metadata, adds it to the managed project as a copied `file:` dependency, runs +the normal npm install, and then verifies the installed lockfile metadata before +trusting the plugin. This is intended for package-acceptance and release-candidate proof where a local pack artifact should behave like the registry artifact it simulates. -npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside -the plugin package. OpenClaw scans the managed npm root before trusting the -install and uses npm to remove npm-managed packages during uninstall, so hoisted -runtime dependencies stay inside the managed cleanup boundary. +npm may hoist transitive dependencies to the per-plugin project's +`node_modules` beside the plugin package. OpenClaw scans the managed project +root before trusting the install and removes that project during uninstall, so +hoisted runtime dependencies stay inside that plugin's cleanup boundary. Published npm plugin packages can ship `npm-shrinkwrap.json`. npm uses that -publishable lockfile during install, and OpenClaw's managed npm root supports it -through the normal npm install path. OpenClaw-owned publishable plugin packages -must include a package-local shrinkwrap generated from that plugin package's -published dependency graph: +publishable lockfile during install, and OpenClaw's managed npm project root +supports it through the normal npm install path. OpenClaw-owned publishable +plugin packages must include a package-local shrinkwrap generated from that +plugin package's published dependency graph: ```bash pnpm deps:shrinkwrap:generate @@ -87,11 +89,11 @@ instead of embedding every platform binary in the plugin tarball. The root Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer dependency. OpenClaw does not let npm install a separate registry copy of the -host package into the managed root, because stale host packages can affect npm -peer resolution during later plugin installs. Managed npm installs skip npm peer -resolution/materialization for the shared root and OpenClaw reasserts -plugin-local `node_modules/openclaw` links for installed packages that declare -the host peer after install, update, or uninstall. +host package into a managed project, because stale host packages can affect npm +peer resolution inside that plugin. Managed npm installs skip npm peer +resolution/materialization and OpenClaw reasserts plugin-local +`node_modules/openclaw` links for installed packages that declare the host peer +after install or update. git installs clone or refresh the repository, then run: @@ -155,7 +157,7 @@ not a supported way to prepare bundled plugin dependencies. | -------------------------------- | ------------------------------------- | -------------------------------------------------------------------- | | `npm install -g openclaw` | Built runtime tree inside the package | OpenClaw package and explicit plugin install/update/doctor flows | | Git checkout plus `pnpm install` | `extensions/` workspace packages | The pnpm workspace, including each plugin package's own dependencies | -| `openclaw plugins install ...` | Managed npm/git/ClawHub plugin root | The plugin install/update flow | +| `openclaw plugins install ...` | Managed npm project/git/ClawHub root | The plugin install/update flow | ## Legacy cleanup @@ -168,4 +170,7 @@ stage directories, and package-local pnpm stores. Packaged postinstall also removes those global symlinks before pruning the legacy target roots so upgrades do not leave dangling ESM package imports. -These paths are legacy debris only. New installs should not create them. +Older npm installs also used a shared `~/.openclaw/npm/node_modules` root. +Current install, update, uninstall, and doctor flows still recognize that legacy +flat root only for recovery and cleanup. New npm installs should create +per-plugin project roots instead. diff --git a/docs/plugins/install-overrides.md b/docs/plugins/install-overrides.md index a6a77e84524..3d306b687e3 100644 --- a/docs/plugins/install-overrides.md +++ b/docs/plugins/install-overrides.md @@ -71,8 +71,8 @@ pnpm openclaw onboard --mode local Verify the installed package under the state directory: ```bash -find "$OPENCLAW_STATE_DIR/npm/node_modules" -maxdepth 3 -name package.json -print -grep -R '"@openclaw/codex"' "$OPENCLAW_STATE_DIR/npm/package-lock.json" +find "$OPENCLAW_STATE_DIR/npm/projects" -path '*/node_modules/@openclaw/codex/package.json' -print +grep -R '"@openclaw/codex"' "$OPENCLAW_STATE_DIR/npm/projects"/*/package-lock.json ``` For live provider E2E, source the real API key from a trusted shell or CI secret diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index efbf6a55eb1..fcb1f994123 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -534,7 +534,7 @@ openclaw plugins install ``` -For npm-sourced installs, `openclaw plugins install` installs the package under `~/.openclaw/npm` with lifecycle scripts disabled. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds. +For npm-sourced installs, `openclaw plugins install` installs the package into a per-plugin project under `~/.openclaw/npm/projects` with lifecycle scripts disabled. Keep plugin dependency trees pure JS/TS and avoid packages that require `postinstall` builds. diff --git a/extensions/codex/src/app-server/managed-binary.test.ts b/extensions/codex/src/app-server/managed-binary.test.ts index 00d0bafd4e9..525c2dda374 100644 --- a/extensions/codex/src/app-server/managed-binary.test.ts +++ b/extensions/codex/src/app-server/managed-binary.test.ts @@ -22,7 +22,8 @@ function startOptions( } function managedCommandPath(root: string, platform: NodeJS.Platform): string { - return path.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex"); + const pathApi = platform === "win32" ? path.win32 : path.posix; + return pathApi.join(root, "node_modules", ".bin", platform === "win32" ? "codex.cmd" : "codex"); } describe("managed Codex app-server binary", () => { @@ -96,6 +97,52 @@ describe("managed Codex app-server binary", () => { }); }); + it("finds Codex bins hoisted into an isolated npm project root", async () => { + const projectRoot = path.join("/tmp", "state", "npm", "projects", "openclaw-codex-hash"); + const pluginRoot = path.join(projectRoot, "node_modules", "@openclaw", "codex"); + const installedCommand = managedCommandPath(projectRoot, "linux"); + const pathExists = vi.fn(async (filePath: string) => filePath === installedCommand); + + await expect( + resolveManagedCodexAppServerStartOptions(startOptions("managed"), { + platform: "linux", + pluginRoot, + pathExists, + }), + ).resolves.toEqual({ + ...startOptions("managed"), + command: installedCommand, + commandSource: "resolved-managed", + }); + }); + + it("finds Windows Codex shims hoisted into an isolated npm project root", async () => { + const projectRoot = path.win32.join( + "C:\\", + "Users", + "test", + ".openclaw", + "npm", + "projects", + "openclaw-codex-hash", + ); + const pluginRoot = path.win32.join(projectRoot, "node_modules", "@openclaw", "codex"); + const installedCommand = managedCommandPath(projectRoot, "win32"); + const pathExists = vi.fn(async (filePath: string) => filePath === installedCommand); + + await expect( + resolveManagedCodexAppServerStartOptions(startOptions("managed"), { + platform: "win32", + pluginRoot, + pathExists, + }), + ).resolves.toEqual({ + ...startOptions("managed"), + command: installedCommand, + commandSource: "resolved-managed", + }); + }); + it("falls back to the resolved Codex package bin when no command shim exists", async () => { const installRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-codex-package-")); const pluginRoot = path.join(installRoot, "dist", "extensions", "codex"); diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index 0493b8bf14a..51bcb857a19 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -90,7 +90,7 @@ function resolveManagedCodexAppServerCandidateRoots( platform: NodeJS.Platform, ): string[] { const pathApi = pathForPlatform(platform); - return [ + const directRoots = [ pluginRoot, pathApi.dirname(pluginRoot), pathApi.dirname(pathApi.dirname(pluginRoot)), @@ -98,6 +98,32 @@ function resolveManagedCodexAppServerCandidateRoots( ? pathApi.dirname(pathApi.dirname(pathApi.dirname(pluginRoot))) : null, ].filter((root): root is string => Boolean(root)); + return [ + ...new Set([...directRoots, ...resolveNearestNodeModulesProjectRoots(directRoots, platform)]), + ]; +} + +function resolveNearestNodeModulesProjectRoots( + roots: readonly string[], + platform: NodeJS.Platform, +): string[] { + const pathApi = pathForPlatform(platform); + const projectRoots: string[] = []; + for (const root of roots) { + let current = pathApi.resolve(root); + while (true) { + if (pathApi.basename(current) === "node_modules") { + projectRoots.push(pathApi.dirname(current)); + break; + } + const parent = pathApi.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + } + return projectRoots; } function resolveManagedCodexPackageBinCandidates( diff --git a/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs index e3e768f5334..c98eebbd508 100644 --- a/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs +++ b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs @@ -210,15 +210,28 @@ function codexInstallPath() { return record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME); } +function codexNpmProjectRoot() { + const installPath = codexInstallPath(); + const packageRoot = "@openclaw/codex" + .split("/") + .reduce((current) => path.dirname(current), installPath); + return path.basename(packageRoot) === "node_modules" + ? path.dirname(packageRoot) + : managedNpmRoot(); +} + function findPackageJson(packageName) { const parts = packageName.split("/"); + const projectRoot = codexNpmProjectRoot(); const candidates = packageName.startsWith("@") && parts.length === 2 ? [ + path.join(projectRoot, "node_modules", parts[0], parts[1], "package.json"), path.join(codexInstallPath(), "node_modules", parts[0], parts[1], "package.json"), path.join(managedNpmRoot(), "node_modules", parts[0], parts[1], "package.json"), ] : [ + path.join(projectRoot, "node_modules", packageName, "package.json"), path.join(codexInstallPath(), "node_modules", packageName, "package.json"), path.join(managedNpmRoot(), "node_modules", packageName, "package.json"), ]; @@ -256,6 +269,7 @@ function assertNpmDeps() { function resolveCodexBin() { const commandName = process.platform === "win32" ? "codex.cmd" : "codex"; const candidates = [ + path.join(codexNpmProjectRoot(), "node_modules", ".bin", commandName), path.join(codexInstallPath(), "node_modules", ".bin", commandName), path.join(managedNpmRoot(), "node_modules", ".bin", commandName), ]; diff --git a/scripts/e2e/lib/codex-on-demand/assertions.mjs b/scripts/e2e/lib/codex-on-demand/assertions.mjs index 16c39f5795e..c96fcb74955 100644 --- a/scripts/e2e/lib/codex-on-demand/assertions.mjs +++ b/scripts/e2e/lib/codex-on-demand/assertions.mjs @@ -42,6 +42,15 @@ function findPackageJson(packageName, roots) { return candidates.find((candidate) => fs.existsSync(candidate)); } +function npmProjectRootForInstalledPackage(installPath, packageName) { + const packageRoot = packageName + .split("/") + .reduce((current) => path.dirname(current), installPath); + return path.basename(packageRoot) === "node_modules" + ? path.dirname(packageRoot) + : path.join(stateDir(), "npm"); +} + const cfg = readJson(configPath()); const inspect = readJson("/tmp/openclaw-codex-inspect.json"); const records = installRecords(); @@ -72,7 +81,12 @@ if (codexPackage.name !== "@openclaw/codex") { throw new Error(`unexpected codex package name: ${codexPackage.name}`); } -const openAiCodexPackageJson = findPackageJson("@openai/codex", [installPath, npmRoot]); +const npmProjectRoot = npmProjectRootForInstalledPackage(installPath, "@openclaw/codex"); +const openAiCodexPackageJson = findPackageJson("@openai/codex", [ + installPath, + npmProjectRoot, + npmRoot, +]); if (!openAiCodexPackageJson) { throw new Error("missing @openai/codex dependency under managed npm root"); } diff --git a/scripts/e2e/lib/live-plugin-tool/assertions.mjs b/scripts/e2e/lib/live-plugin-tool/assertions.mjs index 1ddfeed7a8e..7088dfc4bef 100644 --- a/scripts/e2e/lib/live-plugin-tool/assertions.mjs +++ b/scripts/e2e/lib/live-plugin-tool/assertions.mjs @@ -313,7 +313,12 @@ function configure() { function findDependencyPackageJson(packageName) { const installPath = pluginInstallPath(); const npmRoot = path.join(stateDir(), "npm"); + const pluginName = requireEnv("PLUGIN_NAME"); + const packageRoot = pluginName.split("/").reduce((current) => path.dirname(current), installPath); + const projectRoot = + path.basename(packageRoot) === "node_modules" ? path.dirname(packageRoot) : npmRoot; return [ + path.join(projectRoot, "node_modules", packageName, "package.json"), path.join(installPath, "node_modules", packageName, "package.json"), path.join(npmRoot, "node_modules", packageName, "package.json"), ].find((candidate) => fs.existsSync(candidate)); diff --git a/scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs b/scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs index 7dacd57e15c..b9c760068e8 100644 --- a/scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs +++ b/scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs @@ -51,6 +51,37 @@ function assertVersion(pluginId, version) { ); } +function assertNpmProjectRoot(pluginId, packageName) { + const record = recordFor(pluginId); + assert(record?.installPath, `install path missing for ${pluginId}`); + const relative = path.relative(openclawPath("npm", "projects"), record.installPath); + assert( + !relative.startsWith("..") && !path.isAbsolute(relative), + `install path outside npm projects: ${record.installPath}`, + ); + const segments = relative.split(path.sep); + const packageSegments = packageName.split("/"); + assert( + segments.length === 2 + packageSegments.length, + `unexpected npm project install path: ${record.installPath}`, + ); + assert(Boolean(segments[0]), `missing npm project directory: ${record.installPath}`); + assert( + segments[1] === "node_modules", + `missing project node_modules segment: ${record.installPath}`, + ); + for (let index = 0; index < packageSegments.length; index++) { + assert( + segments[index + 2] === packageSegments[index], + `package path mismatch: ${record.installPath}`, + ); + } + assert( + !fs.existsSync(openclawPath("npm", "node_modules", ...packageSegments)), + `legacy flat npm install path exists for ${packageName}`, + ); +} + function assertEnabled(pluginId, expectedRaw) { const expected = expectedRaw === "true"; const entry = config().plugins?.entries?.[pluginId]; @@ -82,6 +113,9 @@ switch (command) { case "assert-version": assertVersion(pluginId, arg); break; + case "assert-npm-project-root": + assertNpmProjectRoot(pluginId, arg); + break; case "assert-enabled": assertEnabled(pluginId, arg); break; diff --git a/scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh b/scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh index 94bf059b0dc..42f4c3aa1c2 100644 --- a/scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh +++ b/scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh @@ -40,6 +40,7 @@ start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz " run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0" node "$probe" assert-version "$plugin_id" 1.0.0 +node "$probe" assert-npm-project-root "$plugin_id" "$package_name" run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >/tmp/plugin-lifecycle-inspect-v1.json' bash "$entry" "$plugin_id" @@ -51,9 +52,11 @@ node "$probe" assert-enabled "$plugin_id" true run_measured upgrade-v2 node "$entry" plugins update "$package_name@2.0.0" node "$probe" assert-version "$plugin_id" 2.0.0 +node "$probe" assert-npm-project-root "$plugin_id" "$package_name" run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0" node "$probe" assert-version "$plugin_id" 1.0.0 +node "$probe" assert-npm-project-root "$plugin_id" "$package_name" install_path="$(node "$probe" install-path "$plugin_id")" rm -rf "$install_path" diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 8ac0455afc1..31003a056e7 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -51,6 +51,21 @@ function isPathInside(parent, child) { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +function isPathInsideManagedNpmProjectPackageRoot(params) { + const relative = path.relative(path.join(params.stateDir, "npm", "projects"), params.installPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return false; + } + const segments = relative.split(path.sep); + const packageSegments = params.packageName.split("/"); + return ( + segments.length === 2 + packageSegments.length && + Boolean(segments[0]) && + segments[1] === "node_modules" && + packageSegments.every((segment, index) => segments[index + 2] === segment) + ); +} + function write(file, contents) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, contents); @@ -427,10 +442,10 @@ function assertExternalPluginInstall(records, pluginId, packageName) { `configured external ${pluginId} package name changed: ${packageJson.name}`, ); if (installedFromNpm) { - const npmRoot = path.join(requireEnv("OPENCLAW_STATE_DIR"), "npm", "node_modules"); + const stateDir = requireEnv("OPENCLAW_STATE_DIR"); assert( - isPathInside(npmRoot, installPath), - `configured external ${pluginId} npm install path outside managed npm root: ${installPath}`, + isPathInsideManagedNpmProjectPackageRoot({ stateDir, installPath, packageName }), + `configured external ${pluginId} npm install path outside managed npm project root: ${installPath}`, ); assert( String(record.spec ?? record.resolvedSpec ?? "").startsWith(packageName), diff --git a/src/agents/live-model-dynamic-candidates.test.ts b/src/agents/live-model-dynamic-candidates.test.ts index be4ddb98bbd..e6d5bca43e4 100644 --- a/src/agents/live-model-dynamic-candidates.test.ts +++ b/src/agents/live-model-dynamic-candidates.test.ts @@ -8,10 +8,6 @@ vi.mock("./agent-model-discovery.js", () => ({ import { appendPrioritizedDynamicLiveModels } from "./live-model-dynamic-candidates.js"; -vi.mock("./agent-model-discovery.js", () => ({ - normalizeDiscoveredAgentModel: (value: T) => value, -})); - const REGISTRY = { find: () => undefined } as never; const DYNAMIC_PROVIDER = "dynamic-test-provider"; type DynamicModelResolver = NonNullable< @@ -20,6 +16,9 @@ type DynamicModelResolver = NonNullable< type DynamicModelPreparer = NonNullable< Parameters[0]["prepareDynamicModel"] >; +type DynamicModelNormalizer = NonNullable< + Parameters[0]["normalizeModel"] +>; function model(provider: string, id: string): Model { return { @@ -44,7 +43,7 @@ describe("appendPrioritizedDynamicLiveModels", () => { : undefined, ); const prepareDynamicModel: DynamicModelPreparer = vi.fn(async () => undefined); - const normalizeModel = vi.fn((entry: Model) => entry); + const normalizeModel: DynamicModelNormalizer = vi.fn((entry) => entry); const config = { models: { providers: { diff --git a/src/cli/update-cli/post-core-plugin-convergence.test.ts b/src/cli/update-cli/post-core-plugin-convergence.test.ts index cdad1356bf3..a280d2debcc 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + listManagedPluginNpmRoots: vi.fn(), repairMissingConfiguredPluginInstalls: vi.fn(), relinkOpenClawPeerDependenciesInManagedNpmRoot: vi.fn(), runPluginPayloadSmokeCheck: vi.fn(), @@ -16,6 +17,9 @@ vi.mock("../../plugins/plugin-peer-link.js", () => ({ relinkOpenClawPeerDependenciesInManagedNpmRoot: mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot, })); +vi.mock("../../plugins/npm-project-roots.js", () => ({ + listManagedPluginNpmRoots: mocks.listManagedPluginNpmRoots, +})); vi.mock("./plugin-payload-validation.js", () => ({ runPluginPayloadSmokeCheck: mocks.runPluginPayloadSmokeCheck, })); @@ -33,6 +37,9 @@ describe("runPostCorePluginConvergence", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.listManagedPluginNpmRoots.mockImplementation((npmRoot: string) => + Promise.resolve([npmRoot]), + ); mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], warnings: [], @@ -148,28 +155,43 @@ describe("runPostCorePluginConvergence", () => { }); }); - it("repairs managed npm openclaw peer links before payload smoke checks", async () => { + it("repairs managed npm openclaw peer links in every managed npm project before payload smoke checks", async () => { mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], warnings: [], records: { codex: { source: "npm", installPath: "/p/codex" } }, }); - mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockResolvedValue({ - checked: 1, - attempted: 1, - repaired: 1, - skipped: 0, - }); + mocks.listManagedPluginNpmRoots.mockResolvedValue([ + "/tmp/openclaw-state/npm", + "/tmp/openclaw-state/npm/projects/codex", + ]); + mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot + .mockResolvedValueOnce({ + checked: 0, + attempted: 0, + repaired: 0, + skipped: 0, + }) + .mockResolvedValueOnce({ + checked: 1, + attempted: 1, + repaired: 1, + skipped: 0, + }); const result = await runPostCorePluginConvergence({ cfg: { plugins: { entries: { codex: { enabled: true } } } } as unknown as OpenClawConfig, env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" }, }); - expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenCalledWith({ + expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(1, { npmRoot: "/tmp/openclaw-state/npm", logger: {}, }); + expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(2, { + npmRoot: "/tmp/openclaw-state/npm/projects/codex", + logger: {}, + }); expect(result.changes).toEqual([ "Repaired OpenClaw host peer link(s) for 1 managed npm plugin package(s).", ]); diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index d9534e053d2..3fcb9162ce9 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js"; import { resolveDefaultPluginNpmDir } from "../../plugins/install-paths.js"; +import { listManagedPluginNpmRoots } from "../../plugins/npm-project-roots.js"; import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "../../plugins/plugin-peer-link.js"; import { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js"; import { @@ -49,16 +50,20 @@ async function repairManagedNpmOpenClawPeerLinks(params: { env: NodeJS.ProcessEnv; }): Promise<{ changes: string[]; warnings: PostCoreConvergenceWarning[] }> { try { - const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({ - npmRoot: resolveDefaultPluginNpmDir(params.env), - logger: {}, - }); + const npmRoots = await listManagedPluginNpmRoots(resolveDefaultPluginNpmDir(params.env)); + const results = await Promise.all( + npmRoots.map((npmRoot) => + relinkOpenClawPeerDependenciesInManagedNpmRoot({ + npmRoot, + logger: {}, + }), + ), + ); + const repaired = results.reduce((total, result) => total + result.repaired, 0); return { changes: - result.repaired > 0 - ? [ - `Repaired OpenClaw host peer link(s) for ${result.repaired} managed npm plugin package(s).`, - ] + repaired > 0 + ? [`Repaired OpenClaw host peer link(s) for ${repaired} managed npm plugin package(s).`] : [], warnings: [], }; diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index 2bcf23483c3..b5836e2e231 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginCandidate } from "../plugins/discovery.js"; +import { resolvePluginNpmProjectDir } from "../plugins/install-paths.js"; import { readPersistedInstalledPluginIndex, writePersistedInstalledPluginIndex, @@ -117,8 +118,12 @@ function createManagedNpmPlugin(params: { peerDependencies?: Record; packageLock?: boolean; }) { - const npmRoot = path.join(params.stateDir, "npm"); - const packageDir = path.join(npmRoot, "node_modules", params.packageName); + const npmBaseDir = path.join(params.stateDir, "npm"); + const npmRoot = resolvePluginNpmProjectDir({ + npmDir: npmBaseDir, + packageName: params.packageName, + }); + const packageDir = path.join(npmRoot, "node_modules", ...params.packageName.split("/")); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync( path.join(npmRoot, "package.json"), @@ -318,7 +323,7 @@ describe("maybeRepairPluginRegistryState", () => { const stateDir = makeTempDir(); const bundledDir = path.join(stateDir, "bundled", "google-meet"); fs.mkdirSync(bundledDir, { recursive: true }); - createManagedNpmPlugin({ + const managed = createManagedNpmPlugin({ stateDir, id: "google-meet", packageName: "@openclaw/google-meet", @@ -355,16 +360,14 @@ describe("maybeRepairPluginRegistryState", () => { "Managed npm plugin packages shadow bundled plugins", ); expect(vi.mocked(note).mock.calls.join("\n")).toContain("@openclaw/google-meet@2026.5.2"); - expect( - fs.existsSync(path.join(stateDir, "npm", "node_modules", "@openclaw", "google-meet")), - ).toBe(true); + expect(fs.existsSync(managed.packageDir)).toBe(true); }); it("removes stale managed npm packages that shadow bundled plugins during repair", async () => { const stateDir = makeTempDir(); const bundledDir = path.join(stateDir, "bundled", "google-meet"); fs.mkdirSync(bundledDir, { recursive: true }); - createManagedNpmPlugin({ + const managed = createManagedNpmPlugin({ stateDir, id: "google-meet", packageName: "@openclaw/google-meet", @@ -397,11 +400,9 @@ describe("maybeRepairPluginRegistryState", () => { prompter: { shouldRepair: true }, }); + expect(fs.existsSync(managed.packageDir)).toBe(false); expect( - fs.existsSync(path.join(stateDir, "npm", "node_modules", "@openclaw", "google-meet")), - ).toBe(false); - expect( - JSON.parse(fs.readFileSync(path.join(stateDir, "npm", "package.json"), "utf8")), + JSON.parse(fs.readFileSync(path.join(managed.npmRoot, "package.json"), "utf8")), ).not.toHaveProperty("dependencies"); const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir); expect(persisted.refreshReason).toBe("migration"); @@ -590,7 +591,7 @@ describe("maybeRepairPluginRegistryState", () => { const stateDir = makeTempDir(); const bundledDir = path.join(stateDir, "bundled", "google-meet"); fs.mkdirSync(bundledDir, { recursive: true }); - createManagedNpmPlugin({ + const managed = createManagedNpmPlugin({ stateDir, id: "google-meet", packageName: "@openclaw/google-meet", @@ -625,7 +626,7 @@ describe("maybeRepairPluginRegistryState", () => { }); const packageLock = JSON.parse( - fs.readFileSync(path.join(stateDir, "npm", "package-lock.json"), "utf8"), + fs.readFileSync(path.join(managed.npmRoot, "package-lock.json"), "utf8"), ); expect(packageLock.packages[""].dependencies).toEqual({ "other-plugin": "1.0.0" }); expect(packageLock.packages).not.toHaveProperty("node_modules/@openclaw/google-meet"); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index bb2504b7d0c..6600a49ea58 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -11,6 +11,7 @@ import { type InstalledPluginIndexRecordStoreOptions, } from "../plugins/installed-plugin-index-records.js"; import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js"; +import { listManagedPluginNpmRootsSync } from "../plugins/npm-project-roots.js"; import { auditOpenClawPeerDependenciesInManagedNpmRoot, relinkOpenClawPeerDependenciesInManagedNpmRoot, @@ -74,6 +75,10 @@ function resolveManagedPluginNpmRoot(params: PluginRegistryDoctorRepairParams): : resolveDefaultPluginNpmDir(params.env); } +function listManagedPluginNpmRoots(params: PluginRegistryDoctorRepairParams): string[] { + return listManagedPluginNpmRootsSync(resolveManagedPluginNpmRoot(params)); +} + function deleteObjectKey(record: Record, key: string): boolean { if (!Object.prototype.hasOwnProperty.call(record, key)) { return false; @@ -104,33 +109,34 @@ function listStaleManagedNpmBundledPlugins( const bundledByPackage = new Map( currentBundled.map((plugin) => [plugin.packageName, plugin] as const), ); - const npmRoot = resolveManagedPluginNpmRoot(params); - const npmPackageJsonPath = path.join(npmRoot, "package.json"); - const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies); const stale: StaleManagedNpmBundledPlugin[] = []; - for (const packageName of Object.keys(dependencies).toSorted((left, right) => - left.localeCompare(right), - )) { - if (!packageName.startsWith("@openclaw/")) { - continue; + for (const npmRoot of listManagedPluginNpmRoots(params)) { + const npmPackageJsonPath = path.join(npmRoot, "package.json"); + const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies); + for (const packageName of Object.keys(dependencies).toSorted((left, right) => + left.localeCompare(right), + )) { + if (!packageName.startsWith("@openclaw/")) { + continue; + } + const bundled = bundledByPackage.get(packageName); + if (!bundled) { + continue; + } + const packageDir = path.join(npmRoot, "node_modules", ...packageName.split("/")); + const pluginId = readPluginManifestId(packageDir); + if (!pluginId || pluginId !== bundled.pluginId) { + continue; + } + stale.push({ + pluginId, + packageName, + packageDir, + npmRoot, + ...(readPackageVersion(packageDir) ? { version: readPackageVersion(packageDir) } : {}), + }); } - const bundled = bundledByPackage.get(packageName); - if (!bundled) { - continue; - } - const packageDir = path.join(npmRoot, "node_modules", packageName); - const pluginId = readPluginManifestId(packageDir); - if (!pluginId || pluginId !== bundled.pluginId) { - continue; - } - stale.push({ - pluginId, - packageName, - packageDir, - npmRoot, - ...(readPackageVersion(packageDir) ? { version: readPackageVersion(packageDir) } : {}), - }); } return stale; @@ -312,14 +318,17 @@ export async function maybeRepairStaleLocalBundledPluginInstallRecords( export async function maybeRepairManagedNpmOpenClawPeerLinks( params: PluginRegistryDoctorRepairParams, ): Promise { - const npmRoot = resolveManagedPluginNpmRoot(params); + const npmRoots = listManagedPluginNpmRoots(params); if (!params.prompter.shouldRepair) { - const audit = await auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot }); - if (audit.broken > 0) { + const audits = await Promise.all( + npmRoots.map((npmRoot) => auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot })), + ); + const issues = audits.flatMap((audit) => audit.issues); + if (issues.length > 0) { note( [ "Managed npm OpenClaw host peer links need repair:", - ...audit.issues.map((issue) => `- ${issue.packageName}: ${issue.reason}`), + ...issues.map((issue) => `- ${issue.packageName}: ${issue.reason}`), `Repair with ${formatCliCommand("openclaw doctor --fix")} to relink managed npm plugin packages.`, ].join("\n"), "Plugin registry", @@ -333,14 +342,19 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks( info: (message) => messages.push({ level: "info", message }), warn: (message) => messages.push({ level: "warn", message }), }; - const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({ - npmRoot, - logger, - }); + const results = await Promise.all( + npmRoots.map((npmRoot) => + relinkOpenClawPeerDependenciesInManagedNpmRoot({ + npmRoot, + logger, + }), + ), + ); + const repaired = results.reduce((total, result) => total + result.repaired, 0); - if (result.repaired > 0) { + if (repaired > 0) { note( - `Repaired OpenClaw host peer link(s) for ${result.repaired} managed npm plugin package(s).`, + `Repaired OpenClaw host peer link(s) for ${repaired} managed npm plugin package(s).`, "Plugin registry", ); } @@ -354,7 +368,7 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks( ); } - return result.repaired > 0; + return repaired > 0; } async function loadInstallRecordsWithoutPluginIds( diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 7b42d51de9b..cfec8dd24aa 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -62,6 +62,16 @@ const mocks = vi.hoisted(() => ({ ), resolveDefaultPluginExtensionsDir: vi.fn(() => "/tmp/openclaw-plugins"), resolveDefaultPluginNpmDir: vi.fn(() => "/tmp/openclaw-npm"), + resolvePluginNpmPackageDir: vi.fn( + ({ npmDir, packageName }: { npmDir?: string; packageName: string }) => + path.join( + npmDir ?? "/tmp/openclaw-npm", + "projects", + packageName.replace(/[^a-zA-Z0-9._-]+/g, "-"), + "node_modules", + ...packageName.split("/"), + ), + ), resolvePluginInstallDir: vi.fn( (pluginId: string, extensionsDir = "/tmp/openclaw-plugins") => `${extensionsDir}/${pluginId}`, ), @@ -114,6 +124,7 @@ vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) => vi.mock("../../../plugins/install-paths.js", () => ({ resolveDefaultPluginExtensionsDir: mocks.resolveDefaultPluginExtensionsDir, resolveDefaultPluginNpmDir: mocks.resolveDefaultPluginNpmDir, + resolvePluginNpmPackageDir: mocks.resolvePluginNpmPackageDir, resolvePluginInstallDir: mocks.resolvePluginInstallDir, validatePluginId: mocks.validatePluginId, })); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index bed33509f58..4f1956f6fc5 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -25,6 +25,7 @@ import { import { resolveDefaultPluginNpmDir, resolveDefaultPluginExtensionsDir, + resolvePluginNpmPackageDir, resolvePluginInstallDir, } from "../../../plugins/install-paths.js"; import { installPluginFromNpmSpec } from "../../../plugins/install.js"; @@ -602,6 +603,16 @@ function pathsEqual(left: string, right: string): boolean { } function resolveNpmPackageInstallPath(params: { packageName: string; npmRoot: string }): string { + return resolvePluginNpmPackageDir({ + npmDir: params.npmRoot, + packageName: params.packageName, + }); +} + +function resolveLegacyNpmPackageInstallPath(params: { + packageName: string; + npmRoot: string; +}): string { return path.join(params.npmRoot, "node_modules", ...params.packageName.split("/")); } @@ -698,11 +709,20 @@ function resolveSafeBrokenOfficialInstallRemovalPath(params: { if (!parsedNpmSpec?.name) { return null; } - const expectedNpmPath = resolveNpmPackageInstallPath({ - packageName: parsedNpmSpec.name, - npmRoot: resolveDefaultPluginNpmDir(params.env), - }); - return pathsEqual(resolvedInstallPath, expectedNpmPath) ? resolvedInstallPath : null; + const npmRoot = resolveDefaultPluginNpmDir(params.env); + const expectedNpmPaths = [ + resolveNpmPackageInstallPath({ + packageName: parsedNpmSpec.name, + npmRoot, + }), + resolveLegacyNpmPackageInstallPath({ + packageName: parsedNpmSpec.name, + npmRoot, + }), + ]; + return expectedNpmPaths.some((expectedPath) => pathsEqual(resolvedInstallPath, expectedPath)) + ? resolvedInstallPath + : null; } function recordMatchesBundledPackage( @@ -928,7 +948,14 @@ function resolveExistingCandidateNpmPackagePath(params: { packageName: npmName, npmRoot: params.npmDir, }); - return existsSync(packagePath) ? packagePath : null; + if (existsSync(packagePath)) { + return packagePath; + } + const legacyPackagePath = resolveLegacyNpmPackageInstallPath({ + packageName: npmName, + npmRoot: params.npmDir, + }); + return existsSync(legacyPackagePath) ? legacyPackagePath : null; } function resolveExistingCandidateClawHubPackagePath(params: { diff --git a/src/infra/net/node-proxy-agent.ts b/src/infra/net/node-proxy-agent.ts index d8766375b05..29c9f64d621 100644 --- a/src/infra/net/node-proxy-agent.ts +++ b/src/infra/net/node-proxy-agent.ts @@ -1,5 +1,5 @@ import type { Agent as HttpAgent } from "node:http"; -import { createAmbientNodeProxyAgent } from "@openclaw/proxyline"; +import { createRequire } from "node:module"; import { matchesNoProxy, resolveEnvHttpProxyAgentOptions } from "./proxy-env.js"; import { resolveActiveManagedProxyTlsOptions } from "./proxy/managed-proxy-undici.js"; @@ -7,10 +7,14 @@ export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = "Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL."; type NodeProxyProtocol = "http" | "https"; -type ProxylineAgentOptions = NonNullable[0]>; +type ProxylineCreateAmbientNodeProxyAgent = + typeof import("@openclaw/proxyline").createAmbientNodeProxyAgent; +type ProxylineAgentOptions = NonNullable[0]>; type ProxylineEnvSnapshot = NonNullable; type ProxylineTlsOptions = ProxylineAgentOptions["proxyTls"]; +const require = createRequire(import.meta.url); + export type CreateNodeProxyAgentOptions = | { mode: "env"; @@ -92,6 +96,11 @@ function fixedProxyEnv(proxyUrl: URL): ProxylineEnvSnapshot { }; } +function loadCreateAmbientNodeProxyAgent(): ProxylineCreateAmbientNodeProxyAgent { + return (require("@openclaw/proxyline") as typeof import("@openclaw/proxyline")) + .createAmbientNodeProxyAgent; +} + export function resolveEnvNodeProxyUrlForTarget( targetUrl: string | URL, env: NodeJS.ProcessEnv = process.env, @@ -123,7 +132,7 @@ function createFixedNodeProxyAgent( proxyUrl instanceof URL ? proxyUrl : proxyUrlWithDefaultScheme(proxyUrl, options.protocol ?? "https"); - const agent = createAmbientNodeProxyAgent({ + const agent = loadCreateAmbientNodeProxyAgent()({ env: fixedProxyEnv(parsedProxyUrl), protocol: options.protocol ?? "https", ...(options.proxyTls !== undefined ? { proxyTls: options.proxyTls } : {}), diff --git a/src/plugins/install-paths.ts b/src/plugins/install-paths.ts index 0c2f598bbe6..ba3fb17624a 100644 --- a/src/plugins/install-paths.ts +++ b/src/plugins/install-paths.ts @@ -87,6 +87,40 @@ export function resolveDefaultPluginNpmDir( return path.join(resolveConfigDir(env, homedir), "npm"); } +export function encodePluginNpmProjectDirName(packageName: string): string { + const trimmed = packageName.trim(); + if (!trimmed) { + throw new Error("invalid npm package name: missing"); + } + return safePathSegmentHashed(trimmed); +} + +export function resolvePluginNpmProjectsDir(npmDir?: string): string { + const npmBase = npmDir ? resolveUserPath(npmDir) : resolveDefaultPluginNpmDir(); + return path.join(npmBase, "projects"); +} + +export function resolvePluginNpmProjectDir(params: { + packageName: string; + npmDir?: string; +}): string { + return path.join( + resolvePluginNpmProjectsDir(params.npmDir), + encodePluginNpmProjectDirName(params.packageName), + ); +} + +export function resolvePluginNpmPackageDir(params: { + packageName: string; + npmDir?: string; +}): string { + return path.join( + resolvePluginNpmProjectDir(params), + "node_modules", + ...params.packageName.split("/"), + ); +} + export function resolveDefaultPluginGitDir( env: NodeJS.ProcessEnv = process.env, homedir?: () => string, diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index 8fde88301e8..27cee8cc060 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; +import { resolvePluginNpmProjectDir } from "./install-paths.js"; import { installPluginFromNpmSpec } from "./install.js"; type PackedVersion = { @@ -47,6 +48,10 @@ async function makeTempDir(label: string): Promise { return dir; } +function pluginNpmProjectRoot(npmRoot: string, packageName: string): string { + return resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName }); +} + async function packPlugin(params: { dependencies?: Record; packageName: string; @@ -366,14 +371,16 @@ describe("installPluginFromNpmSpec e2e", () => { throw new Error(result.error); } - const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as { + const projectRoot = pluginNpmProjectRoot(npmRoot, packageName); + const lock = JSON.parse( + await fs.readFile(path.join(projectRoot, "package-lock.json"), "utf8"), + ) as { packages?: Record; }; expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined(); - await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toHaveProperty( - "code", - "ENOENT", - ); + await expect( + fs.lstat(path.join(projectRoot, "node_modules", "openclaw")), + ).rejects.toHaveProperty("code", "ENOENT"); await expect( fs .lstat(path.join(result.targetDir, "node_modules", "openclaw")) @@ -381,7 +388,7 @@ describe("installPluginFromNpmSpec e2e", () => { ).resolves.toBe(true); }); - it("keeps third-party peer dependencies across later managed npm installs", async () => { + it("keeps third-party peer dependencies in the owning npm project across later installs", async () => { const rootDir = await makeTempDir("npm-plugin-third-party-peer-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); const pluginWithRuntimePeer = `runtime-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; @@ -439,8 +446,9 @@ describe("installPluginFromNpmSpec e2e", () => { if (!first.ok) { throw new Error(first.error); } + const firstProjectRoot = pluginNpmProjectRoot(npmRoot, pluginWithRuntimePeer); await expect( - fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")), + fs.lstat(path.join(firstProjectRoot, "node_modules", runtimePeer, "package.json")), ).resolves.toBeTruthy(); const second = await installPluginFromNpmSpec({ @@ -454,7 +462,7 @@ describe("installPluginFromNpmSpec e2e", () => { } await expect( - fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")), + fs.lstat(path.join(firstProjectRoot, "node_modules", runtimePeer, "package.json")), ).resolves.toBeTruthy(); }); @@ -518,14 +526,15 @@ describe("installPluginFromNpmSpec e2e", () => { throw new Error(result.error); } + const projectRoot = pluginNpmProjectRoot(npmRoot, pluginWithOptionalDependency); await expect( - fs.lstat(path.join(npmRoot, "node_modules", optionalDependency, "package.json")), + fs.lstat(path.join(projectRoot, "node_modules", optionalDependency, "package.json")), ).resolves.toBeTruthy(); await expect( - fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")), + fs.lstat(path.join(projectRoot, "node_modules", runtimePeer, "package.json")), ).resolves.toBeTruthy(); const rootManifest = JSON.parse( - await fs.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.readFile(path.join(projectRoot, "package.json"), "utf8"), ) as { dependencies?: Record; openclaw?: { managedPeerDependencies?: string[] }; @@ -534,7 +543,7 @@ describe("installPluginFromNpmSpec e2e", () => { expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer); }); - it("repairs pre-existing peer dependencies during later installs", async () => { + it("leaves legacy flat-root peer dependencies alone during isolated later installs", async () => { const rootDir = await makeTempDir("npm-plugin-repaired-peer-scan-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); const pluginWithRuntimePeer = `existing-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; @@ -625,24 +634,25 @@ describe("installPluginFromNpmSpec e2e", () => { throw new Error(later.error); } + const laterProjectRoot = pluginNpmProjectRoot(npmRoot, laterPlugin); await expect( - fs.lstat(path.join(npmRoot, "node_modules", laterPlugin, "package.json")), + fs.lstat(path.join(laterProjectRoot, "node_modules", laterPlugin, "package.json")), ).resolves.toBeTruthy(); await expect( fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")), - ).resolves.toBeTruthy(); + ).rejects.toHaveProperty("code", "ENOENT"); const rootManifest = JSON.parse( await fs.readFile(path.join(npmRoot, "package.json"), "utf8"), ) as { dependencies?: Record; openclaw?: { managedPeerDependencies?: string[] }; }; - expect(rootManifest.dependencies?.[laterPlugin]).toBe("1.0.0"); - expect(rootManifest.dependencies?.[runtimePeer]).toBe("1.0.0"); - expect(rootManifest.openclaw?.managedPeerDependencies ?? []).toContain(runtimePeer); + expect(rootManifest.dependencies?.[laterPlugin]).toBeUndefined(); + expect(rootManifest.dependencies?.[runtimePeer]).toBeUndefined(); + expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(runtimePeer); }); - it("bounds peer dependency discovery across repeated nested package realpaths", async () => { + it("ignores legacy flat-root package cycles during isolated installs", async () => { const rootDir = await makeTempDir("npm-plugin-peer-cycle-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); const existingPlugin = `existing-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; @@ -716,7 +726,14 @@ describe("installPluginFromNpmSpec e2e", () => { expect(later.ok).toBe(true); await expect( - fs.lstat(path.join(npmRoot, "node_modules", laterPlugin, "package.json")), + fs.lstat( + path.join( + pluginNpmProjectRoot(npmRoot, laterPlugin), + "node_modules", + laterPlugin, + "package.json", + ), + ), ).resolves.toBeTruthy(); }); @@ -765,8 +782,9 @@ describe("installPluginFromNpmSpec e2e", () => { }); expect(result.ok).toBe(false); + const projectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin); const rootManifest = JSON.parse( - await fs.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.readFile(path.join(projectRoot, "package.json"), "utf8"), ) as { dependencies?: Record; openclaw?: { managedPeerDependencies?: string[] }; @@ -775,14 +793,14 @@ describe("installPluginFromNpmSpec e2e", () => { expect(rootManifest.dependencies?.[runtimePeer]).toBeUndefined(); expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(runtimePeer); await expect( - fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")), + fs.lstat(path.join(projectRoot, "node_modules", blockedPlugin, "package.json")), ).rejects.toHaveProperty("code", "ENOENT"); await expect( - fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")), + fs.lstat(path.join(projectRoot, "node_modules", runtimePeer, "package.json")), ).rejects.toHaveProperty("code", "ENOENT"); }); - it("falls back to the legacy install path when npm cannot plan third-party peers", async () => { + it("falls back to the legacy npm peer mode inside the plugin project when npm cannot plan third-party peers", async () => { const rootDir = await makeTempDir("npm-plugin-peer-plan-fallback-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); const blockedPlugin = `missing-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; @@ -814,8 +832,9 @@ describe("installPluginFromNpmSpec e2e", () => { }); expect(result.ok).toBe(true); + const projectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin); const rootManifest = JSON.parse( - await fs.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.readFile(path.join(projectRoot, "package.json"), "utf8"), ) as { dependencies?: Record; openclaw?: { managedPeerDependencies?: string[] }; @@ -824,7 +843,7 @@ describe("installPluginFromNpmSpec e2e", () => { expect(rootManifest.dependencies?.[missingPeer]).toBeUndefined(); expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(missingPeer); await expect( - fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")), + fs.lstat(path.join(projectRoot, "node_modules", blockedPlugin, "package.json")), ).resolves.toBeTruthy(); }); @@ -881,9 +900,10 @@ describe("installPluginFromNpmSpec e2e", () => { process.env.NPM_CONFIG_REGISTRY = registry; process.env.npm_config_registry = registry; - await fs.mkdir(npmRoot, { recursive: true }); + const blockedProjectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin); + await fs.mkdir(blockedProjectRoot, { recursive: true }); await fs.writeFile( - path.join(npmRoot, "package.json"), + path.join(blockedProjectRoot, "package.json"), `${JSON.stringify( { private: true, @@ -906,7 +926,7 @@ describe("installPluginFromNpmSpec e2e", () => { "--no-audit", "--no-fund", ], - { cwd: npmRoot }, + { cwd: blockedProjectRoot }, ); const result = await installPluginFromNpmSpec({ @@ -918,7 +938,7 @@ describe("installPluginFromNpmSpec e2e", () => { expect(result.ok).toBe(false); const rootManifest = JSON.parse( - await fs.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.readFile(path.join(blockedProjectRoot, "package.json"), "utf8"), ) as { dependencies?: Record; openclaw?: { managedPeerDependencies?: string[] }; @@ -931,17 +951,19 @@ describe("installPluginFromNpmSpec e2e", () => { ); expect(rootManifest.openclaw?.managedPeerDependencies ?? []).not.toContain(runtimePeer); await expect( - fs.lstat(path.join(npmRoot, "node_modules", existingRootDependency, "package.json")), + fs.lstat( + path.join(blockedProjectRoot, "node_modules", existingRootDependency, "package.json"), + ), ).resolves.toBeTruthy(); await expect( - fs.lstat(path.join(npmRoot, "node_modules", blockedPlugin, "package.json")), + fs.lstat(path.join(blockedProjectRoot, "node_modules", blockedPlugin, "package.json")), ).rejects.toHaveProperty("code", "ENOENT"); await expect( - fs.lstat(path.join(npmRoot, "node_modules", runtimePeer, "package.json")), + fs.lstat(path.join(blockedProjectRoot, "node_modules", runtimePeer, "package.json")), ).rejects.toHaveProperty("code", "ENOENT"); }); - it("scrubs host peers when installing beside an existing host-peer plugin", async () => { + it("scrubs host peers inside each isolated npm project", async () => { const rootDir = await makeTempDir("npm-plugin-sibling-peer-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); const codexName = `codex-peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; @@ -991,61 +1013,52 @@ describe("installPluginFromNpmSpec e2e", () => { process.env.NPM_CONFIG_REGISTRY = registry; process.env.npm_config_registry = registry; - await fs.mkdir(npmRoot, { recursive: true }); - await fs.writeFile( - path.join(npmRoot, "package.json"), - `${JSON.stringify({ private: true, dependencies: { [codexName]: "1.0.0" } }, null, 2)}\n`, - "utf8", - ); - await execFileAsync( - "npm", - ["install", "--omit=peer", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"], - { - cwd: npmRoot, - env: { - ...process.env, - NPM_CONFIG_REGISTRY: registry, - NPM_CONFIG_LEGACY_PEER_DEPS: "false", - NPM_CONFIG_STRICT_PEER_DEPS: "false", - npm_config_registry: registry, - npm_config_legacy_peer_deps: "false", - npm_config_strict_peer_deps: "false", - }, - timeout: 120_000, - }, - ); + const first = await installPluginFromNpmSpec({ + spec: `${codexName}@1.0.0`, + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + timeoutMs: 120_000, + }); + if (!first.ok) { + throw new Error(first.error); + } - const result = await installPluginFromNpmSpec({ + const second = await installPluginFromNpmSpec({ spec: `${opikName}@1.0.0`, npmDir: npmRoot, logger: { info: () => {}, warn: () => {} }, timeoutMs: 120_000, }); - if (!result.ok) { - throw new Error(result.error); + if (!second.ok) { + throw new Error(second.error); } - const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as { - packages?: Record; - }; - expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined(); - await expect(fs.lstat(path.join(npmRoot, "node_modules", "openclaw"))).rejects.toHaveProperty( - "code", - "ENOENT", - ); + const codexProjectRoot = pluginNpmProjectRoot(npmRoot, codexName); + const opikProjectRoot = pluginNpmProjectRoot(npmRoot, opikName); + for (const projectRoot of [codexProjectRoot, opikProjectRoot]) { + const lock = JSON.parse( + await fs.readFile(path.join(projectRoot, "package-lock.json"), "utf8"), + ) as { + packages?: Record; + }; + expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined(); + await expect( + fs.lstat(path.join(projectRoot, "node_modules", "openclaw")), + ).rejects.toHaveProperty("code", "ENOENT"); + } await expect( fs - .lstat(path.join(npmRoot, "node_modules", codexName, "node_modules", "openclaw")) + .lstat(path.join(first.targetDir, "node_modules", "openclaw")) .then((stat) => stat.isSymbolicLink()), ).resolves.toBe(true); await expect( fs - .lstat(path.join(npmRoot, "node_modules", opikName, "node_modules", "openclaw")) + .lstat(path.join(second.targetDir, "node_modules", "openclaw")) .then((stat) => stat.isSymbolicLink()), ).resolves.toBe(true); }); - it("relinks managed npm sibling openclaw peers after later plugin installs", async () => { + it("keeps an earlier isolated openclaw peer link after later plugin installs", async () => { const rootDir = await makeTempDir("npm-plugin-peer-e2e"); const npmRoot = path.join(rootDir, "managed-npm"); const peerPackageName = `peer-plugin-${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; @@ -1097,11 +1110,16 @@ describe("installPluginFromNpmSpec e2e", () => { } await expect(fs.lstat(peerLink).then((stat) => stat.isSymbolicLink())).resolves.toBe(true); - const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as { + const peerProjectRoot = pluginNpmProjectRoot(npmRoot, peerPackageName); + const manifest = JSON.parse( + await fs.readFile(path.join(peerProjectRoot, "package.json"), "utf8"), + ) as { dependencies?: Record; }; expect(manifest.dependencies?.openclaw).toBeUndefined(); - const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as { + const lock = JSON.parse( + await fs.readFile(path.join(peerProjectRoot, "package-lock.json"), "utf8"), + ) as { packages?: Record; }; expect(lock.packages?.["node_modules/openclaw"]).toBeUndefined(); @@ -1138,7 +1156,10 @@ describe("installPluginFromNpmSpec e2e", () => { expect(result.ok).toBe(true); expect(result.npmResolution?.version).toBe("1.0.0"); - const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as { + const projectRoot = pluginNpmProjectRoot(npmRoot, packageName); + const manifest = JSON.parse( + await fs.readFile(path.join(projectRoot, "package.json"), "utf8"), + ) as { dependencies?: Record; }; expect(manifest.dependencies?.[packageName]).toBe("1.0.0"); @@ -1148,7 +1169,9 @@ describe("installPluginFromNpmSpec e2e", () => { ) as { version?: string }; expect(installedManifest.version).toBe("1.0.0"); - const lock = JSON.parse(await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8")) as { + const lock = JSON.parse( + await fs.readFile(path.join(projectRoot, "package-lock.json"), "utf8"), + ) as { packages?: Record; }; const installedLockEntry = lock.packages?.[`node_modules/${packageName}`]; diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 57ff1d66de5..24f0fe25291 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -5,6 +5,7 @@ import { expectIntegrityDriftRejected, mockNpmViewMetadataResult, } from "../test-utils/npm-spec-install-test-helpers.js"; +import { resolvePluginNpmProjectDir } from "./install-paths.js"; import { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js"; const runCommandWithTimeoutMock = vi.fn(); @@ -102,6 +103,31 @@ function expectNpmInstallIntoRoot(params: { calls: unknown[][]; npmRoot: string ]); } +function expectNpmInstallIntoProject(params: { + calls: unknown[][]; + npmRoot: string; + packageName: string; +}) { + expectNpmInstallIntoRoot({ + calls: params.calls, + npmRoot: resolvePluginNpmProjectDir({ + npmDir: params.npmRoot, + packageName: params.packageName, + }), + }); +} + +function resolveTestPluginPackageDir(npmRoot: string, packageName: string): string { + return path.join( + resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName, + }), + "node_modules", + ...packageName.split("/"), + ); +} + function writeInstalledNpmPlugin(params: { npmRoot: string; packageName: string; @@ -394,6 +420,7 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) { } writeInstalledNpmPlugin({ ...pkg, + npmRoot, version: pkg.installedVersion ?? pkg.version, }); if (pkg.materializesRootOpenClaw) { @@ -431,10 +458,13 @@ function mockNpmViewAndInstallMany(packages: MockNpmPackage[]) { if (!pkg) { throw new Error(`unexpected npm uninstall package: ${packageName ?? ""}`); } - fs.rmSync(path.join(pkg.npmRoot, "node_modules", pkg.packageName), { - recursive: true, - force: true, - }); + fs.rmSync( + path.join(options?.cwd ?? pkg.npmRoot, "node_modules", ...pkg.packageName.split("/")), + { + recursive: true, + force: true, + }, + ); return successfulSpawn(); } throw new Error(`unexpected command: ${(argv as string[]).join(" ")}`); @@ -509,22 +539,27 @@ describe("installPluginFromNpmSpec", () => { return; } expect(result.pluginId).toBe("pack-demo"); - expect(result.targetDir).toBe(path.join(npmRoot, "node_modules", "@openclaw/pack-demo")); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "@openclaw/pack-demo", + }); + expect(result.targetDir).toBe(resolveTestPluginPackageDir(npmRoot, "@openclaw/pack-demo")); expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/pack-demo@1.2.3"); expect(result.npmResolution?.integrity).toBe("sha512-pack-demo"); expect(result.npmTarballName).toBe("openclaw-pack-demo-1.2.3.tgz"); - expectNpmInstallIntoRoot({ + expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, + packageName: "@openclaw/pack-demo", }); const managedManifest = JSON.parse( - await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"), ) as { dependencies?: Record }; const dependencySpec = managedManifest.dependencies?.["@openclaw/pack-demo"]; expect(dependencySpec).toMatch(/^file:\.\/_openclaw-pack-archives\/.+\.tgz$/); expect(dependencySpec).not.toContain(archivePath); const stagedArchivePath = dependencySpec - ? resolveManagedFileDependency(npmRoot, dependencySpec) + ? resolveManagedFileDependency(npmProjectRoot, dependencySpec) : null; if (stagedArchivePath === null) { throw new Error("expected staged archive path"); @@ -594,15 +629,16 @@ describe("installPluginFromNpmSpec", () => { return; } expect(result.pluginId).toBe("voice-call"); - expect(result.targetDir).toBe(path.join(npmRoot, "node_modules", "@openclaw/voice-call")); + expect(result.targetDir).toBe(resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call")); expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1"); expect(result.npmResolution?.integrity).toBe("sha512-plugin-test"); expect( fs.existsSync(path.join(result.targetDir, "node_modules", "is-number", "package.json")), ).toBe(true); - expectNpmInstallIntoRoot({ + expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, + packageName: "@openclaw/voice-call", }); }); @@ -624,8 +660,12 @@ describe("installPluginFromNpmSpec", () => { }); expect(result.ok).toBe(true); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "mutable-plugin", + }); const manifest = JSON.parse( - await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"), ) as { dependencies?: Record }; expect(manifest.dependencies?.["mutable-plugin"]).toBe("1.2.3"); }); @@ -657,7 +697,7 @@ describe("installPluginFromNpmSpec", () => { } expect(result.error).toContain("integrity sha512-evil"); expect(result.error).toContain("expected sha512-safe"); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "drift-plugin"))).toBe(false); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "drift-plugin"))).toBe(false); }); it("rejects npm installs when the installed version drifts from verified metadata", async () => { @@ -684,7 +724,7 @@ describe("installPluginFromNpmSpec", () => { } expect(result.error).toContain("version 1.0.1"); expect(result.error).toContain("expected 1.0.0"); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "version-drift-plugin"))).toBe(false); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "version-drift-plugin"))).toBe(false); }); it("rejects npm installs when package-lock omits the installed plugin", async () => { @@ -712,7 +752,7 @@ describe("installPluginFromNpmSpec", () => { expect(result.error).toContain( "npm install did not record package-lock metadata for missing-lock-plugin", ); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "missing-lock-plugin"))).toBe(false); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false); }); it("rejects npm installs with blocked hoisted transitive dependencies", async () => { @@ -771,10 +811,9 @@ describe("installPluginFromNpmSpec", () => { logger: { info: () => {}, warn: () => {} }, }); expect(first.ok).toBe(true); + const peerPluginDir = resolveTestPluginPackageDir(npmRoot, "peer-plugin"); expect( - fs - .lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw")) - .isSymbolicLink(), + fs.lstatSync(path.join(peerPluginDir, "node_modules", "openclaw")).isSymbolicLink(), ).toBe(true); const second = await installPluginFromNpmSpec({ @@ -788,9 +827,7 @@ describe("installPluginFromNpmSpec", () => { expect(second.error).not.toContain("peer-plugin/node_modules/openclaw"); } expect( - fs - .lstatSync(path.join(npmRoot, "node_modules", "peer-plugin", "node_modules", "openclaw")) - .isSymbolicLink(), + fs.lstatSync(path.join(peerPluginDir, "node_modules", "openclaw")).isSymbolicLink(), ).toBe(true); }, ); @@ -826,13 +863,9 @@ describe("installPluginFromNpmSpec", () => { logger: { info: () => {}, warn: () => {} }, }); expect(first.ok).toBe(true); + const peerPluginDir = resolveTestPluginPackageDir(npmRoot, "peer-plugin"); - const staleNodeModulesPath = path.join( - npmRoot, - "node_modules", - "peer-plugin", - "node_modules", - ); + const staleNodeModulesPath = path.join(peerPluginDir, "node_modules"); fs.rmSync(staleNodeModulesPath, { recursive: true, force: true }); fs.writeFileSync(staleNodeModulesPath, "not a directory", "utf-8"); @@ -843,12 +876,8 @@ describe("installPluginFromNpmSpec", () => { }); expect(second.ok).toBe(true); - expect( - warnings.some((warning) => - warning.includes(`Skipping openclaw peerDependency link because ${staleNodeModulesPath}`), - ), - ).toBe(true); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "next-plugin"))).toBe(true); + expect(warnings).toEqual([]); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "next-plugin"))).toBe(true); expect(fs.readFileSync(staleNodeModulesPath, "utf-8")).toBe("not a directory"); }, ); @@ -883,9 +912,13 @@ describe("installPluginFromNpmSpec", () => { expect( warnings.some((warning) => warning.includes("Could not locate openclaw package root")), ).toBe(true); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "codex"))).toBe(false); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/codex"))).toBe(false); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "@openclaw/codex", + }); const managedManifest = JSON.parse( - fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"), + fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"), ) as { dependencies?: Record }; expect(managedManifest.dependencies?.["@openclaw/codex"]).toBeUndefined(); }); @@ -1021,19 +1054,20 @@ describe("installPluginFromNpmSpec", () => { }); expect(result.ok).toBe(true); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "openclaw"))).toBe(false); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "required-peer-plugin", + }); + const requiredPeerPluginDir = resolveTestPluginPackageDir(npmRoot, "required-peer-plugin"); + expect(fs.existsSync(path.join(npmProjectRoot, "node_modules", "openclaw"))).toBe(false); const lockfile = JSON.parse( - fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"), + fs.readFileSync(path.join(npmProjectRoot, "package-lock.json"), "utf8"), ) as { packages?: Record; }; expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined(); expect( - fs - .lstatSync( - path.join(npmRoot, "node_modules", "required-peer-plugin", "node_modules", "openclaw"), - ) - .isSymbolicLink(), + fs.lstatSync(path.join(requiredPeerPluginDir, "node_modules", "openclaw")).isSymbolicLink(), ).toBe(true); }, ); @@ -1041,9 +1075,13 @@ describe("installPluginFromNpmSpec", () => { it("repairs stale managed openclaw root packages before npm plugin installs", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); - fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true }); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "@openclaw/discord", + }); + fs.mkdirSync(path.join(npmProjectRoot, "node_modules", "openclaw"), { recursive: true }); fs.writeFileSync( - path.join(npmRoot, "package.json"), + path.join(npmProjectRoot, "package.json"), JSON.stringify( { private: true, @@ -1057,7 +1095,7 @@ describe("installPluginFromNpmSpec", () => { "utf-8", ); fs.writeFileSync( - path.join(npmRoot, "package-lock.json"), + path.join(npmProjectRoot, "package-lock.json"), `${JSON.stringify( { lockfileVersion: 3, @@ -1084,7 +1122,7 @@ describe("installPluginFromNpmSpec", () => { "utf-8", ); fs.writeFileSync( - path.join(npmRoot, "node_modules", "openclaw", "package.json"), + path.join(npmProjectRoot, "node_modules", "openclaw", "package.json"), JSON.stringify({ name: "openclaw", version: "2026.5.4", @@ -1109,13 +1147,13 @@ describe("installPluginFromNpmSpec", () => { }); expect(result.ok).toBe(true); - const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as { - dependencies?: Record; - }; + const manifest = JSON.parse( + fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"), + ) as { dependencies?: Record }; expect(manifest.dependencies).not.toHaveProperty("openclaw"); expect(manifest.dependencies?.["@openclaw/discord"]).toBe("2026.5.5-beta.1"); const lockfile = JSON.parse( - fs.readFileSync(path.join(npmRoot, "package-lock.json"), "utf8"), + fs.readFileSync(path.join(npmProjectRoot, "package-lock.json"), "utf8"), ) as { packages?: Record; dependencies?: Record; @@ -1190,12 +1228,26 @@ describe("installPluginFromNpmSpec", () => { }); expect(result.ok).toBe(true); - const manifest = JSON.parse(fs.readFileSync(path.join(npmRoot, "package.json"), "utf8")) as { - dependencies?: Record; - }; - expect(manifest.dependencies?.openclaw).toBe("2026.5.12-beta.6"); - expect(manifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBe("1.4.69"); + if (!result.ok) { + return; + } + const baseManifest = JSON.parse( + fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"), + ) as { dependencies?: Record }; + expect(baseManifest.dependencies?.openclaw).toBe("2026.5.12-beta.6"); + expect(baseManifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBeUndefined(); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "@xdarkicex/openclaw-memory-libravdb", + }); + const projectManifest = JSON.parse( + fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"), + ) as { dependencies?: Record }; + expect(projectManifest.dependencies?.["@xdarkicex/openclaw-memory-libravdb"]).toBe("1.4.69"); expect(fs.existsSync(hostPackageRoot)).toBe(true); + expect(result.targetDir).toBe( + resolveTestPluginPackageDir(npmRoot, "@xdarkicex/openclaw-memory-libravdb"), + ); expect( runCommandWithTimeoutMock.mock.calls.some( ([argv]) => @@ -1237,27 +1289,19 @@ describe("installPluginFromNpmSpec", () => { ), ), ).toBe(true); - expectNpmInstallIntoRoot({ + expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, + packageName: "dangerous-plugin", }); }); it("rolls back the managed npm root when npm install fails", async () => { const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); - const peerPluginDir = path.join(npmRoot, "node_modules", "peer-plugin"); - const peerLink = path.join(peerPluginDir, "node_modules", "openclaw"); - fs.mkdirSync(path.dirname(peerLink), { recursive: true }); - fs.writeFileSync( - path.join(peerPluginDir, "package.json"), - JSON.stringify({ - name: "peer-plugin", - version: "1.0.0", - peerDependencies: { openclaw: ">=2026.0.0" }, - }), - "utf8", - ); - fs.symlinkSync(suiteTempRootTracker.makeTempDir(), peerLink, "junction"); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "@openclaw/voice-call", + }); runCommandWithTimeoutMock.mockImplementation( async (argv: string[], options?: { cwd?: string }) => { if (JSON.stringify(argv) === JSON.stringify(npmViewArgv("@openclaw/voice-call@0.0.1"))) { @@ -1290,7 +1334,6 @@ describe("installPluginFromNpmSpec", () => { return successfulSpawn(); } if (isManagedNpmInstallCommand(argv)) { - fs.rmSync(peerLink, { recursive: true, force: true }); return { code: 1, stdout: "", @@ -1302,7 +1345,9 @@ describe("installPluginFromNpmSpec", () => { } if (argv[0] === "npm" && argv[1] === "uninstall") { if (!(argv as string[]).includes("--legacy-peer-deps")) { - fs.mkdirSync(path.join(npmRoot, "node_modules", "openclaw"), { recursive: true }); + fs.mkdirSync(path.join(options?.cwd ?? npmRoot, "node_modules", "openclaw"), { + recursive: true, + }); } return successfulSpawn(""); } @@ -1321,12 +1366,11 @@ describe("installPluginFromNpmSpec", () => { expect(result.error).toContain("registry unavailable"); } const manifest = JSON.parse( - await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"), ) as { dependencies?: Record }; expect(manifest.dependencies).toEqual({}); - expect(fs.lstatSync(peerLink).isSymbolicLink()).toBe(true); await expect( - fs.promises.access(path.join(npmRoot, "node_modules", "openclaw")), + fs.promises.access(path.join(npmProjectRoot, "node_modules", "openclaw")), ).rejects.toHaveProperty("code", "ENOENT"); }); @@ -1366,8 +1410,12 @@ describe("installPluginFromNpmSpec", () => { async (argv: string[], options?: { cwd?: string }) => { if (isManagedNpmInstallCommand(argv)) { installAttempts += 1; + const npmProjectRoot = options?.cwd; + if (!npmProjectRoot) { + throw new Error("expected npm install cwd"); + } const manifest = JSON.parse( - fs.readFileSync(path.join(npmRoot, "package.json"), "utf8"), + fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"), ) as { overrides?: Record; openclaw?: { managedOverrides?: string[] } }; if (installAttempts === 1) { expect(manifest.overrides?.["node-domexception"]).toBe( @@ -1431,9 +1479,13 @@ describe("installPluginFromNpmSpec", () => { }); expect(result.ok).toBe(false); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "dangerous-plugin"))).toBe(false); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "dangerous-plugin"))).toBe(false); + const npmProjectRoot = resolvePluginNpmProjectDir({ + npmDir: npmRoot, + packageName: "dangerous-plugin", + }); const manifest = JSON.parse( - await fs.promises.readFile(path.join(npmRoot, "package.json"), "utf8"), + await fs.promises.readFile(path.join(npmProjectRoot, "package.json"), "utf8"), ) as { dependencies?: Record }; expect(manifest.dependencies).toEqual({}); }); @@ -1489,7 +1541,7 @@ describe("installPluginFromNpmSpec", () => { return; } expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED); - expect(fs.existsSync(path.join(npmRoot, "node_modules", spec))).toBe(false); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, spec))).toBe(false); expect( warnings.some((warning) => warning.includes("allowed because it is an official OpenClaw package"), @@ -1529,9 +1581,10 @@ describe("installPluginFromNpmSpec", () => { } expect(result.pluginId).toBe(pluginId); expect(warnings.join("\n")).not.toContain("installation blocked"); - expectNpmInstallIntoRoot({ + expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, + packageName: spec, }); }, ); @@ -1548,7 +1601,7 @@ describe("installPluginFromNpmSpec", () => { it("rejects duplicate npm installs unless update mode is requested", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); - const installRoot = path.join(npmRoot, "node_modules", "@openclaw", "voice-call"); + const installRoot = resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call"); fs.mkdirSync(installRoot, { recursive: true }); mockNpmViewMetadataResult(runCommandWithTimeoutMock, { name: "@openclaw/voice-call", @@ -1578,7 +1631,7 @@ describe("installPluginFromNpmSpec", () => { it("allows duplicate npm installs in update mode", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); - const installRoot = path.join(npmRoot, "node_modules", "@openclaw", "voice-call"); + const installRoot = resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call"); fs.mkdirSync(installRoot, { recursive: true }); fs.writeFileSync(path.join(installRoot, "old.txt"), "old", "utf-8"); mockNpmViewAndInstall({ @@ -1602,9 +1655,10 @@ describe("installPluginFromNpmSpec", () => { } expect(result.targetDir).toBe(installRoot); expect(result.npmResolution?.version).toBe("0.0.2"); - expectNpmInstallIntoRoot({ + expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, + packageName: "@openclaw/voice-call", }); }); @@ -1644,12 +1698,13 @@ describe("installPluginFromNpmSpec", () => { }); expect(result2.ok).toBe(true); - expectNpmInstallIntoRoot({ + expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, + packageName: "@openclaw/whatsapp", }); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "voice-call"))).toBe(true); - expect(fs.existsSync(path.join(npmRoot, "node_modules", "@openclaw", "whatsapp"))).toBe(true); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/voice-call"))).toBe(true); + expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "@openclaw/whatsapp"))).toBe(true); }); it("aborts when integrity drift callback rejects the fetched artifact", async () => { @@ -1854,9 +1909,10 @@ describe("installPluginFromNpmSpec", () => { } expect(accepted.npmResolution?.version).toBe("0.0.2-beta.1"); expect(accepted.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1"); - expectNpmInstallIntoRoot({ + expectNpmInstallIntoProject({ calls: runCommandWithTimeoutMock.mock.calls, npmRoot, + packageName: "@openclaw/voice-call", }); }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 4300dbbf333..483fa896dd7 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -50,6 +50,7 @@ import { matchesExpectedPluginId, resolveDefaultPluginExtensionsDir, resolveDefaultPluginNpmDir, + resolvePluginNpmProjectDir, safePluginInstallFileName, validatePluginId, } from "./install-paths.js"; @@ -691,8 +692,12 @@ async function installPluginFromManagedNpmRoot( defaultLogger, ); const expectedPluginId = params.expectedPluginId; - const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); - const installRoot = path.join(npmRoot, "node_modules", params.packageName); + const npmBaseDir = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); + const npmRoot = resolvePluginNpmProjectDir({ + npmDir: npmBaseDir, + packageName: params.packageName, + }); + const installRoot = resolveManagedNpmRootPackageDir(npmRoot, params.packageName); const effectiveMode = await resolveEffectiveInstallMode({ runtime, requestedMode: mode, @@ -2079,7 +2084,11 @@ export async function installPluginFromNpmPackArchive( return packageNameResult; } const packageName = packageNameResult.packageName; - const npmRoot = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); + const npmBaseDir = params.npmDir ? resolveUserPath(params.npmDir) : resolveDefaultPluginNpmDir(); + const npmRoot = resolvePluginNpmProjectDir({ + npmDir: npmBaseDir, + packageName, + }); let dependencySpec = ""; if (!dryRun) { try { @@ -2113,7 +2122,7 @@ export async function installPluginFromNpmPackArchive( requestedSpecifier: `npm-pack:${metadataResult.archivePath}`, }, extensionsDir: params.extensionsDir, - npmDir: npmRoot, + npmDir: npmBaseDir, timeoutMs, logger, mode, diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index 5dbd7dbc5db..b0118dc0890 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -8,6 +8,7 @@ import { resolveInstalledPluginIndexStorePath, type InstalledPluginIndexStoreOptions, } from "./installed-plugin-index-store-path.js"; +import { listManagedPluginNpmProjectRootsSync } from "./npm-project-roots.js"; function cloneInstallRecords( records: Record | undefined, @@ -99,15 +100,14 @@ function resolveRecoveredManagedNpmPluginId(params: { return validatePluginId(pluginId) ? undefined : pluginId; } -function buildRecoveredManagedNpmInstallRecords( - options: InstalledPluginIndexStoreOptions = {}, +function buildRecoveredManagedNpmInstallRecordsForRoot( + npmRoot: string, ): Record { - const npmRoot = resolveRecoveredManagedNpmRoot(options); const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json")); const dependencies = readStringRecord(rootManifest?.dependencies); const records: Record = {}; for (const [packageName, dependencySpec] of Object.entries(dependencies)) { - const packageDir = path.join(npmRoot, "node_modules", packageName); + const packageDir = path.join(npmRoot, "node_modules", ...packageName.split("/")); let stat: fs.Stats; try { stat = fs.statSync(packageDir); @@ -137,6 +137,18 @@ function buildRecoveredManagedNpmInstallRecords( return records; } +function buildRecoveredManagedNpmInstallRecords( + options: InstalledPluginIndexStoreOptions = {}, +): Record { + const npmRoot = resolveRecoveredManagedNpmRoot(options); + const legacyRecords = buildRecoveredManagedNpmInstallRecordsForRoot(npmRoot); + const projectRecords: Record = {}; + for (const projectRoot of listManagedPluginNpmProjectRootsSync(npmRoot)) { + Object.assign(projectRecords, buildRecoveredManagedNpmInstallRecordsForRoot(projectRoot)); + } + return { ...legacyRecords, ...projectRecords }; +} + function recordsShareInstallPath( left: PluginInstallRecord | undefined, right: PluginInstallRecord, diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index 8756843d3c8..5fd4d384f3e 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -365,6 +365,28 @@ describe("plugin index install records store", () => { expectRecordFields(loadedSync.discord, { source: "npm", installPath: discordDir }); }); + it("still recovers legacy flat managed npm plugin records", async () => { + const stateDir = makeStateDir(); + const discordDir = writeManagedNpmPlugin({ + stateDir, + packageName: "@openclaw/discord", + pluginId: "discord", + version: "2026.5.2", + layout: "legacy", + }); + const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir }); + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, JSON.stringify({ installRecords: {}, plugins: [] }), "utf8"); + + const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir }); + expectRecordFields(loaded.discord, { + source: "npm", + spec: "@openclaw/discord@2026.5.2", + installPath: discordDir, + version: "2026.5.2", + }); + }); + it("keeps persisted install record metadata over recovered npm records", async () => { const stateDir = makeStateDir(); writeManagedNpmPlugin({ diff --git a/src/plugins/npm-project-roots.ts b/src/plugins/npm-project-roots.ts new file mode 100644 index 00000000000..8a3c37171f5 --- /dev/null +++ b/src/plugins/npm-project-roots.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { resolvePluginNpmProjectsDir } from "./install-paths.js"; + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +function sortPaths(paths: string[]): string[] { + return paths.toSorted((left, right) => left.localeCompare(right)); +} + +export function listManagedPluginNpmProjectRootsSync(npmRoot: string): string[] { + const projectsDir = resolvePluginNpmProjectsDir(npmRoot); + try { + return sortPaths( + fs + .readdirSync(projectsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(projectsDir, entry.name)), + ); + } catch (error) { + if (isMissing(error)) { + return []; + } + throw error; + } +} + +export async function listManagedPluginNpmProjectRoots(npmRoot: string): Promise { + const projectsDir = resolvePluginNpmProjectsDir(npmRoot); + try { + return sortPaths( + (await fsp.readdir(projectsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(projectsDir, entry.name)), + ); + } catch (error) { + if (isMissing(error)) { + return []; + } + throw error; + } +} + +export function listManagedPluginNpmRootsSync(npmRoot: string): string[] { + return [npmRoot, ...listManagedPluginNpmProjectRootsSync(npmRoot)]; +} + +export async function listManagedPluginNpmRoots(npmRoot: string): Promise { + return [npmRoot, ...(await listManagedPluginNpmProjectRoots(npmRoot))]; +} diff --git a/src/plugins/plugin-metadata-snapshot.memo.test.ts b/src/plugins/plugin-metadata-snapshot.memo.test.ts index 4dde7e117e1..60b0ee713d3 100644 --- a/src/plugins/plugin-metadata-snapshot.memo.test.ts +++ b/src/plugins/plugin-metadata-snapshot.memo.test.ts @@ -660,6 +660,42 @@ describe("loadPluginMetadataSnapshot process memo", () => { expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); }); + it("keeps npm project package fingerprints stable across directory order changes", () => { + const stateDir = tempStateDir(); + const projectsDir = path.join(stateDir, "npm", "projects"); + writeJson(path.join(projectsDir, "zeta", "package.json"), { name: "zeta", version: "1.0.0" }); + writeJson(path.join(projectsDir, "alpha", "package.json"), { name: "alpha", version: "1.0.0" }); + touchPersistedIndex(stateDir); + loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: makeIndex(), + diagnostics: [], + }); + const originalReaddirSync = fs.readdirSync.bind(fs); + let reverseProjectEntries = false; + const readdirSpy = vi.spyOn(fs, "readdirSync").mockImplementation((( + directoryPath: fs.PathLike, + options?: Parameters[1], + ): unknown => { + const entries = originalReaddirSync(directoryPath, options as never); + if (directoryPath === projectsDir && reverseProjectEntries && Array.isArray(entries)) { + return entries.toReversed(); + } + return entries; + }) as unknown as typeof fs.readdirSync); + + try { + loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); + reverseProjectEntries = true; + loadPluginMetadataSnapshot({ config: {}, env: {}, stateDir }); + } finally { + readdirSpy.mockRestore(); + } + + expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce(); + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); + }); + it("requires reload before an in-root package manifest symlink target change is visible", () => { const stateDir = tempStateDir(); const pluginDir = path.join(stateDir, "extensions", "demo"); diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index c1b5363e9c6..7fb6e0cce25 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -10,7 +10,7 @@ import { isRecord } from "../shared/record-coerce.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; -import { resolveDefaultPluginNpmDir } from "./install-paths.js"; +import { resolveDefaultPluginNpmDir, resolvePluginNpmProjectsDir } from "./install-paths.js"; import { hashJson } from "./installed-plugin-index-hash.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; import { resolveInstalledPluginIndexStorePath } from "./installed-plugin-index-store-path.js"; @@ -92,6 +92,22 @@ function fileFingerprint(filePath: string): unknown { } } +function directoryChildPackageJsonFingerprint(directoryPath: string): unknown { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + } catch { + return [directoryPath, "missing"]; + } + return [ + directoryPath, + ...entries + .filter((entry) => entry.isDirectory()) + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map((entry) => fileFingerprint(path.join(directoryPath, entry.name, "package.json"))), + ]; +} + function readJsonObject(filePath: string): Record | undefined { try { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); @@ -194,6 +210,9 @@ function resolvePersistedRegistryFastMemoFingerprint(params: { return { index: fileFingerprint(indexPath), npmPackageJson: fileFingerprint(path.join(npmRoot, "package.json")), + npmProjectPackageJsons: directoryChildPackageJsonFingerprint( + resolvePluginNpmProjectsDir(npmRoot), + ), }; } diff --git a/src/plugins/test-helpers/managed-npm-plugin.ts b/src/plugins/test-helpers/managed-npm-plugin.ts index 6a0d20e4c5c..e8c5f938506 100644 --- a/src/plugins/test-helpers/managed-npm-plugin.ts +++ b/src/plugins/test-helpers/managed-npm-plugin.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { resolvePluginNpmProjectDir } from "../install-paths.js"; export function writeManagedNpmPlugin(params: { stateDir: string; @@ -8,8 +9,16 @@ export function writeManagedNpmPlugin(params: { version: string; name?: string; dependencySpec?: string; + layout?: "project" | "legacy"; }): string { - const npmRoot = path.join(params.stateDir, "npm"); + const npmBaseDir = path.join(params.stateDir, "npm"); + const npmRoot = + params.layout === "legacy" + ? npmBaseDir + : resolvePluginNpmProjectDir({ + npmDir: npmBaseDir, + packageName: params.packageName, + }); const rootManifestPath = path.join(npmRoot, "package.json"); fs.mkdirSync(npmRoot, { recursive: true }); const rootManifest = fs.existsSync(rootManifestPath) @@ -34,7 +43,7 @@ export function writeManagedNpmPlugin(params: { "utf8", ); - const packageDir = path.join(npmRoot, "node_modules", params.packageName); + const packageDir = path.join(npmRoot, "node_modules", ...params.packageName.split("/")); fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true }); fs.writeFileSync( path.join(packageDir, "package.json"), diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index b2381e748b1..0506d397a0a 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { toRepoRelativePath } from "../test-utils/repo-files.js"; +import { resolvePluginNpmProjectDir } from "./install-paths.js"; import { resolvePluginInstallDir } from "./install.js"; import { cleanupTrackedTempDirsAsync, @@ -1024,6 +1025,71 @@ describe("uninstallPlugin", () => { await expectPathAccessState(pluginDir, "missing"); }); + it("uninstalls per-plugin npm project packages through their project root", async () => { + const stateDir = path.join(tempDir, "state"); + const extensionsDir = path.join(stateDir, "extensions"); + const npmBaseDir = path.join(stateDir, "npm"); + const npmRoot = resolvePluginNpmProjectDir({ + npmDir: npmBaseDir, + packageName: "@openclaw/kitchen-sink", + }); + const pluginDir = path.join(npmRoot, "node_modules", "@openclaw", "kitchen-sink"); + const hoistedDir = path.join(npmRoot, "node_modules", "is-number"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(hoistedDir, { recursive: true }); + await fs.writeFile( + path.join(npmRoot, "package.json"), + `${JSON.stringify( + { + private: true, + dependencies: { + "@openclaw/kitchen-sink": "1.0.0", + "is-number": "7.0.0", + }, + }, + null, + 2, + )}\n`, + ); + 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, + }, + }, + }), + pluginId: "openclaw-kitchen-sink-fixture", + deleteFiles: true, + extensionsDir, + }); + + expect(plan.ok).toBe(true); + if (!plan.ok) { + throw new Error(plan.error); + } + expect(plan.directoryRemoval).toEqual({ + target: pluginDir, + cleanup: { + kind: "npm", + npmRoot, + packageName: "@openclaw/kitchen-sink", + }, + }); + + const applied = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + + expect(applied).toEqual({ directoryRemoved: true, warnings: [] }); + expectNpmUninstallCommand({ packageName: "@openclaw/kitchen-sink", npmRoot }); + await expectPathAccessState(pluginDir, "missing"); + }); + it("repairs remaining npm plugin openclaw peer links after npm uninstall prunes them", async () => { const stateDir = path.join(tempDir, "state"); const npmRoot = path.join(stateDir, "npm"); @@ -1719,6 +1785,29 @@ describe("resolveUninstallDirectoryTarget", () => { ).toBe(installPath); }); + it("uses configured installPath when npm installed it under a managed npm project", () => { + const stateDir = path.join(os.tmpdir(), "openclaw-uninstall-safe"); + const extensionsDir = path.join(stateDir, "extensions"); + const npmRoot = resolvePluginNpmProjectDir({ + npmDir: path.join(stateDir, "npm"), + packageName: "@openclaw/kitchen-sink", + }); + const installPath = path.join(npmRoot, "node_modules", "@openclaw", "kitchen-sink"); + + expect( + resolveUninstallDirectoryTarget({ + pluginId: "openclaw-kitchen-sink-fixture", + hasInstall: true, + installRecord: { + source: "npm", + spec: "@openclaw/kitchen-sink@latest", + installPath, + }, + extensionsDir, + }), + ).toBe(installPath); + }); + it("uses configured installPath when git installed it under the managed git root", () => { const stateDir = path.join(os.tmpdir(), "openclaw-uninstall-safe"); const extensionsDir = path.join(stateDir, "extensions"); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts index 40b963a1376..8cca492dc29 100644 --- a/src/plugins/uninstall.ts +++ b/src/plugins/uninstall.ts @@ -14,6 +14,7 @@ import { resolveDefaultPluginGitDir, resolveDefaultPluginNpmDir, resolvePluginInstallDir, + resolvePluginNpmProjectsDir, } from "./install-paths.js"; import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "./plugin-peer-link.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -211,10 +212,44 @@ function resolveNpmManagedInstall(params: { const packageName = resolveNpmPackageNameFromInstallPath({ installPath, nodeModulesRoot }); return packageName ? { installPath, npmRoot, packageName } : null; } + const projectMatch = resolveNpmManagedProjectInstall({ + installPath, + projectsDir: resolvePluginNpmProjectsDir(npmRoot), + }); + if (projectMatch) { + return projectMatch; + } } return null; } +function resolveNpmManagedProjectInstall(params: { + installPath: string; + projectsDir: string; +}): { installPath: string; npmRoot: string; packageName: string } | null { + if ( + !isPathInsideOrEqual(params.projectsDir, params.installPath) || + resolveComparablePath(params.projectsDir) === resolveComparablePath(params.installPath) + ) { + return null; + } + const relativePath = path.relative( + path.resolve(params.projectsDir), + path.resolve(params.installPath), + ); + const segments = relativePath.split(path.sep).filter(Boolean); + if (segments.length < 3 || segments[1] !== "node_modules") { + return null; + } + const npmRoot = path.join(params.projectsDir, segments[0] ?? ""); + const nodeModulesRoot = path.join(npmRoot, "node_modules"); + const packageName = resolveNpmPackageNameFromInstallPath({ + installPath: params.installPath, + nodeModulesRoot, + }); + return packageName ? { installPath: params.installPath, npmRoot, packageName } : null; +} + function resolveNpmPackageNameFromInstallPath(params: { installPath: string; nodeModulesRoot: string; diff --git a/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts b/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts index 893c81755e5..b3b9c09d32e 100644 --- a/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts +++ b/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts @@ -242,4 +242,36 @@ exit 1 rmSync(root, { recursive: true, force: true }); } }); + + it("handles missing toolcache roots under nounset", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-")); + try { + const result = spawnSync( + "bash", + [ + "-c", + [ + "set -euo pipefail", + `source "${ensureNodeScript}"`, + `openclaw_find_toolcache_node "99.99.99"`, + ].join("; "), + ], + { + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + RUNNER_TOOL_CACHE: join(root, "missing-toolcache"), + AGENT_TOOLSDIRECTORY: join(root, "missing-agent-tools"), + ACTIONS_RUNNER_TOOL_CACHE: join(root, "missing-actions-cache"), + OPENCLAW_CONTAINER_TOOL_CACHE: join(root, "missing-container-cache"), + }, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).not.toContain("unbound variable"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); });