fix(plugins): preserve host package during managed peer repair

This commit is contained in:
Vincent Koc 2026-05-16 03:30:24 +08:00
parent 56aec53dde
commit 89a9b4e75a
8 changed files with 187 additions and 15 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, string>;
};
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<string, { dependencies?: Record<string, string>; version?: string }>;
dependencies?: Record<string, unknown>;
};
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"));
});
});

View file

@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@ -758,12 +761,11 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
}): Promise<boolean> {
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<boolean> {
}): Promise<ManagedNpmRootOpenClawHostState> {
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<boolean> {
@ -884,6 +899,17 @@ async function realpathIfExists(filePath: string): Promise<string | null> {
}
}
async function lstatIfExists(filePath: string): Promise<Stats | null> {
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<boolean> {
return await fs
.lstat(filePath)
@ -896,7 +922,10 @@ async function pathExists(filePath: string): Promise<boolean> {
});
}
async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise<void> {
async function scrubManagedNpmRootOpenClawPeer(params: {
npmRoot: string;
preservePackageDir?: boolean;
}): Promise<void> {
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");

View file

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

View file

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

View file

@ -222,6 +222,7 @@ function buildCoreDistEntries(): Record<string, string> {
"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",