fix: isolate npm plugin installs per package (#87647)

* fix: isolate npm plugin installs per package

* test: assert isolated npm plugin projects in upgrade survivor

* test: assert plugin lifecycle npm project roots

* test: resolve npm project deps in live assertions

* fix: resolve codex bins from isolated npm projects

* docs: document isolated npm plugin projects

* ci: configure testbox workflow for crabbox

* fix: stabilize npm project fingerprint

* fix: keep fetch runtime import side-effect free

* test: keep dynamic live model unit hermetic

* ci: handle empty node toolcache roots

* test: make nounset toolcache probe deterministic
This commit is contained in:
Peter Steinberger 2026-05-28 21:16:07 +01:00 committed by GitHub
parent 2b587be44d
commit ea682182d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 981 additions and 295 deletions

View file

@ -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

View file

@ -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

View file

@ -169,7 +169,7 @@ is available, then fall back to `latest`.
<Accordion title="Hook packs and npm specs">
`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:<package>` 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:<path.tgz>` 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.
<Warning>
`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.

View file

@ -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.

View file

@ -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

View file

@ -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/<encoded-package>`
- 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/<encoded-package>
npm install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund
```
`openclaw plugins install npm-pack:<path.tgz>` 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:<path.tgz>` 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/<id>` 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.

View file

@ -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

View file

@ -534,7 +534,7 @@ openclaw plugins install <package-name>
```
<Info>
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.
</Info>
<Note>

View file

@ -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");

View file

@ -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(

View file

@ -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),
];

View file

@ -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");
}

View file

@ -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));

View file

@ -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;

View file

@ -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"

View file

@ -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),

View file

@ -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: <T>(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<typeof appendPrioritizedDynamicLiveModels>[0]["prepareDynamicModel"]
>;
type DynamicModelNormalizer = NonNullable<
Parameters<typeof appendPrioritizedDynamicLiveModels>[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: {

View file

@ -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).",
]);

View file

@ -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: [],
};

View file

@ -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<string, string>;
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");

View file

@ -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<string, unknown>, 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<boolean> {
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(

View file

@ -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,
}));

View file

@ -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: {

View file

@ -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<Parameters<typeof createAmbientNodeProxyAgent>[0]>;
type ProxylineCreateAmbientNodeProxyAgent =
typeof import("@openclaw/proxyline").createAmbientNodeProxyAgent;
type ProxylineAgentOptions = NonNullable<Parameters<ProxylineCreateAmbientNodeProxyAgent>[0]>;
type ProxylineEnvSnapshot = NonNullable<ProxylineAgentOptions["env"]>;
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 } : {}),

View file

@ -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,

View file

@ -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<string> {
return dir;
}
function pluginNpmProjectRoot(npmRoot: string, packageName: string): string {
return resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
}
async function packPlugin(params: {
dependencies?: Record<string, string>;
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<string, unknown>;
};
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<string, string>;
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<string, string>;
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<string, string>;
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<string, string>;
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<string, string>;
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<string, unknown>;
};
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<string, unknown>;
};
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<string, string>;
};
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<string, unknown>;
};
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<string, string>;
};
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<string, { integrity?: string; version?: string }>;
};
const installedLockEntry = lock.packages?.[`node_modules/${packageName}`];

View file

@ -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<string, string> };
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<string, string> };
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<string, string> };
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<string, unknown>;
};
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<string, string>;
};
const manifest = JSON.parse(
fs.readFileSync(path.join(npmProjectRoot, "package.json"), "utf8"),
) as { dependencies?: Record<string, string> };
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<string, unknown>;
dependencies?: Record<string, unknown>;
@ -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<string, string>;
};
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<string, string> };
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<string, string> };
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<string, string> };
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<string, unknown>; 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<string, string> };
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",
});
});
});

View file

@ -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,

View file

@ -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<string, PluginInstallRecord> | undefined,
@ -99,15 +100,14 @@ function resolveRecoveredManagedNpmPluginId(params: {
return validatePluginId(pluginId) ? undefined : pluginId;
}
function buildRecoveredManagedNpmInstallRecords(
options: InstalledPluginIndexStoreOptions = {},
function buildRecoveredManagedNpmInstallRecordsForRoot(
npmRoot: string,
): Record<string, PluginInstallRecord> {
const npmRoot = resolveRecoveredManagedNpmRoot(options);
const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json"));
const dependencies = readStringRecord(rootManifest?.dependencies);
const records: Record<string, PluginInstallRecord> = {};
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<string, PluginInstallRecord> {
const npmRoot = resolveRecoveredManagedNpmRoot(options);
const legacyRecords = buildRecoveredManagedNpmInstallRecordsForRoot(npmRoot);
const projectRecords: Record<string, PluginInstallRecord> = {};
for (const projectRoot of listManagedPluginNpmProjectRootsSync(npmRoot)) {
Object.assign(projectRecords, buildRecoveredManagedNpmInstallRecordsForRoot(projectRoot));
}
return { ...legacyRecords, ...projectRecords };
}
function recordsShareInstallPath(
left: PluginInstallRecord | undefined,
right: PluginInstallRecord,

View file

@ -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({

View file

@ -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<string[]> {
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<string[]> {
return [npmRoot, ...(await listManagedPluginNpmProjectRoots(npmRoot))];
}

View file

@ -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<typeof fs.readdirSync>[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");

View file

@ -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<string, unknown> | 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),
),
};
}

View file

@ -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"),

View file

@ -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");

View file

@ -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;

View file

@ -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 });
}
});
});