diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8ffe543f0..d967961aebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman. - Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt. - WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss. +- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc. - Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870. ## 2026.3.23 diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index f25bf552886..eacaac1647e 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -3,6 +3,8 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/; + function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, "utf8")); } @@ -84,31 +86,114 @@ function sanitizeBundledManifestForRuntimeInstall(pluginDir) { export function resolveNpmRunner(params = {}) { const execPath = params.execPath ?? process.execPath; + const npmArgs = params.npmArgs ?? []; const existsSync = params.existsSync ?? fs.existsSync; + const env = params.env ?? process.env; const platform = params.platform ?? process.platform; - const nodeDir = path.dirname(execPath); - const npmCliPath = path.resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"); - if (existsSync(npmCliPath)) { + const comSpec = params.comSpec ?? env.ComSpec ?? "cmd.exe"; + const pathImpl = platform === "win32" ? path.win32 : path; + const nodeDir = pathImpl.dirname(execPath); + const npmToolchain = resolveToolchainNpmRunner({ + comSpec, + existsSync, + nodeDir, + npmArgs, + pathImpl, + platform, + }); + if (npmToolchain) { + return npmToolchain; + } + if (platform === "win32") { + const expectedPaths = [ + pathImpl.resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), + pathImpl.resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"), + pathImpl.resolve(nodeDir, "npm.exe"), + pathImpl.resolve(nodeDir, "npm.cmd"), + ]; + throw new Error( + `failed to resolve a toolchain-local npm next to ${execPath}. ` + + `Checked: ${expectedPaths.join(", ")}. ` + + "OpenClaw refuses to shell out to bare npm on Windows; install a Node.js toolchain that bundles npm or run with a matching Node installation.", + ); + } + const pathKey = resolvePathEnvKey(env); + const currentPath = env[pathKey]; + return { + command: "npm", + args: npmArgs, + shell: false, + env: { + ...env, + [pathKey]: + typeof currentPath === "string" && currentPath.length > 0 + ? `${nodeDir}${path.delimiter}${currentPath}` + : nodeDir, + }, + }; +} + +function resolveToolchainNpmRunner(params) { + const npmCliCandidates = [ + params.pathImpl.resolve(params.nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), + params.pathImpl.resolve(params.nodeDir, "node_modules/npm/bin/npm-cli.js"), + ]; + const npmCliPath = npmCliCandidates.find((candidate) => params.existsSync(candidate)); + if (npmCliPath) { return { - command: execPath, - args: [npmCliPath], + command: + params.platform === "win32" + ? params.pathImpl.join(params.nodeDir, "node.exe") + : params.pathImpl.join(params.nodeDir, "node"), + args: [npmCliPath, ...params.npmArgs], shell: false, }; } - return { - command: "npm", - args: [], - shell: platform === "win32", - }; + if (params.platform !== "win32") { + return null; + } + const npmExePath = params.pathImpl.resolve(params.nodeDir, "npm.exe"); + if (params.existsSync(npmExePath)) { + return { + command: npmExePath, + args: params.npmArgs, + shell: false, + }; + } + const npmCmdPath = params.pathImpl.resolve(params.nodeDir, "npm.cmd"); + if (params.existsSync(npmCmdPath)) { + return { + command: params.comSpec, + args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmCmdPath, params.npmArgs)], + shell: false, + windowsVerbatimArguments: true, + }; + } + return null; +} + +function resolvePathEnvKey(env) { + return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; +} + +function escapeForCmdExe(arg) { + if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) { + throw new Error(`unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`); + } + if (!arg.includes(" ") && !arg.includes('"')) { + return arg; + } + return `"${arg.replace(/"/g, '""')}"`; +} + +function buildCmdExeCommandLine(command, args) { + return [escapeForCmdExe(command), ...args.map(escapeForCmdExe)].join(" "); } function installPluginRuntimeDeps(pluginDir, pluginId) { sanitizeBundledManifestForRuntimeInstall(pluginDir); - const npmRunner = resolveNpmRunner(); - const result = spawnSync( - npmRunner.command, - [ - ...npmRunner.args, + const npmRunner = resolveNpmRunner({ + npmArgs: [ "install", "--omit=dev", "--silent", @@ -116,11 +201,17 @@ function installPluginRuntimeDeps(pluginDir, pluginId) { "--legacy-peer-deps", "--package-lock=false", ], + }); + const result = spawnSync( + npmRunner.command, + npmRunner.args, { cwd: pluginDir, encoding: "utf8", + env: npmRunner.env, stdio: "pipe", shell: npmRunner.shell, + windowsVerbatimArguments: npmRunner.windowsVerbatimArguments, }, ); if (result.status === 0) { diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 9cc4d0ab40c..ad2d08f77fb 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -34,8 +34,12 @@ describe("tsdown config", () => { expect(distGraphs).toHaveLength(1); expect(entryKeys(distGraphs[0])).toEqual( expect.arrayContaining([ + "agents/auth-profiles.runtime", + "agents/pi-model-discovery-runtime", "index", "cli/memory-cli", + "commands/status.summary.runtime", + "plugins/provider-runtime.runtime", "plugins/runtime/index", "plugin-sdk/compat", "plugin-sdk/index", diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index a57245c035c..3570e1771af 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -12,6 +12,7 @@ describe("resolveNpmRunner", () => { const runner = resolveNpmRunner({ execPath, + env: {}, existsSync: (candidate: string) => candidate === expectedNpmCliPath, platform: "darwin", }); @@ -23,10 +24,74 @@ describe("resolveNpmRunner", () => { }); }); - it("falls back to bare npm when npm-cli.js is unavailable", () => { + it("anchors Windows npm staging to the adjacent npm-cli.js without a shell", () => { + const execPath = "C:\\nodejs\\node.exe"; + const expectedNpmCliPath = path.win32.resolve( + path.win32.dirname(execPath), + "node_modules/npm/bin/npm-cli.js", + ); + + const runner = resolveNpmRunner({ + execPath, + env: {}, + existsSync: (candidate: string) => candidate === expectedNpmCliPath, + platform: "win32", + }); + + expect(runner).toEqual({ + command: execPath, + args: [expectedNpmCliPath], + shell: false, + }); + }); + + it("uses an adjacent npm.exe on Windows without a shell", () => { + const execPath = "C:\\nodejs\\node.exe"; + const expectedNpmExePath = path.win32.resolve(path.win32.dirname(execPath), "npm.exe"); + + const runner = resolveNpmRunner({ + execPath, + env: {}, + existsSync: (candidate: string) => candidate === expectedNpmExePath, + npmArgs: ["install", "--silent"], + platform: "win32", + }); + + expect(runner).toEqual({ + command: expectedNpmExePath, + args: ["install", "--silent"], + shell: false, + }); + }); + + it("wraps an adjacent npm.cmd via cmd.exe without enabling shell mode", () => { + const execPath = "C:\\nodejs\\node.exe"; + const npmCmdPath = path.win32.resolve(path.win32.dirname(execPath), "npm.cmd"); + + const runner = resolveNpmRunner({ + comSpec: "C:\\Windows\\System32\\cmd.exe", + execPath, + env: {}, + existsSync: (candidate: string) => candidate === npmCmdPath, + npmArgs: ["install", "--omit=dev"], + platform: "win32", + }); + + expect(runner).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", `${npmCmdPath} install --omit=dev`], + shell: false, + windowsVerbatimArguments: true, + }); + }); + + it("prefixes PATH with the active node dir when falling back to bare npm", () => { expect( resolveNpmRunner({ execPath: "/tmp/node", + env: { + PATH: "/usr/bin:/bin", + }, existsSync: () => false, platform: "linux", }), @@ -34,20 +99,22 @@ describe("resolveNpmRunner", () => { command: "npm", args: [], shell: false, + env: { + PATH: `/tmp${path.delimiter}/usr/bin:/bin`, + }, }); }); - it("keeps shell mode for bare npm fallback on Windows", () => { - expect( + it("fails closed on Windows when no toolchain-local npm CLI exists", () => { + expect(() => resolveNpmRunner({ execPath: "C:\\node\\node.exe", + env: { + Path: "C:\\Windows\\System32", + }, existsSync: () => false, platform: "win32", }), - ).toEqual({ - command: "npm", - args: [], - shell: true, - }); + ).toThrow("OpenClaw refuses to shell out to bare npm on Windows"); }); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index 8d534c79eaa..76f388bee89 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -119,6 +119,12 @@ function buildCoreDistEntries(): Record { // it by a deterministic path instead of a content-hashed chunk name. // See https://github.com/openclaw/openclaw/issues/51676 "cli/memory-cli": "src/cli/memory-cli.ts", + // Keep long-lived lazy runtime boundaries on stable filenames so rebuilt + // dist/ trees do not strand already-running gateways on stale hashed chunks. + "agents/auth-profiles.runtime": "src/agents/auth-profiles.runtime.ts", + "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", + "commands/status.summary.runtime": "src/commands/status.summary.runtime.ts", + "plugins/provider-runtime.runtime": "src/plugins/provider-runtime.runtime.ts", extensionAPI: "src/extensionAPI.ts", "infra/warning-filter": "src/infra/warning-filter.ts", "telegram/audit": "extensions/telegram/src/audit.ts",