Skip to content

Commit fcbca2d

Browse files
committed
fix(plugins): preserve host package during managed peer repair
1 parent 783ef1d commit fcbca2d

8 files changed

Lines changed: 187 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Docs: https://docs.openclaw.ai
2222
- 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`.
2323
- Plugins: reject malformed `package.json` `openclaw.extensions` metadata during install, discovery, and post-update payload smoke instead of silently dropping invalid entries.
2424
- Media/files: sniff `input_file` bytes before trusting declared MIME headers, rejecting spoofed image or zip payloads before they become agent-visible text.
25+
- 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.
26+
- 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.
2527
- 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.
2628
- 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.
2729
- 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.

extensions/telegram/src/polling-session.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,16 @@ export class TelegramPollingSession {
234234
if (this.#deliveryDrainInFlight) {
235235
return;
236236
}
237+
if (!this.opts.config) {
238+
return;
239+
}
237240
this.#deliveryDrainInFlight = true;
238241
const accountId = normalizeTelegramAccountId(this.opts.accountId);
242+
const cfg = this.opts.config;
239243
void drainPendingDeliveries({
240244
drainKey: `telegram:${accountId}`,
241245
logLabel: "Telegram reconnect drain",
242-
cfg: this.opts.config,
246+
cfg,
243247
log: {
244248
info: (message) => this.opts.log(`[telegram][diag] ${message}`),
245249
warn: (message) => this.opts.log(`[telegram] ${message}`),

scripts/runtime-postbuild.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [
4444
// gateway may resolve these only after an npm package tree replacement.
4545
["server-close-DsVPJDIx.js", "server-close.runtime.js"],
4646
["server-close-DvAvfgr8.js", "server-close.runtime.js"],
47+
// v2026.5.12-beta.8 gateway shutdown hook chunks.
48+
["hook-runner-global-B8rMIo8I.js", "plugins/hook-runner-global.js"],
4749
// v2026.5.3 beta reply-dispatch lazy chunks.
4850
["provider-dispatcher-6EQEtc-t.js", "provider-dispatcher.runtime.js"],
4951
["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.runtime.js"],

src/infra/npm-managed-root.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,4 +919,120 @@ describe("managed npm root", () => {
919919
fs.readFile(path.join(hostPackageRoot, "package.json"), "utf8"),
920920
).resolves.toContain("2026.5.12-beta.6");
921921
});
922+
923+
it("scrubs managed ownership metadata without deleting a linked active host package", async () => {
924+
const npmRoot = await makeTempRoot();
925+
const hostPackageRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-host-package-"));
926+
tempDirs.push(hostPackageRoot);
927+
await fs.mkdir(path.join(npmRoot, "node_modules", ".bin"), { recursive: true });
928+
await fs.writeFile(
929+
path.join(hostPackageRoot, "package.json"),
930+
`${JSON.stringify({ name: "openclaw", version: "2026.5.12-beta.6" })}\n`,
931+
);
932+
await fs.symlink(hostPackageRoot, path.join(npmRoot, "node_modules", "openclaw"), "dir");
933+
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw"), "shim");
934+
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.cmd"), "cmd shim");
935+
await fs.writeFile(path.join(npmRoot, "node_modules", ".bin", "openclaw.ps1"), "ps1 shim");
936+
await fs.writeFile(
937+
path.join(npmRoot, "node_modules", ".package-lock.json"),
938+
`${JSON.stringify(
939+
{
940+
lockfileVersion: 3,
941+
packages: {
942+
"node_modules/openclaw": {
943+
version: "2026.5.12-beta.6",
944+
},
945+
},
946+
},
947+
null,
948+
2,
949+
)}\n`,
950+
);
951+
await fs.writeFile(
952+
path.join(npmRoot, "package.json"),
953+
`${JSON.stringify(
954+
{
955+
private: true,
956+
dependencies: {
957+
openclaw: "2026.5.12-beta.6",
958+
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
959+
},
960+
},
961+
null,
962+
2,
963+
)}\n`,
964+
);
965+
await fs.writeFile(
966+
path.join(npmRoot, "package-lock.json"),
967+
`${JSON.stringify(
968+
{
969+
lockfileVersion: 3,
970+
packages: {
971+
"": {
972+
dependencies: {
973+
openclaw: "2026.5.12-beta.6",
974+
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
975+
},
976+
},
977+
"node_modules/openclaw": {
978+
version: "2026.5.12-beta.6",
979+
},
980+
"node_modules/@xdarkicex/openclaw-memory-libravdb": {
981+
version: "1.4.69",
982+
},
983+
},
984+
dependencies: {
985+
openclaw: {
986+
version: "2026.5.12-beta.6",
987+
},
988+
},
989+
},
990+
null,
991+
2,
992+
)}\n`,
993+
);
994+
995+
const runCommand = vi.fn().mockResolvedValue(successfulSpawn);
996+
await expect(
997+
repairManagedNpmRootOpenClawPeer({
998+
npmRoot,
999+
packageRoot: hostPackageRoot,
1000+
runCommand,
1001+
}),
1002+
).resolves.toBe(true);
1003+
1004+
expect(runCommand).not.toHaveBeenCalled();
1005+
await expect(fs.realpath(path.join(npmRoot, "node_modules", "openclaw"))).resolves.toBe(
1006+
await fs.realpath(hostPackageRoot),
1007+
);
1008+
await expect(
1009+
fs.readFile(path.join(hostPackageRoot, "package.json"), "utf8"),
1010+
).resolves.toContain("2026.5.12-beta.6");
1011+
1012+
const manifest = JSON.parse(await fs.readFile(path.join(npmRoot, "package.json"), "utf8")) as {
1013+
dependencies?: Record<string, string>;
1014+
};
1015+
expect(manifest.dependencies).toEqual({
1016+
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
1017+
});
1018+
1019+
const lockfile = JSON.parse(
1020+
await fs.readFile(path.join(npmRoot, "package-lock.json"), "utf8"),
1021+
) as {
1022+
packages?: Record<string, { dependencies?: Record<string, string>; version?: string }>;
1023+
dependencies?: Record<string, unknown>;
1024+
};
1025+
expect(lockfile.packages?.[""]?.dependencies).toEqual({
1026+
"@xdarkicex/openclaw-memory-libravdb": "1.4.69",
1027+
});
1028+
expect(lockfile.packages?.["node_modules/openclaw"]).toBeUndefined();
1029+
expect(lockfile.packages?.["node_modules/@xdarkicex/openclaw-memory-libravdb"]?.version).toBe(
1030+
"1.4.69",
1031+
);
1032+
expect(lockfile.dependencies?.openclaw).toBeUndefined();
1033+
for (const binName of ["openclaw", "openclaw.cmd", "openclaw.ps1"]) {
1034+
await expectPathMissing(path.join(npmRoot, "node_modules", ".bin", binName));
1035+
}
1036+
await expectPathMissing(path.join(npmRoot, "node_modules", ".package-lock.json"));
1037+
});
9221038
});

src/infra/npm-managed-root.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Stats } from "node:fs";
12
import fs from "node:fs/promises";
23
import os from "node:os";
34
import path from "node:path";
@@ -52,6 +53,8 @@ type ManagedNpmRootLogger = {
5253

5354
type ManagedNpmRootRunCommand = typeof runCommandWithTimeout;
5455

56+
type ManagedNpmRootOpenClawHostState = "none" | "managed-active-host" | "linked-active-host";
57+
5558
function isRecord(value: unknown): value is Record<string, unknown> {
5659
return typeof value === "object" && value !== null && !Array.isArray(value);
5760
}
@@ -758,12 +761,11 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
758761
}): Promise<boolean> {
759762
await fs.mkdir(params.npmRoot, { recursive: true });
760763

761-
if (
762-
await managedNpmRootOpenClawPackageIsActiveHost({
763-
npmRoot: params.npmRoot,
764-
packageRoot: params.packageRoot,
765-
})
766-
) {
764+
const activeHostState = await readManagedNpmRootOpenClawHostState({
765+
npmRoot: params.npmRoot,
766+
packageRoot: params.packageRoot,
767+
});
768+
if (activeHostState === "managed-active-host") {
767769
return false;
768770
}
769771

@@ -773,10 +775,19 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
773775
const hasManifestDependency = "openclaw" in dependencies;
774776
const hasLockDependency = await managedNpmRootLockfileHasOpenClawPeer(params.npmRoot);
775777
const hasPackageDir = await pathExists(path.join(params.npmRoot, "node_modules", "openclaw"));
776-
if (!hasManifestDependency && !hasLockDependency && !hasPackageDir) {
778+
const preserveActiveHostLink = activeHostState === "linked-active-host";
779+
if (!hasManifestDependency && !hasLockDependency && (!hasPackageDir || preserveActiveHostLink)) {
777780
return false;
778781
}
779782

783+
if (preserveActiveHostLink) {
784+
await scrubManagedNpmRootOpenClawPeer({
785+
npmRoot: params.npmRoot,
786+
preservePackageDir: true,
787+
});
788+
return true;
789+
}
790+
780791
const command = params.runCommand ?? runCommandWithTimeout;
781792
const npmArgs = hasManifestDependency
782793
? [
@@ -823,10 +834,10 @@ export async function repairManagedNpmRootOpenClawPeer(params: {
823834
return true;
824835
}
825836

826-
async function managedNpmRootOpenClawPackageIsActiveHost(params: {
837+
async function readManagedNpmRootOpenClawHostState(params: {
827838
npmRoot: string;
828839
packageRoot?: string | null;
829-
}): Promise<boolean> {
840+
}): Promise<ManagedNpmRootOpenClawHostState> {
830841
const packageRoot =
831842
params.packageRoot === undefined
832843
? resolveOpenClawPackageRootSync({
@@ -836,15 +847,19 @@ async function managedNpmRootOpenClawPackageIsActiveHost(params: {
836847
})
837848
: params.packageRoot;
838849
if (!packageRoot) {
839-
return false;
850+
return "none";
840851
}
841852

842853
const managedOpenClawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
843-
const [hostPackageRoot, managedPackageRoot] = await Promise.all([
854+
const [hostPackageRoot, managedPackageRoot, managedPackageStat] = await Promise.all([
844855
realpathIfExists(packageRoot),
845856
realpathIfExists(managedOpenClawPackageDir),
857+
lstatIfExists(managedOpenClawPackageDir),
846858
]);
847-
return hostPackageRoot !== null && hostPackageRoot === managedPackageRoot;
859+
if (hostPackageRoot === null || hostPackageRoot !== managedPackageRoot) {
860+
return "none";
861+
}
862+
return managedPackageStat?.isSymbolicLink() ? "linked-active-host" : "managed-active-host";
848863
}
849864

850865
async function managedNpmRootLockfileHasOpenClawPeer(npmRoot: string): Promise<boolean> {
@@ -884,6 +899,17 @@ async function realpathIfExists(filePath: string): Promise<string | null> {
884899
}
885900
}
886901

902+
async function lstatIfExists(filePath: string): Promise<Stats | null> {
903+
try {
904+
return await fs.lstat(filePath);
905+
} catch (err) {
906+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
907+
return null;
908+
}
909+
throw err;
910+
}
911+
}
912+
887913
async function pathExists(filePath: string): Promise<boolean> {
888914
return await fs
889915
.lstat(filePath)
@@ -896,7 +922,10 @@ async function pathExists(filePath: string): Promise<boolean> {
896922
});
897923
}
898924

899-
async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Promise<void> {
925+
async function scrubManagedNpmRootOpenClawPeer(params: {
926+
npmRoot: string;
927+
preservePackageDir?: boolean;
928+
}): Promise<void> {
900929
const manifestPath = path.join(params.npmRoot, "package.json");
901930
const manifest = await readManagedNpmRootManifest(manifestPath);
902931
const dependencies = readDependencyRecord(manifest.dependencies);
@@ -944,7 +973,7 @@ async function scrubManagedNpmRootOpenClawPeer(params: { npmRoot: string }): Pro
944973
}
945974

946975
const openclawPackageDir = path.join(params.npmRoot, "node_modules", "openclaw");
947-
if (await pathExists(openclawPackageDir)) {
976+
if (!params.preservePackageDir && (await pathExists(openclawPackageDir))) {
948977
await fs.rm(openclawPackageDir, { recursive: true, force: true });
949978
}
950979
const binDir = path.join(params.npmRoot, "node_modules", ".bin");

src/infra/tsdown-config.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ describe("tsdown config", () => {
9999
"index",
100100
"commands/status.summary.runtime",
101101
"provider-dispatcher.runtime",
102+
"plugins/hook-runner-global",
102103
"plugins/provider-discovery.runtime",
103104
"plugins/provider-runtime.runtime",
104105
"plugins/runtime/index",
@@ -138,6 +139,14 @@ describe("tsdown config", () => {
138139
);
139140
});
140141

142+
it("keeps gateway shutdown hook runner behind one stable dist entry", () => {
143+
const distGraph = requireUnifiedDistGraph();
144+
145+
expect(entrySources(distGraph)["plugins/hook-runner-global"]).toBe(
146+
"src/plugins/hook-runner-global.ts",
147+
);
148+
});
149+
141150
it("keeps Telegram ingress worker behind one root stable dist entry", () => {
142151
const distGraph = requireUnifiedDistGraph();
143152

test/scripts/runtime-postbuild.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,12 +637,18 @@ describe("runtime postbuild static assets", () => {
637637
it("writes compatibility aliases for previous gateway shutdown chunk names", async () => {
638638
const rootDir = createTempDir("openclaw-runtime-postbuild-");
639639
const distDir = path.join(rootDir, "dist");
640+
await fs.mkdir(path.join(distDir, "plugins"), { recursive: true });
640641
await fs.mkdir(distDir, { recursive: true });
641642
await fs.writeFile(
642643
path.join(distDir, "server-close.runtime.js"),
643644
'export * from "./server-close.runtime-NewHash.js";\n',
644645
"utf8",
645646
);
647+
await fs.writeFile(
648+
path.join(distDir, "plugins", "hook-runner-global.js"),
649+
"export const runGlobalHook = true;\n",
650+
"utf8",
651+
);
646652

647653
writeLegacyRootRuntimeCompatAliases({ rootDir });
648654

@@ -652,6 +658,9 @@ describe("runtime postbuild static assets", () => {
652658
expect(await fs.readFile(path.join(distDir, "server-close-DvAvfgr8.js"), "utf8")).toBe(
653659
'export * from "./server-close.runtime.js";\n',
654660
);
661+
expect(await fs.readFile(path.join(distDir, "hook-runner-global-B8rMIo8I.js"), "utf8")).toBe(
662+
'export * from "./plugins/hook-runner-global.js";\n',
663+
);
655664
});
656665

657666
it("writes compatibility aliases for previous tool and ACP manager chunk names", async () => {

tsdown.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ function buildCoreDistEntries(): Record<string, string> {
222222
"cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts",
223223
"provider-dispatcher.runtime": "src/auto-reply/reply/provider-dispatcher.runtime.ts",
224224
"server-close.runtime": "src/gateway/server-close.runtime.ts",
225+
"plugins/hook-runner-global": "src/plugins/hook-runner-global.ts",
225226
"plugins/memory-state": "src/plugins/memory-state.ts",
226227
"subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts",
227228
"task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts",

0 commit comments

Comments
 (0)