Skip to content

Commit 109493b

Browse files
authored
fix(update): mandatory post-core plugin convergence before gateway restart
Summary: - validate active plugin payloads, including openclaw.extensions entry files, after core package updates - treat corrupt active install records without installPath as convergence failures - prevent managed gateway recovery restart when post-core plugin convergence fails Verification: - CI=true pnpm test src/cli/update-cli/plugin-payload-validation.test.ts src/cli/update-cli/post-core-plugin-convergence.test.ts src/cli/update-cli.test.ts src/commands/doctor/shared/missing-configured-plugin-install.test.ts src/commands/doctor/shared/update-phase.test.ts - CI=true pnpm check:changed - PR checks green for 2afa84d
1 parent e7ba2f9 commit 109493b

15 files changed

Lines changed: 1304 additions & 73 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ Docs: https://docs.openclaw.ai
592592
- Gateway/live tests: avoid full model-registry enumeration for explicit provider-qualified live model filters, preventing `.profile` OpenAI gateway profile runs from hanging before provider dispatch.
593593
- Gateway/status: surface CLI and gateway runtime versions, warn about stale PATH/global wrappers when they differ, and add stale-wrapper checks to the newer-config warning. Refs #79091. Thanks @RamaAditya49 and @sallyom.
594594
- Google/Gemini: retry stalled Gemini 3 preview direct API-key streams with a lean first-response payload and share Gemini tool-schema cleanup across direct Google and Gemini CLI providers, so main sessions with coding tools can recover before the LLM idle watchdog fires. (#79668) Thanks @joshavant.
595+
- Update/plugins: run a mandatory post-core convergence pass after `openclaw update` swaps the core package and before the gateway restarts, repairing missing configured plugin payloads, validating active install records including `openclaw.extensions`, and exiting with structured repair guidance instead of restarting the gateway with broken plugins. (#79143) Thanks @BKF-Gitty.
595596
- Providers: preserve non-OK `text/event-stream` response bodies so provider HTTP errors keep their JSON detail instead of collapsing to generic streaming failures. Fixes #78180.
596597
- Gateway/auth: make explicit `trusted-proxy` mode fail closed instead of accepting local password fallback credentials after trusted-proxy identity checks fail. Fixes #78684.
597598
- Active memory: treat Google Chat `spaces/...` conversation ids as scoped targets instead of runnable channel names so recall runs no longer fail bundled-plugin dirName validation. Fixes #78918.

docs/cli/update.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
185185
</Warning>
186186

187187
<Note>
188-
Post-update plugin sync failures that are scoped to a managed plugin are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
188+
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
189+
190+
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
189191

190192
When the updated Gateway starts, plugin loading is verify-only: startup does not run package managers or mutate dependency trees. Package-manager `update.run` restarts bypass the normal idle deferral and restart cooldown after the package tree has been swapped, so the old process cannot keep lazy-loading removed chunks.
191193

src/cli/update-cli.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,71 @@ describe("update-cli", () => {
773773
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
774774
});
775775

776+
it("does not restart a stopped managed gateway after post-core plugin errors", async () => {
777+
const root = createCaseDir("openclaw-update");
778+
const entryPath = path.join(root, "dist", "index.js");
779+
mockPackageInstallStatus(root);
780+
serviceLoaded.mockResolvedValue(true);
781+
pathExists.mockImplementation(async (candidate: string) => candidate === entryPath);
782+
spawn.mockImplementationOnce((_command: unknown, _argv: unknown, options: unknown) => {
783+
const resultPath = (options as { env?: NodeJS.ProcessEnv }).env
784+
?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH;
785+
if (!resultPath) {
786+
throw new Error("missing post-core result path");
787+
}
788+
queueMicrotask(() => {
789+
void fs.writeFile(
790+
resultPath,
791+
JSON.stringify({
792+
status: "error",
793+
changed: false,
794+
warnings: [
795+
{
796+
pluginId: "demo",
797+
reason: "missing-extension-entry: ./dist/index.js",
798+
message:
799+
'Plugin "demo" failed post-core payload smoke check (missing-extension-entry): ./dist/index.js',
800+
guidance: ["Run openclaw doctor --fix to attempt automatic repair."],
801+
},
802+
],
803+
sync: {
804+
changed: false,
805+
switchedToBundled: [],
806+
switchedToNpm: [],
807+
warnings: [],
808+
errors: [],
809+
},
810+
npm: {
811+
changed: false,
812+
outcomes: [
813+
{
814+
pluginId: "demo",
815+
status: "error",
816+
message: "Plugin extension entry missing",
817+
},
818+
],
819+
},
820+
integrityDrifts: [],
821+
}),
822+
"utf-8",
823+
);
824+
});
825+
const child = new EventEmitter() as EventEmitter & {
826+
kill: () => boolean;
827+
once: EventEmitter["once"];
828+
};
829+
child.kill = vi.fn(() => true);
830+
return child;
831+
});
832+
833+
await updateCommand({ yes: true });
834+
835+
expect(serviceStop).toHaveBeenCalled();
836+
expect(serviceRestart).not.toHaveBeenCalled();
837+
expect(runDaemonRestart).not.toHaveBeenCalled();
838+
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
839+
});
840+
776841
it("does not carry gateway service markers into the post-core update process", async () => {
777842
setupUpdatedRootRefresh();
778843

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { runPluginPayloadSmokeCheck } from "./plugin-payload-validation.js";
6+
7+
describe("runPluginPayloadSmokeCheck", () => {
8+
let tmpRoot: string;
9+
beforeEach(async () => {
10+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-payload-smoke-"));
11+
});
12+
afterEach(async () => {
13+
await fs.rm(tmpRoot, { recursive: true, force: true });
14+
});
15+
16+
async function writePackage(
17+
dir: string,
18+
manifest: Record<string, unknown>,
19+
mainContent?: string,
20+
) {
21+
await fs.mkdir(dir, { recursive: true });
22+
await fs.writeFile(path.join(dir, "package.json"), JSON.stringify(manifest), "utf8");
23+
const main = typeof manifest.main === "string" ? manifest.main : "index.js";
24+
if (mainContent !== undefined) {
25+
const target = path.join(dir, main);
26+
await fs.mkdir(path.dirname(target), { recursive: true });
27+
await fs.writeFile(target, mainContent, "utf8");
28+
}
29+
}
30+
31+
it("reports ok for a record whose package.json + main file exist", async () => {
32+
const dir = path.join(tmpRoot, "discord");
33+
await writePackage(
34+
dir,
35+
{ name: "@openclaw/discord", main: "dist/index.js" },
36+
"module.exports = {};",
37+
);
38+
const result = await runPluginPayloadSmokeCheck({
39+
records: { discord: { source: "npm", installPath: dir } },
40+
env: {},
41+
});
42+
expect(result.failures).toEqual([]);
43+
expect(result.checked).toEqual(["discord"]);
44+
});
45+
46+
it("reports a failure when the package directory is missing", async () => {
47+
const dir = path.join(tmpRoot, "brave");
48+
const result = await runPluginPayloadSmokeCheck({
49+
records: { brave: { source: "npm", installPath: dir } },
50+
env: {},
51+
});
52+
expect(result.failures).toEqual([
53+
{
54+
pluginId: "brave",
55+
installPath: dir,
56+
reason: "missing-package-dir",
57+
detail: expect.stringContaining(dir),
58+
},
59+
]);
60+
});
61+
62+
it("reports a failure when the package.json is missing", async () => {
63+
const dir = path.join(tmpRoot, "brave");
64+
await fs.mkdir(dir, { recursive: true });
65+
const result = await runPluginPayloadSmokeCheck({
66+
records: { brave: { source: "npm", installPath: dir } },
67+
env: {},
68+
});
69+
expect(result.failures).toEqual([
70+
{
71+
pluginId: "brave",
72+
installPath: dir,
73+
reason: "missing-package-json",
74+
detail: expect.stringContaining("package.json"),
75+
},
76+
]);
77+
});
78+
79+
it("reports a failure when the main entry file is missing on disk", async () => {
80+
const dir = path.join(tmpRoot, "brave");
81+
await writePackage(dir, { name: "@openclaw/brave", main: "dist/index.js" });
82+
const result = await runPluginPayloadSmokeCheck({
83+
records: { brave: { source: "npm", installPath: dir } },
84+
env: {},
85+
});
86+
expect(result.failures).toHaveLength(1);
87+
expect(result.failures[0]).toMatchObject({
88+
pluginId: "brave",
89+
reason: "missing-main-entry",
90+
});
91+
expect(result.failures[0]?.detail).toContain("dist/index.js");
92+
});
93+
94+
it("accepts a manifest with no main field (OpenClaw plugins commonly use `exports` or `openclaw.extensions`)", async () => {
95+
const dir = path.join(tmpRoot, "matrix");
96+
await writePackage(dir, { name: "@openclaw/plugin-matrix" });
97+
const result = await runPluginPayloadSmokeCheck({
98+
records: { matrix: { source: "npm", installPath: dir } },
99+
env: {},
100+
});
101+
expect(result.failures).toEqual([]);
102+
});
103+
104+
it("accepts a manifest that declares only `exports` and no `main`", async () => {
105+
const dir = path.join(tmpRoot, "qa");
106+
await writePackage(dir, {
107+
name: "@openclaw/qa-channel",
108+
exports: { ".": "./index.js", "./api.js": "./api.js" },
109+
});
110+
const result = await runPluginPayloadSmokeCheck({
111+
records: { qa: { source: "npm", installPath: dir } },
112+
env: {},
113+
});
114+
expect(result.failures).toEqual([]);
115+
});
116+
117+
it("accepts a manifest that declares an existing `openclaw.extensions` entry and no `main`", async () => {
118+
const dir = path.join(tmpRoot, "brave");
119+
await writePackage(dir, {
120+
name: "@openclaw/brave-plugin",
121+
openclaw: { extensions: ["./index.js"] },
122+
});
123+
await fs.writeFile(path.join(dir, "index.js"), "export default {};\n", "utf8");
124+
const result = await runPluginPayloadSmokeCheck({
125+
records: { brave: { source: "npm", installPath: dir } },
126+
env: {},
127+
});
128+
expect(result.failures).toEqual([]);
129+
});
130+
131+
it("reports a failure when an `openclaw.extensions` entry file is missing", async () => {
132+
const dir = path.join(tmpRoot, "brave");
133+
await writePackage(dir, {
134+
name: "@openclaw/brave-plugin",
135+
openclaw: { extensions: ["./dist/index.js"] },
136+
});
137+
const result = await runPluginPayloadSmokeCheck({
138+
records: { brave: { source: "npm", installPath: dir } },
139+
env: {},
140+
});
141+
expect(result.failures).toHaveLength(1);
142+
expect(result.failures[0]).toMatchObject({
143+
pluginId: "brave",
144+
reason: "missing-extension-entry",
145+
});
146+
expect(result.failures[0]?.detail).toContain("./dist/index.js");
147+
});
148+
149+
it("reports a failure when `main` resolves to a directory rather than a file", async () => {
150+
const dir = path.join(tmpRoot, "dir-main");
151+
await fs.mkdir(dir, { recursive: true });
152+
await fs.writeFile(
153+
path.join(dir, "package.json"),
154+
JSON.stringify({ name: "dir-main", main: "lib" }),
155+
"utf8",
156+
);
157+
await fs.mkdir(path.join(dir, "lib"), { recursive: true });
158+
const result = await runPluginPayloadSmokeCheck({
159+
records: { x: { source: "npm", installPath: dir } },
160+
env: {},
161+
});
162+
expect(result.failures).toHaveLength(1);
163+
expect(result.failures[0]).toMatchObject({ pluginId: "x", reason: "missing-main-entry" });
164+
});
165+
166+
it("reports a failure when `main` is a symlink whose target is missing", async () => {
167+
const dir = path.join(tmpRoot, "broken-symlink");
168+
await fs.mkdir(dir, { recursive: true });
169+
await fs.writeFile(
170+
path.join(dir, "package.json"),
171+
JSON.stringify({ name: "broken-symlink", main: "dist/entry.js" }),
172+
"utf8",
173+
);
174+
await fs.mkdir(path.join(dir, "dist"), { recursive: true });
175+
await fs.symlink(
176+
path.join(dir, "dist", "missing-target.js"),
177+
path.join(dir, "dist", "entry.js"),
178+
);
179+
const result = await runPluginPayloadSmokeCheck({
180+
records: { x: { source: "npm", installPath: dir } },
181+
env: {},
182+
});
183+
expect(result.failures).toHaveLength(1);
184+
expect(result.failures[0]).toMatchObject({
185+
pluginId: "x",
186+
reason: "missing-main-entry",
187+
});
188+
});
189+
190+
it("reports a failure when package.json cannot be parsed", async () => {
191+
const dir = path.join(tmpRoot, "broken");
192+
await fs.mkdir(dir, { recursive: true });
193+
await fs.writeFile(path.join(dir, "package.json"), "not-json", "utf8");
194+
const result = await runPluginPayloadSmokeCheck({
195+
records: { broken: { source: "npm", installPath: dir } },
196+
env: {},
197+
});
198+
expect(result.failures).toHaveLength(1);
199+
expect(result.failures[0]).toMatchObject({
200+
pluginId: "broken",
201+
reason: "invalid-package-json",
202+
});
203+
});
204+
205+
it("reports a failure when an install record is missing installPath", async () => {
206+
const result = await runPluginPayloadSmokeCheck({
207+
records: {
208+
discord: { source: "npm" } as unknown as { source: "npm"; installPath?: string },
209+
},
210+
env: {},
211+
});
212+
expect(result.checked).toEqual(["discord"]);
213+
expect(result.failures).toEqual([
214+
{
215+
pluginId: "discord",
216+
reason: "missing-install-path",
217+
detail: "Install path is missing from the plugin install record.",
218+
},
219+
]);
220+
});
221+
222+
it("only checks records whose source is package-tracked (npm/clawhub/git/marketplace)", async () => {
223+
const dir = path.join(tmpRoot, "tracked");
224+
await writePackage(dir, { name: "tracked" }, "module.exports = {};");
225+
const records = {
226+
bundled: { source: "bundled", installPath: dir } as never,
227+
npm: { source: "npm" as const, installPath: dir },
228+
};
229+
const result = await runPluginPayloadSmokeCheck({
230+
records,
231+
env: {},
232+
});
233+
expect(result.checked).toEqual(["npm"]);
234+
});
235+
});

0 commit comments

Comments
 (0)