diff --git a/.npmrc b/.npmrc
index bdf24a6c276..49ef47513d9 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,4 +1,2 @@
-# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies.
-# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker.
-# Keep the workspace on a hoisted layout so pnpm check/build stay stable.
-node-linker=hoisted
+# pnpm v11 reads project settings from pnpm-workspace.yaml.
+# Keep this file for registry/auth-only npmrc entries so Docker COPY steps stay stable.
diff --git a/docs/ci.md b/docs/ci.md
index 303334b9369..bb189dc7295 100644
--- a/docs/ci.md
+++ b/docs/ci.md
@@ -277,7 +277,7 @@ Package Acceptance has bounded legacy-compatibility windows for already-publishe
- known private QA entries in `dist/postinstall-inventory.json` may point at tarball-omitted files;
- `doctor-switch` may skip the `gateway install --wrapper` persistence subcase when the package does not expose that flag;
-- `update-channel-switch` may prune missing `pnpm.patchedDependencies` from the tarball-derived fake git fixture and may log missing persisted `update.channel`;
+- `update-channel-switch` may prune missing pnpm `patchedDependencies` from the tarball-derived fake git fixture and may log missing persisted `update.channel`;
- plugin smokes may read legacy install-record locations or accept missing marketplace install-record persistence;
- `plugin-update` may allow config metadata migration while still requiring the install record and no-reinstall behavior to stay unchanged.
diff --git a/docs/cli/update.md b/docs/cli/update.md
index 47780f80cb2..49022115b98 100644
--- a/docs/cli/update.md
+++ b/docs/cli/update.md
@@ -158,7 +158,7 @@ manually.
Rebases onto the selected commit (dev only).
- Uses the repo package manager. For pnpm checkouts, the updater bootstraps `pnpm` on demand (via `corepack` first, then a temporary `npm install pnpm@10` fallback) instead of running `npm run build` inside a pnpm workspace.
+ Uses the repo package manager. For pnpm checkouts, the updater bootstraps `pnpm` on demand (via `corepack` first, then a temporary `npm install pnpm@11` fallback) instead of running `npm run build` inside a pnpm workspace.
Builds the gateway and the Control UI.
diff --git a/extensions/acpx/AGENTS.md b/extensions/acpx/AGENTS.md
index 84c291e258f..ca759c84738 100644
--- a/extensions/acpx/AGENTS.md
+++ b/extensions/acpx/AGENTS.md
@@ -18,14 +18,14 @@ Use this flow when OpenClaw needs unreleased ACPX changes before the ACPX versio
1. Make the ACPX code change in the `openclaw/acpx` repo first.
2. In OpenClaw, temporarily point `extensions/acpx/package.json` at the ACPX GitHub commit you need.
-3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx` to `onlyBuiltDependencies` in both `package.json` and `pnpm-workspace.yaml`.
+3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx: true` to `allowBuilds` in `pnpm-workspace.yaml`.
4. Refresh the root workspace lock:
- `pnpm install --lockfile-only --filter ./extensions/acpx`
5. Refresh the extension-local npm lock for install metadata:
- `cd extensions/acpx && npm install --package-lock-only --ignore-scripts`
6. Rebuild OpenClaw and restart the gateway before doing live ACP validation.
7. Once ACPX is released, switch `extensions/acpx/package.json` back to the published npm version and refresh the same lockfiles again.
-8. Remove any temporary `acpx` build-script allowlist entries that were only needed for the GitHub-sourced development pin.
+8. Remove any temporary `acpx` build-script allowlist entry that was only needed for the GitHub-sourced development pin.
## Lockfile Notes
diff --git a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts
index 83c268b3f73..5efcd8c88eb 100644
--- a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts
+++ b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts
@@ -489,7 +489,7 @@ qa_status=0
{
set -e
echo "remote pwd: $(pwd)"
- sudo corepack enable || sudo npm install -g pnpm@10.33.2
+ sudo corepack enable || sudo npm install -g pnpm@11
if [ "$hydrate_mode" = "source" ]; then
if ! command -v make >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
sudo apt-get update -y >>"$out/apt.log" 2>&1 || true
diff --git a/package.json b/package.json
index bcc6fbed1b4..46597e62b57 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"CHANGELOG.md",
"LICENSE",
"openclaw.mjs",
+ "pnpm-workspace.yaml",
"README.md",
"dist/",
"!dist/.buildstamp",
@@ -1814,70 +1815,5 @@
"engines": {
"node": ">=22.16.0"
},
- "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
- "pnpm": {
- "overrides": {
- "@anthropic-ai/sdk": "0.95.1",
- "hono": "4.12.18",
- "@hono/node-server": "1.19.14",
- "@aws-sdk/client-bedrock-runtime": "3.1045.0",
- "axios": "1.16.0",
- "fast-uri": "3.1.2",
- "follow-redirects": "1.16.0",
- "defu": "6.1.5",
- "fast-xml-parser": "5.7.0",
- "request": "npm:@cypress/request@3.0.10",
- "request-promise": "npm:@cypress/request-promise@5.0.0",
- "basic-ftp": "6.0.1",
- "file-type": "22.0.1",
- "form-data": "2.5.4",
- "ip-address": "10.2.0",
- "minimatch": "10.2.5",
- "path-to-regexp": "8.4.0",
- "qs": "6.14.2",
- "node-domexception": "npm:@nolyfill/domexception@1.0.28",
- "typebox": "1.1.38",
- "tar": "7.5.15",
- "tough-cookie": "4.1.3",
- "yauzl": "3.2.1",
- "protobufjs": "7.5.5",
- "uuid": "14.0.0"
- },
- "onlyBuiltDependencies": [
- "@openclaw/fs-safe",
- "@google/genai",
- "@lydell/node-pty",
- "@matrix-org/matrix-sdk-crypto-nodejs",
- "@tloncorp/api",
- "@tloncorp/tlon-skill",
- "baileys",
- "@whiskeysockets/libsignal-node",
- "authenticate-pam",
- "esbuild",
- "node-llama-cpp",
- "protobufjs",
- "sharp"
- ],
- "ignoredBuiltDependencies": [
- "@discordjs/opus",
- "koffi",
- "tree-sitter-bash"
- ],
- "packageExtensions": {
- "@mariozechner/pi-coding-agent": {
- "dependencies": {
- "strip-ansi": "^7.2.0"
- }
- }
- },
- "peerDependencyRules": {
- "allowedVersions": {
- "prism-media>opusscript": "^0.0.8 || ^0.1.1"
- }
- },
- "patchedDependencies": {
- "baileys@7.0.0-rc10": "patches/baileys@7.0.0-rc10.patch",
- "@agentclientprotocol/claude-agent-acp@0.33.1": "patches/@agentclientprotocol__claude-agent-acp@0.33.1.patch"
- }
- }
+ "packageManager": "pnpm@11.0.8+sha512.4c4097e1dd2d42372c4e7fa5a791ff28fc75a484c7ac192e64b1df0fdef17594ba982f9b4fed9adfb3c757846f565b799b2763fb3733d1de1bcb82cf46684912"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24c33340c8d..37715d4ee6c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3360,7 +3360,7 @@ packages:
os: [win32]
'@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/c7ccb99d3058f2acf2ad2758ad2470c7e113a53c':
- resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/c7ccb99d3058f2acf2ad2758ad2470c7e113a53c}
+ resolution: {gitHosted: true, tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/c7ccb99d3058f2acf2ad2758ad2470c7e113a53c}
version: 0.2.0
engines: {node: '>=20.11'}
@@ -4848,7 +4848,7 @@ packages:
resolution: {integrity: sha512-58rWEqDGg+CKCyEeKm2KoxxSwTWtHh/NLTW9ObR4K8CGF6VwuuGudEI1CtniS/oSRmL1nJq/eh8MKARiluw4DQ==}
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
- resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
+ resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
version: 2.0.1
'@zed-industries/codex-acp-darwin-arm64@0.14.0':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 94303c4d6db..ba001af66cc 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -32,22 +32,63 @@ minimumReleaseAgeExclude:
- "sqlite-vec"
- "sqlite-vec-*"
-onlyBuiltDependencies:
- - "@openclaw/fs-safe"
- - "@google/genai"
- - "@lydell/node-pty"
- - "@matrix-org/matrix-sdk-crypto-nodejs"
- - "@napi-rs/canvas"
- - "@tloncorp/api"
- - "baileys"
- - "@whiskeysockets/libsignal-node"
- - authenticate-pam
- - esbuild
- - node-llama-cpp
- - protobufjs
- - sharp
+nodeLinker: hoisted
-ignoredBuiltDependencies:
- - "@discordjs/opus"
- - koffi
- - tree-sitter-bash
+overrides:
+ "@anthropic-ai/sdk": 0.95.1
+ hono: 4.12.18
+ "@hono/node-server": 1.19.14
+ "@aws-sdk/client-bedrock-runtime": 3.1045.0
+ axios: 1.16.0
+ fast-uri: 3.1.2
+ follow-redirects: 1.16.0
+ defu: 6.1.5
+ fast-xml-parser: 5.7.0
+ request: "npm:@cypress/request@3.0.10"
+ request-promise: "npm:@cypress/request-promise@5.0.0"
+ basic-ftp: 6.0.1
+ file-type: 22.0.1
+ form-data: 2.5.4
+ ip-address: 10.2.0
+ minimatch: 10.2.5
+ path-to-regexp: 8.4.0
+ qs: 6.14.2
+ node-domexception: "npm:@nolyfill/domexception@1.0.28"
+ typebox: 1.1.38
+ tar: 7.5.15
+ tough-cookie: 4.1.3
+ yauzl: 3.2.1
+ protobufjs: 7.5.5
+ uuid: 14.0.0
+
+allowBuilds:
+ "@openclaw/fs-safe": true
+ "@google/genai": true
+ "@lydell/node-pty": true
+ "@matrix-org/matrix-sdk-crypto-nodejs": true
+ "@napi-rs/canvas": true
+ "@tloncorp/api": true
+ "@tloncorp/tlon-skill": true
+ baileys: true
+ "@whiskeysockets/libsignal-node": true
+ authenticate-pam: true
+ "@discordjs/opus": false
+ esbuild: true
+ koffi: false
+ node-llama-cpp: true
+ protobufjs: true
+ sharp: true
+ tree-sitter-bash: false
+
+packageExtensions:
+ "@mariozechner/pi-coding-agent":
+ dependencies:
+ strip-ansi: ^7.2.0
+
+peerDependencyRules:
+ allowedVersions:
+ "prism-media>opusscript": "^0.0.8 || ^0.1.1"
+
+patchedDependencies:
+ "baileys@7.0.0-rc10": "patches/baileys@7.0.0-rc10.patch"
+ "@agentclientprotocol/claude-agent-acp@0.33.1": "patches/@agentclientprotocol__claude-agent-acp@0.33.1.patch"
diff --git a/scripts/e2e/lib/update-channel-switch/assertions.mjs b/scripts/e2e/lib/update-channel-switch/assertions.mjs
index 5e9c938fcce..611628e8d79 100644
--- a/scripts/e2e/lib/update-channel-switch/assertions.mjs
+++ b/scripts/e2e/lib/update-channel-switch/assertions.mjs
@@ -16,6 +16,80 @@ function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
+// Runs inside the bare Docker E2E image, before package dependencies are installed.
+// Keep this to the small pnpm-workspace.yaml surface the fixture mutates.
+function findTopLevelBlock(lines, key) {
+ const start = lines.findIndex((line) => new RegExp(`^${key}:\\s*(?:#.*)?$`).test(line));
+ if (start === -1) {
+ return null;
+ }
+ let end = start + 1;
+ while (end < lines.length && !/^[A-Za-z0-9_-]+:\s*/.test(lines[end])) {
+ end += 1;
+ }
+ return { start, end };
+}
+
+function parseYamlScalar(raw) {
+ const trimmed = raw.trim();
+ const withoutComment = trimmed.replace(/\s+#.*$/, "");
+ if (withoutComment.startsWith('"') && withoutComment.endsWith('"')) {
+ return withoutComment.slice(1, -1);
+ }
+ if (withoutComment.startsWith("'") && withoutComment.endsWith("'")) {
+ return withoutComment.slice(1, -1);
+ }
+ return withoutComment;
+}
+
+function readWorkspacePatchedDependencies(file) {
+ const lines = fs.readFileSync(file, "utf8").split("\n");
+ const block = findTopLevelBlock(lines, "patchedDependencies");
+ if (!block) {
+ return { patches: undefined };
+ }
+
+ const patches = {};
+ for (const line of lines.slice(block.start + 1, block.end)) {
+ const match = line.match(/^\s+(.+?):\s+(.+?)\s*$/);
+ if (!match) {
+ continue;
+ }
+ patches[parseYamlScalar(match[1])] = parseYamlScalar(match[2]);
+ }
+ return { patches };
+}
+
+function writeWorkspacePnpmConfig(file, keptPatches) {
+ const original = fs.readFileSync(file, "utf8");
+ const hadTrailingNewline = original.endsWith("\n");
+ const lines = original.replace(/\n$/, "").split("\n");
+ const patchBlock = findTopLevelBlock(lines, "patchedDependencies");
+
+ if (patchBlock) {
+ const nextLines = [];
+ nextLines.push(...lines.slice(0, patchBlock.start));
+ if (Object.keys(keptPatches).length > 0) {
+ nextLines.push("patchedDependencies:");
+ for (const [dependency, patchFile] of Object.entries(keptPatches)) {
+ nextLines.push(` ${JSON.stringify(dependency)}: ${JSON.stringify(patchFile)}`);
+ }
+ }
+ nextLines.push(...lines.slice(patchBlock.end));
+ lines.length = 0;
+ lines.push(...nextLines);
+ }
+
+ const allowUnusedIndex = lines.findIndex((line) => /^allowUnusedPatches:\s*/.test(line));
+ if (allowUnusedIndex === -1) {
+ lines.push("allowUnusedPatches: true");
+ } else {
+ lines[allowUnusedIndex] = "allowUnusedPatches: true";
+ }
+
+ fs.writeFileSync(file, `${lines.join("\n")}${hadTrailingNewline ? "\n" : ""}`);
+}
+
function writeControlUi(root) {
const file = path.join(root, "dist", "control-ui", "index.html");
fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -25,31 +99,41 @@ function writeControlUi(root) {
function prepareGitFixture(root) {
const packageJsonPath = path.join(root, "package.json");
const packageJson = readJson(packageJsonPath);
- packageJson.pnpm = { ...packageJson.pnpm, allowUnusedPatches: true };
- const patches = packageJson.pnpm.patchedDependencies;
+ const pnpmWorkspacePath = path.join(root, "pnpm-workspace.yaml");
+ const workspaceConfig = fs.existsSync(pnpmWorkspacePath)
+ ? readWorkspacePatchedDependencies(pnpmWorkspacePath)
+ : undefined;
+ const pnpmConfig = workspaceConfig ? {} : { ...packageJson.pnpm };
+ const patches = workspaceConfig?.patches ?? pnpmConfig.patchedDependencies;
+ const keptPatches = {};
if (patches && typeof patches === "object" && !Array.isArray(patches)) {
- const kept = {};
const missing = [];
for (const [dependency, patchFile] of Object.entries(patches)) {
const exists =
typeof patchFile === "string" &&
fs.existsSync(path.resolve(path.dirname(packageJsonPath), patchFile));
if (exists) {
- kept[dependency] = patchFile;
+ keptPatches[dependency] = patchFile;
} else {
missing.push(`${dependency} -> ${String(patchFile)}`);
}
}
if (missing.length > 0 && !legacyPackageAcceptanceCompat(packageJson.version)) {
throw new Error(
- `package ${packageJson.version} has missing pnpm.patchedDependencies in package fixture: ${missing.join(", ")}`,
+ `package ${packageJson.version} has missing pnpm patchedDependencies in package fixture: ${missing.join(", ")}`,
);
}
- if (Object.keys(kept).length > 0) {
- packageJson.pnpm.patchedDependencies = kept;
+ }
+ if (workspaceConfig) {
+ writeWorkspacePnpmConfig(pnpmWorkspacePath, keptPatches);
+ } else {
+ pnpmConfig.allowUnusedPatches = true;
+ if (Object.keys(keptPatches).length > 0) {
+ pnpmConfig.patchedDependencies = keptPatches;
} else {
- delete packageJson.pnpm.patchedDependencies;
+ delete pnpmConfig.patchedDependencies;
}
+ packageJson.pnpm = pnpmConfig;
}
const fixtureUiBuildSource = `const fs=require("node:fs");fs.mkdirSync("dist/control-ui",{recursive:true});fs.writeFileSync("dist/control-ui/index.html",${JSON.stringify(controlUiHtml)})`;
packageJson.scripts = {
diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts
index 388cf0ebd55..532899952c4 100755
--- a/scripts/e2e/parallels/macos-smoke.ts
+++ b/scripts/e2e/parallels/macos-smoke.ts
@@ -851,7 +851,7 @@ fi
echo "bootstrap-pnpm: install"
rm -rf "$bootstrap_root"
mkdir -p "$bootstrap_root"
-/opt/homebrew/bin/node /opt/homebrew/bin/npm install --prefix "$bootstrap_root" --no-save pnpm@10
+/opt/homebrew/bin/node /opt/homebrew/bin/npm install --prefix "$bootstrap_root" --no-save pnpm@11
"$bootstrap_bin/pnpm" --version`);
}
diff --git a/scripts/install.sh b/scripts/install.sh
index c8cefabe68f..4932b356c84 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1839,7 +1839,7 @@ ensure_pnpm() {
if command -v corepack &> /dev/null; then
ui_info "Configuring pnpm via Corepack"
corepack enable >/dev/null 2>&1 || true
- if ! run_quiet_step "Activating pnpm" corepack prepare pnpm@10 --activate; then
+ if ! run_quiet_step "Activating pnpm" corepack prepare pnpm@11 --activate; then
ui_warn "Corepack pnpm activation failed; falling back"
fi
refresh_shell_command_cache
@@ -1854,7 +1854,7 @@ ensure_pnpm() {
ui_info "Installing pnpm via npm"
fix_npm_permissions
- run_quiet_step "Installing pnpm" npm install -g pnpm@10
+ run_quiet_step "Installing pnpm" npm install -g pnpm@11
refresh_shell_command_cache
if detect_pnpm_cmd && pnpm_cmd_is_ready; then
ui_success "pnpm ready ($(pnpm_cmd_pretty))"
@@ -1873,7 +1873,7 @@ ensure_pnpm_binary_for_scripts() {
if command -v corepack >/dev/null 2>&1; then
ui_info "Ensuring pnpm command is available"
corepack enable >/dev/null 2>&1 || true
- corepack prepare pnpm@10 --activate >/dev/null 2>&1 || true
+ corepack prepare pnpm@11 --activate >/dev/null 2>&1 || true
refresh_shell_command_cache
if command -v pnpm >/dev/null 2>&1; then
ui_success "pnpm command enabled via Corepack"
@@ -1899,7 +1899,7 @@ EOF
fi
ui_error "pnpm command not available on PATH"
- ui_info "Install pnpm globally (npm install -g pnpm@10) and retry"
+ ui_info "Install pnpm globally (npm install -g pnpm@11) and retry"
return 1
}
diff --git a/src/commands/doctor-install.test.ts b/src/commands/doctor-install.test.ts
new file mode 100644
index 00000000000..672158a67c5
--- /dev/null
+++ b/src/commands/doctor-install.test.ts
@@ -0,0 +1,48 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { note } from "../terminal/note.js";
+import { withTempDir } from "../test-helpers/temp-dir.js";
+import { noteSourceInstallIssues } from "./doctor-install.js";
+
+vi.mock("../terminal/note.js", () => ({
+ note: vi.fn(),
+}));
+
+async function writeFile(root: string, relativePath: string, content = "") {
+ const file = path.join(root, relativePath);
+ await fs.mkdir(path.dirname(file), { recursive: true });
+ await fs.writeFile(file, content, "utf8");
+}
+
+describe("noteSourceInstallIssues", () => {
+ beforeEach(() => {
+ vi.mocked(note).mockReset();
+ });
+
+ it("does not treat a packaged workspace config as a source checkout", async () => {
+ await withTempDir({ prefix: "openclaw-doctor-install-" }, async (root) => {
+ await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
+ await writeFile(root, "pnpm-workspace.yaml", "packages:\n - .\n");
+
+ noteSourceInstallIssues(root);
+
+ expect(note).not.toHaveBeenCalled();
+ });
+ });
+
+ it("warns source checkouts when node_modules was not installed by pnpm", async () => {
+ await withTempDir({ prefix: "openclaw-doctor-install-" }, async (root) => {
+ await fs.mkdir(path.join(root, "node_modules"), { recursive: true });
+ await writeFile(root, "pnpm-workspace.yaml", "packages:\n - .\n");
+ await writeFile(root, "src/entry.ts", "export {};\n");
+
+ noteSourceInstallIssues(root);
+
+ expect(note).toHaveBeenCalledWith(
+ expect.stringContaining("node_modules was not installed by pnpm"),
+ "Install",
+ );
+ });
+ });
+});
diff --git a/src/commands/doctor-install.ts b/src/commands/doctor-install.ts
index 2b2a3db301b..c01b4edcd3b 100644
--- a/src/commands/doctor-install.ts
+++ b/src/commands/doctor-install.ts
@@ -7,8 +7,9 @@ export function noteSourceInstallIssues(root: string | null) {
return;
}
+ const srcEntry = path.join(root, "src", "entry.ts");
const workspaceMarker = path.join(root, "pnpm-workspace.yaml");
- if (!fs.existsSync(workspaceMarker)) {
+ if (!fs.existsSync(workspaceMarker) || !fs.existsSync(srcEntry)) {
return;
}
@@ -16,7 +17,6 @@ export function noteSourceInstallIssues(root: string | null) {
const nodeModules = path.join(root, "node_modules");
const pnpmStore = path.join(nodeModules, ".pnpm");
const tsxBin = path.join(nodeModules, ".bin", "tsx");
- const srcEntry = path.join(root, "src", "entry.ts");
if (fs.existsSync(nodeModules) && !fs.existsSync(pnpmStore)) {
warnings.push(
diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts
index 2ef7dfa5a5a..39d98efa6ec 100644
--- a/src/dockerfile.test.ts
+++ b/src/dockerfile.test.ts
@@ -3,10 +3,11 @@ import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BUNDLED_PLUGIN_ROOT_DIR } from "openclaw/plugin-sdk/test-fixtures";
import { describe, expect, it } from "vitest";
+import YAML from "yaml";
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const dockerfilePath = join(repoRoot, "Dockerfile");
-const packageJsonPath = join(repoRoot, "package.json");
+const pnpmWorkspacePath = join(repoRoot, "pnpm-workspace.yaml");
function collapseDockerContinuations(dockerfile: string): string {
return dockerfile.replace(/\\\r?\n[ \t]*/g, " ");
@@ -140,11 +141,11 @@ describe("Dockerfile", () => {
it("keeps package manager patch files in runtime images", async () => {
const dockerfile = await readFile(dockerfilePath, "utf8");
- const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as {
- pnpm?: { patchedDependencies?: Record };
+ const pnpmWorkspace = YAML.parse(await readFile(pnpmWorkspacePath, "utf8")) as {
+ patchedDependencies?: Record;
};
- expect(Object.keys(packageJson.pnpm?.patchedDependencies ?? {})).not.toHaveLength(0);
+ expect(Object.keys(pnpmWorkspace.patchedDependencies ?? {})).not.toHaveLength(0);
expect(dockerfile).toContain(
"COPY --from=runtime-assets --chown=node:node /app/patches ./patches",
);
diff --git a/src/infra/update-package-manager.test.ts b/src/infra/update-package-manager.test.ts
index db0c97ccec9..82043cab1a3 100644
--- a/src/infra/update-package-manager.test.ts
+++ b/src/infra/update-package-manager.test.ts
@@ -13,7 +13,7 @@ describe("resolveUpdateBuildManager", () => {
const envPath = options.env?.PATH ?? options.env?.Path ?? "";
if (envPath.includes("openclaw-update-pnpm-")) {
paths.push(envPath);
- return { stdout: "10.0.0", stderr: "", code: 0 };
+ return { stdout: "11.0.0", stderr: "", code: 0 };
}
throw new Error("spawn pnpm ENOENT");
}
@@ -23,7 +23,7 @@ describe("resolveUpdateBuildManager", () => {
if (key === "npm --version") {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
- if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
+ if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) {
return { stdout: "added 1 package", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
@@ -53,7 +53,7 @@ describe("resolveUpdateBuildManager", () => {
if (key === "npm --version") {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
- if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
+ if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) {
return { stdout: "", stderr: "network exploded", code: 1 };
}
return { stdout: "", stderr: "", code: 0 };
diff --git a/src/infra/update-package-manager.ts b/src/infra/update-package-manager.ts
index 5cfec4c7752..582ca37b0ca 100644
--- a/src/infra/update-package-manager.ts
+++ b/src/infra/update-package-manager.ts
@@ -34,6 +34,8 @@ type ResolvedBuildManager =
reason: UpdatePackageManagerFailureReason;
};
+const PNPM_NPM_FALLBACK_SPEC = "pnpm@11";
+
async function detectBuildManager(root: string): Promise {
return (await detectPackageManagerImpl(root)) ?? "npm";
}
@@ -124,7 +126,7 @@ async function bootstrapPnpmViaNpm(params: {
};
try {
const installResult = await params.runCommand(
- ["npm", "install", "--prefix", tempRoot, "pnpm@10"],
+ ["npm", "install", "--prefix", tempRoot, PNPM_NPM_FALLBACK_SPEC],
{
timeoutMs: params.timeoutMs,
env: params.baseEnv,
diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts
index 6ce864376f7..17a40c67084 100644
--- a/src/infra/update-runner.test.ts
+++ b/src/infra/update-runner.test.ts
@@ -438,7 +438,7 @@ describe("runGatewayUpdate", () => {
if (key === "pnpm --version") {
const envPath = options?.env?.PATH ?? options?.env?.Path ?? "";
if (envPath.includes("openclaw-update-pnpm-")) {
- return { stdout: "10.0.0" };
+ return { stdout: "11.0.0" };
}
throw new Error("spawn pnpm ENOENT");
}
@@ -448,7 +448,7 @@ describe("runGatewayUpdate", () => {
if (key === "npm --version") {
return { stdout: "10.0.0" };
}
- if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
+ if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) {
return { stdout: "added 1 package" };
}
return undefined;
@@ -550,7 +550,7 @@ describe("runGatewayUpdate", () => {
const envPath = options?.env?.PATH ?? options?.env?.Path ?? "";
if (envPath.includes("openclaw-update-pnpm-")) {
pnpmEnvPaths.push(envPath);
- return { stdout: "10.0.0", stderr: "", code: 0 };
+ return { stdout: "11.0.0", stderr: "", code: 0 };
}
throw new Error("spawn pnpm ENOENT");
}
@@ -560,7 +560,7 @@ describe("runGatewayUpdate", () => {
if (key === "npm --version") {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
- if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
+ if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) {
return { stdout: "added 1 package", stderr: "", code: 0 };
}
if (
@@ -1412,7 +1412,7 @@ describe("runGatewayUpdate", () => {
if (key === "npm --version") {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
- if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@10")) {
+ if (key.startsWith("npm install --prefix ") && key.endsWith(" pnpm@11")) {
return { stdout: "", stderr: "network exploded", code: 1 };
}
return { stdout: "", stderr: "", code: 0 };
diff --git a/src/plugins/dependency-denylist.test.ts b/src/plugins/dependency-denylist.test.ts
index 55420ab1003..971de20fcb4 100644
--- a/src/plugins/dependency-denylist.test.ts
+++ b/src/plugins/dependency-denylist.test.ts
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
+import YAML from "yaml";
import {
blockedInstallDependencyPackageNames,
findBlockedPackageDirectoryInPath,
@@ -15,9 +16,10 @@ type RootPackageManifest = {
optionalDependencies?: Record;
overrides?: Record>;
peerDependencies?: Record;
- pnpm?: {
- overrides?: Record;
- };
+};
+
+type PnpmWorkspaceConfig = {
+ overrides?: Record;
};
function readRootManifest(): RootPackageManifest {
@@ -26,6 +28,12 @@ function readRootManifest(): RootPackageManifest {
) as RootPackageManifest;
}
+function readPnpmWorkspaceConfig(): PnpmWorkspaceConfig {
+ return YAML.parse(
+ fs.readFileSync(path.resolve(process.cwd(), "pnpm-workspace.yaml"), "utf8"),
+ ) as PnpmWorkspaceConfig;
+}
+
function readRootLockfile(): string {
return fs.readFileSync(path.resolve(process.cwd(), "pnpm-lock.yaml"), "utf8");
}
@@ -84,8 +92,9 @@ describe("dependency denylist guardrails", () => {
it("pins the axios override to an exact version", () => {
const manifest = readRootManifest();
+ const pnpmWorkspace = readPnpmWorkspaceConfig();
expect(manifest.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/);
- expect(manifest.pnpm?.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/);
+ expect(pnpmWorkspace.overrides?.axios).toMatch(/^\d+\.\d+\.\d+$/);
});
it("finds blocked package directories under node_modules regardless of node_modules casing", () => {
diff --git a/src/plugins/pi-package-graph.test.ts b/src/plugins/pi-package-graph.test.ts
index 256b06da5a6..d2228ad2067 100644
--- a/src/plugins/pi-package-graph.test.ts
+++ b/src/plugins/pi-package-graph.test.ts
@@ -1,12 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
+import YAML from "yaml";
type RootPackageManifest = {
dependencies?: Record;
- pnpm?: {
- overrides?: Record;
- };
+};
+
+type PnpmWorkspaceConfig = {
+ overrides?: Record;
};
const PI_PACKAGE_NAMES = [
@@ -21,6 +23,11 @@ function readRootManifest(): RootPackageManifest {
return JSON.parse(fs.readFileSync(manifestPath, "utf8")) as RootPackageManifest;
}
+function readPnpmWorkspaceConfig(): PnpmWorkspaceConfig {
+ const workspacePath = path.resolve(process.cwd(), "pnpm-workspace.yaml");
+ return YAML.parse(fs.readFileSync(workspacePath, "utf8")) as PnpmWorkspaceConfig;
+}
+
function isExactPinnedVersion(spec: string): boolean {
return !spec.startsWith("^") && !spec.startsWith("~");
}
@@ -76,8 +83,8 @@ describe("pi package graph guardrails", () => {
});
it("forbids pnpm overrides that target Pi packages", () => {
- const manifest = readRootManifest();
- const overrides = manifest.pnpm?.overrides ?? {};
+ const pnpmWorkspace = readPnpmWorkspaceConfig();
+ const overrides = pnpmWorkspace.overrides ?? {};
const piOverrides = Object.keys(overrides).filter(isPiOverrideKey);
expectNoGraphViolations(
diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts
index 1ea6785b20c..4140a6e6cb1 100644
--- a/test/scripts/docker-build-helper.test.ts
+++ b/test/scripts/docker-build-helper.test.ts
@@ -1,4 +1,7 @@
-import { readFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { describe, expect, it } from "vitest";
const HELPER_PATH = "scripts/lib/docker-build.sh";
@@ -225,6 +228,50 @@ describe("docker build helper", () => {
expect(pluginsAssertions).toContain("expected modern installRecords in installed plugin index");
});
+ it("prepares pnpm workspace package fixtures without package dependencies", () => {
+ const root = mkdtempSync(join(tmpdir(), "openclaw-update-channel-fixture-"));
+ try {
+ mkdirSync(join(root, "patches"));
+ writeFileSync(
+ join(root, "package.json"),
+ `${JSON.stringify({ name: "openclaw", version: "2026.5.6", scripts: {} }, null, 2)}\n`,
+ "utf8",
+ );
+ writeFileSync(
+ join(root, "pnpm-workspace.yaml"),
+ [
+ "packages:",
+ " - .",
+ "",
+ "patchedDependencies:",
+ ' "kept@1.0.0": "patches/kept.patch"',
+ "allowBuilds:",
+ " esbuild: true",
+ "",
+ ].join("\n"),
+ "utf8",
+ );
+ writeFileSync(join(root, "patches", "kept.patch"), "", "utf8");
+
+ execFileSync(process.execPath, [
+ UPDATE_CHANNEL_SWITCH_ASSERTIONS_PATH,
+ "prepare-git-fixture",
+ root,
+ ]);
+
+ const workspace = readFileSync(join(root, "pnpm-workspace.yaml"), "utf8");
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
+ pnpm?: unknown;
+ };
+ expect(workspace).toContain(' "kept@1.0.0": "patches/kept.patch"');
+ expect(workspace).toContain("allowUnusedPatches: true");
+ expect(workspace).toContain("allowBuilds:");
+ expect(manifest.pnpm).toBeUndefined();
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
it("keeps bundled plugin install/uninstall sweep chunkable", () => {
const runner = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH, "utf8");
const sweep = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_SWEEP_PATH, "utf8");
diff --git a/test/scripts/root-package-overrides.test.ts b/test/scripts/root-package-overrides.test.ts
index d6649287d83..f3cf4154341 100644
--- a/test/scripts/root-package-overrides.test.ts
+++ b/test/scripts/root-package-overrides.test.ts
@@ -1,13 +1,15 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
+import YAML from "yaml";
type RootPackageManifest = {
dependencies?: Record;
overrides?: Record;
- pnpm?: {
- overrides?: Record;
- };
+};
+
+type PnpmWorkspaceConfig = {
+ overrides?: Record;
};
function readRootManifest(): RootPackageManifest {
@@ -15,13 +17,19 @@ function readRootManifest(): RootPackageManifest {
return JSON.parse(fs.readFileSync(manifestPath, "utf8")) as RootPackageManifest;
}
+function readPnpmWorkspaceConfig(): PnpmWorkspaceConfig {
+ const workspacePath = path.resolve(process.cwd(), "pnpm-workspace.yaml");
+ return YAML.parse(fs.readFileSync(workspacePath, "utf8")) as PnpmWorkspaceConfig;
+}
+
describe("root package override guardrails", () => {
it("pins the Bedrock runtime below the Windows ARM Node 24 npm resolver failure", () => {
const manifest = readRootManifest();
+ const pnpmWorkspace = readPnpmWorkspaceConfig();
const packageName = "@aws-sdk/client-bedrock-runtime";
const dependencyVersion = manifest.dependencies?.[packageName];
const npmOverride = manifest.overrides?.[packageName];
- const pnpmOverride = manifest.pnpm?.overrides?.["@aws-sdk/client-bedrock-runtime"];
+ const pnpmOverride = pnpmWorkspace.overrides?.["@aws-sdk/client-bedrock-runtime"];
expect(manifest.dependencies).toHaveProperty(packageName);
expect(pnpmOverride).toBe(dependencyVersion);
@@ -30,7 +38,8 @@ describe("root package override guardrails", () => {
it("pins the node-domexception alias exactly in npm and pnpm overrides", () => {
const manifest = readRootManifest();
- const pnpmOverride = manifest.pnpm?.overrides?.["node-domexception"];
+ const pnpmWorkspace = readPnpmWorkspaceConfig();
+ const pnpmOverride = pnpmWorkspace.overrides?.["node-domexception"];
const npmOverride = manifest.overrides?.["node-domexception"];
expect(pnpmOverride).toBe("npm:@nolyfill/domexception@1.0.28");