diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d46b6e8cfa..4b16897ad2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI Responses: clamp `input_tokens - cached_tokens` at zero and reconstruct `totalTokens` from input + output + cached components so Responses-API streams report consistent usage when providers under-report `input_tokens` relative to `cached_tokens`. - Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries. - Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text. +- Plugins/dependencies: scrub stale managed-root `openclaw` ownership metadata without deleting a linked active host package, preventing plugin installs from downgrading npm-global hosts. Fixes #79462. Thanks @lisandromachado. +- Gateway/update: keep shutdown hook-runner imports on a stable dist entry and ship a legacy chunk alias so package swaps do not strand running gateways on missing shutdown chunks. Fixes #81819. Thanks @najef1979-code. - Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records. - Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling. - Providers/images: reject malformed successful OpenAI-compatible, OpenAI, Google, fal, and OpenRouter image responses with provider-owned errors instead of raw shape failures, silent invalid base64 skips, or empty image results. diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index d1267972abd..953f8a3aff5 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -234,12 +234,16 @@ export class TelegramPollingSession { if (this.#deliveryDrainInFlight) { return; } + if (!this.opts.config) { + return; + } this.#deliveryDrainInFlight = true; const accountId = normalizeTelegramAccountId(this.opts.accountId); + const cfg = this.opts.config; void drainPendingDeliveries({ drainKey: `telegram:${accountId}`, logLabel: "Telegram reconnect drain", - cfg: this.opts.config, + cfg, log: { info: (message) => this.opts.log(`[telegram][diag] ${message}`), warn: (message) => this.opts.log(`[telegram] ${message}`), diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 8161268de29..d5fa216d84f 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -44,6 +44,8 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ // gateway may resolve these only after an npm package tree replacement. ["server-close-DsVPJDIx.js", "server-close.runtime.js"], ["server-close-DvAvfgr8.js", "server-close.runtime.js"], + // v2026.5.12-beta.8 gateway shutdown hook chunks. + ["hook-runner-global-B8rMIo8I.js", "plugins/hook-runner-global.js"], // v2026.5.3 beta reply-dispatch lazy chunks. ["provider-dispatcher-6EQEtc-t.js", "provider-dispatcher.runtime.js"], ["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.runtime.js"], diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 3f9dce574fe..bff977b004b 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -919,4 +919,120 @@ describe("managed npm root", () => { fs.readFile(path.join(hostPackageRoot, "package.json"), "utf8"), ).resolves.toContain("2026.5.12-beta.6"); }); + + it("scrubs managed ownership metadata without deleting a linked active host package", async () => { + const npmRoot = await makeTempRoot(); + const hostPackageRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-host-package-")); + tempDirs.push(hostPackageRoot); + await fs.mkdir(path.join(npmRoot, "node_modules", ".bin"), { recursive: true }); + await fs.writeFile( + path.join(hostPackageRoot, "package.json"), + `${JSON.stringify({ name: "openclaw", version: "2026.5.12-beta.6" })}\n`, + ); + await fs.symlink(hostPackageRoot, path.join(npmRoot, "node_modules", "openclaw"), "dir"); + await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw"), "shim"); + await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.cmd"), "cmd shim"); + await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.ps1"), "ps1 shim"); + await fs.writeFile( + path.join(npmRoot, "node_modules", ".package-lock.json"), + `${JSON.stringify( + { + lockfileVersion: 3, + packages: { + "node_modules/openclaw": { + version: "2026.5.12-beta.6", + }, + }, + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(npmRoot, "package.json"), + `${JSON.stringify( + { + private: true, + dependencies: { + openclaw: "2026.5.12-beta.6", + "@xdarkicex/openclaw-memory-libravdb": "1.4.69", + }, + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(npmRoot, "package-lock.json"), + `${JSON.stringify( + { + lockfileVersion: 3, + packages: { + "": { + dependencies: { + openclaw: "2026.5.12-beta.6", + "@xdarkicex/openclaw-memory-libravdb": "1.4.69", + }, + }, + "node_modules/openclaw": { + version: "2026.5.12-beta.6", + }, + "node_modules/@xdarkicex/openclaw-memory-libravdb": { + version: "1.4.69", + }, + }, + dependencies: { + openclaw: { + version: "2026.5.12-beta.6", + }, + }, + }, + null, + 2, + )}\n`, + ); + + const runCommand = vi.fn().mockResolvedValue(successfulSpawn); + await expect( + repairManagedNpmRootOpenClawPeer({ + npmRoot, + packageRoot: hostPackageRoot, + runCommand, + }), + ).resolves.toBe(true); + + expect(runCommand).not.toHaveBeenCalled(); + await expect(fs.realpath(path.join(npmRoot, "node_modules", "openclaw"))).resolves.toBe( + await fs.realpath(hostPackageRoot), + ); + await expect( + fs.readFile(path.join(hostPackageRoot, "package.json"), "utf8"), + ).resolves.toContain("2026.5.12-beta.6"); + + const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as { + dependencies?: Record; + }; + expect(manifest.dependencies).toEqual({ + "@xdarkicex/openclaw-memory-libravdb": "1.4.69", + }); + + const lockfile = JSON.parse( + await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8"), + ) as { + packages?: Record; version?: string }>; + dependencies?: Record; + }; + expect(lockfile.packages?.[""]?.dependencies).toEqual({ + "@xdarkicex/openclaw-memory-libravdb": "1.4.69", + }); + expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined(); + expect(lockfile.packages?.["node_modules/@xdarkicex/openclaw-memory-libravdb"]?.version).toBe( + "1.4.69", + ); + expect(lockfile.dependencies?.openclaw).toBeUndefined(); + for (const binName of ["openclaw", "openclaw.cmd", "openclaw.ps1"]) { + await expectPathMissing(path.join(npmRoot, "node_modules", ".bin", binName)); + } + await expectPathMissing(path.join(npmRoot, "node_modules", ".package-lock.json")); + }); }); diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index e0266a03a1b..8ddcf6d5c66 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -1,3 +1,4 @@ +import type { Stats } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -52,6 +53,8 @@ type ManagedNpmRootLogger = { type ManagedNpmRootRunCommand = typeof runCommandWithTimeout; +type ManagedNpmRootOpenClawHostState = "none" | "managed-active-host" | "linked-active-host"; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -758,12 +761,11 @@ export async function repairManagedNpmRootOpenClawPeer(params: { }): Promise { await fs.mkdir(params.npmRoot, { recursive: true }); - if ( - await managedNpmRootOpenClawPackageIsActiveHost({ - npmRoot: params.npmRoot, - packageRoot: params.packageRoot, - }) - ) { + const activeHostState = await readManagedNpmRootOpenClawHostState({ + npmRoot: params.npmRoot, + packageRoot: params.packageRoot, + }); + if (activeHostState === "managed-active-host") { return false; } @@ -773,10 +775,19 @@ export async function repairManagedNpmRootOpenClawPeer(params: { const hasManifestDependency = "openclaw" in dependencies; const hasLockDependency = await managedNpmRootLockfileHasOpenClawPeer(params.npmRoot); const hasPackageDir = await pathExists(path.join(params.npmRoot, "node_modules", "openclaw")); - if (!hasManifestDependency && !hasLockDependency && !hasPackageDir) { + const preserveActiveHostLink = activeHostState === "linked-active-host"; + if (!hasManifestDependency && !hasLockDependency && (!hasPackageDir || preserveActiveHostLink)) { return false; } + if (preserveActiveHostLink) { + await scrubManagedNpmRootOpenClawPeer({ + npmRoot: params.npmRoot, + preservePackageDir: true, + }); + return true; + } + const command = params.runCommand ?? runCommandWithTimeout; const npmArgs = hasManifestDependency ? [ @@ -823,10 +834,10 @@ export async function repairManagedNpmRootOpenClawPeer(params: { return true; } -async function managedNpmRootOpenClawPackageIsActiveHost(params: { +async function readManagedNpmRootOpenClawHostState(params: { npmRoot: string; packageRoot?: string | null; -}): Promise { +}): Promise { const packageRoot = params.packageRoot === undefined ? resolveOpenClawPackageRootSync({ @@ -836,15 +847,19 @@ async function managedNpmRootOpenClawPackageIsActiveHost(params: { }) : params.packageRoot; if (!packageRoot) { - return false; + return "none"; } const managedOpenClawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw"); - const [hostPackageRoot, managedPackageRoot] = await Promise.all([ + const [hostPackageRoot, managedPackageRoot, managedPackageStat] = await Promise.all([ realpathIfExists(packageRoot), realpathIfExists(managedOpenClawPackageDir), + lstatIfExists(managedOpenClawPackageDir), ]); - return hostPackageRoot !== null && hostPackageRoot === managedPackageRoot; + if (hostPackageRoot === null || hostPackageRoot !== managedPackageRoot) { + return "none"; + } + return managedPackageStat?.isSymbolicLink() ? "linked-active-host" : "managed-active-host"; } async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise { @@ -884,6 +899,17 @@ async function realpathIfExists(filePath: string): Promise { } } +async function lstatIfExists(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw err; + } +} + async function pathExists(filePath: string): Promise { return await fs .lstat(filePath) @@ -896,7 +922,10 @@ async function pathExists(filePath: string): Promise { }); } -async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise { +async function scrubManagedNpmRootOpenClawPeer(params: { + npmRoot: string; + preservePackageDir?: boolean; +}): Promise { const manifestPath = path.join(params.npmRoot, "package.json"); const manifest = await readManagedNpmRootManifest(manifestPath); const dependencies = readDependencyRecord(manifest.dependencies); @@ -944,7 +973,7 @@ async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Pro } const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw"); - if (await pathExists(openclawPackageDir)) { + if (!params.preservePackageDir && (await pathExists(openclawPackageDir))) { await fs.rm(openclawPackageDir, { recursive: true, force: true }); } const binDir = path.join(params.npmRoot, "node_modules", ".bin"); diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 24931f5c0d6..33a09834907 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -99,6 +99,7 @@ describe("tsdown config", () => { "index", "commands/status.summary.runtime", "provider-dispatcher.runtime", + "plugins/hook-runner-global", "plugins/provider-discovery.runtime", "plugins/provider-runtime.runtime", "plugins/runtime/index", @@ -138,6 +139,14 @@ describe("tsdown config", () => { ); }); + it("keeps gateway shutdown hook runner behind one stable dist entry", () => { + const distGraph = requireUnifiedDistGraph(); + + expect(entrySources(distGraph)["plugins/hook-runner-global"]).toBe( + "src/plugins/hook-runner-global.ts", + ); + }); + it("keeps Telegram ingress worker behind one root stable dist entry", () => { const distGraph = requireUnifiedDistGraph(); diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 1d95f01b20f..4bf5f32b3f1 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -688,12 +688,18 @@ describe("runtime postbuild static assets", () => { it("writes compatibility aliases for previous gateway shutdown chunk names", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); + await fs.mkdir(path.join(distDir, "plugins"), { recursive: true }); await fs.mkdir(distDir, { recursive: true }); await fs.writeFile( path.join(distDir, "server-close.runtime.js"), 'export * from "./server-close.runtime-NewHash.js";\n', "utf8", ); + await fs.writeFile( + path.join(distDir, "plugins", "hook-runner-global.js"), + "export const runGlobalHook = true;\n", + "utf8", + ); writeLegacyRootRuntimeCompatAliases({ rootDir }); @@ -703,6 +709,9 @@ describe("runtime postbuild static assets", () => { expect(await fs.readFile(path.join(distDir, "server-close-DvAvfgr8.js"), "utf8")).toBe( 'export * from "./server-close.runtime.js";\n', ); + expect(await fs.readFile(path.join(distDir, "hook-runner-global-B8rMIo8I.js"), "utf8")).toBe( + 'export * from "./plugins/hook-runner-global.js";\n', + ); }); it("writes compatibility aliases for previous tool and ACP manager chunk names", async () => { diff --git a/tsdown.config.ts b/tsdown.config.ts index 4910ce265b5..d914a37014a 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -222,6 +222,7 @@ function buildCoreDistEntries(): Record { "cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts", "provider-dispatcher.runtime": "src/auto-reply/reply/provider-dispatcher.runtime.ts", "server-close.runtime": "src/gateway/server-close.runtime.ts", + "plugins/hook-runner-global": "src/plugins/hook-runner-global.ts", "plugins/memory-state": "src/plugins/memory-state.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts",