Skip to content

Commit 748fc04

Browse files
authored
fix(update): classify unreadable plugin manifests (#110200)
Co-authored-by: VACInc <[email protected]>
1 parent b42dc72 commit 748fc04

6 files changed

Lines changed: 149 additions & 16 deletions

src/cli/update-cli/plugin-payload-validation.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import type { PluginInstallRecord } from "../../config/types.plugins.js";
77
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
88
import { runPluginPayloadSmokeCheck } from "./plugin-payload-validation.js";
@@ -19,6 +19,7 @@ describe("runPluginPayloadSmokeCheck", () => {
1919
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-payload-smoke-"));
2020
});
2121
afterEach(async () => {
22+
vi.restoreAllMocks();
2223
await fs.rm(tmpRoot, { recursive: true, force: true });
2324
});
2425

@@ -619,6 +620,32 @@ describe("runPluginPayloadSmokeCheck", () => {
619620
]);
620621
});
621622

623+
it.each(["EACCES", "EPERM", "EIO"])(
624+
"classifies a %s package.json read failure as unreadable",
625+
async (code) => {
626+
const dir = path.join(tmpRoot, "unreadable");
627+
const packageJsonPath = path.join(dir, "package.json");
628+
await writePackage(dir, { name: "unreadable" });
629+
vi.spyOn(fs, "readFile").mockRejectedValueOnce(
630+
Object.assign(new Error(`${code}: could not read ${packageJsonPath}`), { code }),
631+
);
632+
633+
const result = await runPluginPayloadSmokeCheck({
634+
records: { unreadable: { source: "npm", installPath: dir } },
635+
env: {},
636+
});
637+
638+
expect(result.failures).toStrictEqual([
639+
{
640+
pluginId: "unreadable",
641+
installPath: dir,
642+
reason: "unreadable-package-json",
643+
detail: `Could not read package.json at ${packageJsonPath}: ${code}: could not read ${packageJsonPath}`,
644+
},
645+
]);
646+
},
647+
);
648+
622649
it("reports a failure when an install record is missing installPath", async () => {
623650
const result = await runPluginPayloadSmokeCheck({
624651
records: {

src/cli/update-cli/plugin-payload-validation.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type PluginPayloadSmokeFailureReason =
1313
| "missing-install-path"
1414
| "missing-package-dir"
1515
| "missing-package-json"
16+
| "unreadable-package-json"
1617
| "invalid-package-json"
1718
| "missing-bundle-manifest"
1819
| "invalid-bundle-manifest"
@@ -121,6 +122,7 @@ type PackagePayloadManifest = PackageManifest & { main?: unknown; exports?: unkn
121122

122123
type PackagePayloadManifestReadResult =
123124
| { status: "missing" }
125+
| { status: "unreadable"; error: string }
124126
| { status: "invalid"; error: string }
125127
| { status: "present"; manifest: PackagePayloadManifest };
126128

@@ -132,10 +134,16 @@ async function readPackagePayloadManifest(
132134
if (!packageJsonStat?.isFile()) {
133135
return { status: "missing" };
134136
}
137+
let packageJson: string;
138+
try {
139+
packageJson = await fs.readFile(packageJsonPath, "utf8");
140+
} catch (err) {
141+
return { status: "unreadable", error: err instanceof Error ? err.message : String(err) };
142+
}
135143
try {
136144
return {
137145
status: "present",
138-
manifest: JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as PackagePayloadManifest,
146+
manifest: JSON.parse(packageJson) as PackagePayloadManifest,
139147
};
140148
} catch (err) {
141149
return { status: "invalid", error: err instanceof Error ? err.message : String(err) };
@@ -147,6 +155,15 @@ function formatPackagePayloadReadFailure(params: {
147155
installPath: string;
148156
packagePayload: Exclude<PackagePayloadManifestReadResult, { status: "present" }>;
149157
}): PluginPayloadSmokeFailure {
158+
if (params.packagePayload.status === "unreadable") {
159+
const packageJsonPath = path.join(params.installPath, "package.json");
160+
return {
161+
pluginId: params.pluginId,
162+
installPath: params.installPath,
163+
reason: "unreadable-package-json",
164+
detail: `Could not read package.json at ${packageJsonPath}: ${params.packagePayload.error}`,
165+
};
166+
}
150167
if (params.packagePayload.status === "invalid") {
151168
return {
152169
pluginId: params.pluginId,

src/cli/update-cli/post-core-plugin-convergence.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,53 @@ describe("runPostCorePluginConvergence", () => {
502502
]);
503503
});
504504

505+
it("uses ownership guidance and a coherent update outcome for unreadable package.json", async () => {
506+
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
507+
changes: [],
508+
warnings: [],
509+
records: { brave: { source: "npm", installPath: "/p/brave" } },
510+
});
511+
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({
512+
checked: ["brave"],
513+
failures: [
514+
{
515+
pluginId: "brave",
516+
installPath: "/p/brave",
517+
reason: "unreadable-package-json",
518+
detail: "Could not read package.json at /p/brave/package.json: EACCES: permission denied",
519+
},
520+
],
521+
});
522+
523+
const result = await runPostCorePluginConvergence({
524+
cfg: {
525+
plugins: { entries: { brave: { enabled: true } } },
526+
} as unknown as OpenClawConfig,
527+
env: {},
528+
});
529+
530+
const message =
531+
'Plugin "brave" failed post-core payload smoke check (unreadable-package-json): Could not read package.json at /p/brave/package.json: EACCES: permission denied';
532+
const guidance = [
533+
"Fix file access for /p/brave/package.json so it is readable by the user running OpenClaw. For EACCES or EPERM, correct its ownership or permissions; otherwise resolve the reported filesystem I/O error, then retry.",
534+
"Run `openclaw plugins inspect brave --runtime --json` for details.",
535+
];
536+
expect(result.warnings).toStrictEqual([
537+
{
538+
pluginId: "brave",
539+
reason:
540+
"unreadable-package-json: Could not read package.json at /p/brave/package.json: EACCES: permission denied",
541+
message,
542+
guidance,
543+
},
544+
]);
545+
expect(convergenceWarningsToOutcomes(result)).toStrictEqual({
546+
warnings: result.warnings,
547+
outcomes: [{ pluginId: "brave", status: "error", message }],
548+
errored: true,
549+
});
550+
});
551+
505552
it("hands repair's post-mutation records straight to the smoke check (no second disk read)", async () => {
506553
const records = { brave: { source: "npm" as const, installPath: "/p/brave" } };
507554
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({

src/cli/update-cli/post-core-plugin-convergence.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Reconciles configured plugin installs after the core package update has completed.
2+
import path from "node:path";
23
import { repairMissingConfiguredPluginInstalls } from "../../commands/doctor/shared/missing-configured-plugin-install.js";
34
import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/update-phase.js";
45
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -49,6 +50,19 @@ const REPAIR_GUIDANCE = "Run `openclaw update repair` to retry plugin repair.";
4950
const inspectGuidance = (pluginId: string) =>
5051
`Run \`openclaw plugins inspect ${pluginId} --runtime --json\` for details.`;
5152

53+
function smokeFailureGuidance(failure: PluginPayloadSmokeFailure): string[] {
54+
if (failure.reason !== "unreadable-package-json") {
55+
return [REPAIR_GUIDANCE, inspectGuidance(failure.pluginId)];
56+
}
57+
const packageJsonPath = failure.installPath
58+
? path.join(failure.installPath, "package.json")
59+
: "the plugin package.json";
60+
return [
61+
`Fix file access for ${packageJsonPath} so it is readable by the user running OpenClaw. For EACCES or EPERM, correct its ownership or permissions; otherwise resolve the reported filesystem I/O error, then retry.`,
62+
inspectGuidance(failure.pluginId),
63+
];
64+
}
65+
5266
async function repairManagedNpmOpenClawPeerLinks(params: {
5367
env: NodeJS.ProcessEnv;
5468
}): Promise<{ changes: string[]; warnings: PostCoreConvergenceWarning[] }> {
@@ -156,7 +170,7 @@ export async function runPostCorePluginConvergence(params: {
156170
pluginId: failure.pluginId,
157171
reason: `${failure.reason}: ${failure.detail}`,
158172
message: `Plugin "${failure.pluginId}" failed post-core payload smoke check (${failure.reason}): ${failure.detail}`,
159-
guidance: [REPAIR_GUIDANCE, inspectGuidance(failure.pluginId)],
173+
guidance: smokeFailureGuidance(failure),
160174
});
161175
}
162176

src/commands/doctor-config-preflight.state-migration.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,9 @@ describe("runDoctorConfigPreflight state migration", () => {
563563
invalidConfigNote: false,
564564
requireStartupMigrationCheckpoint: true,
565565
}),
566-
).rejects.toThrow("refusing to report the gateway ready");
566+
).rejects.toThrow(
567+
"OpenClaw startup migrations did not complete cleanly; refusing to report the gateway ready.",
568+
);
567569

568570
expect(recordSuccessfulStartupMigrations).not.toHaveBeenCalled();
569571
expect(startupMigrationLeaseRelease).toHaveBeenCalledOnce();
@@ -621,7 +623,9 @@ describe("runDoctorConfigPreflight state migration", () => {
621623
invalidConfigNote: false,
622624
requireStartupMigrationCheckpoint: true,
623625
}),
624-
).rejects.toThrow("failed post-core payload smoke check");
626+
).rejects.toThrow(
627+
'OpenClaw plugin verification failed; refusing to report the gateway ready.\n- Plugin "discord" failed post-core payload smoke check',
628+
);
625629

626630
expect(recordSuccessfulStartupMigrations).not.toHaveBeenCalled();
627631
expect(startupMigrationLeaseRelease).toHaveBeenCalledOnce();

src/commands/doctor-config-preflight.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,15 @@ function noteStateMigrationResult(result: {
141141
}
142142
}
143143

144+
type StartupPluginVerificationDiagnostic = {
145+
kind: "plugin-verification";
146+
messages: string[];
147+
};
148+
144149
async function runStartupUpgradeConvergence(params: {
145150
cfg: OpenClawConfig;
146151
env: NodeJS.ProcessEnv;
147-
}): Promise<string[]> {
152+
}): Promise<StartupPluginVerificationDiagnostic | null> {
148153
const { planStartupPluginConvergence } = await measureStartupPreflightStep(
149154
"plugin-plan-import",
150155
() => import("./doctor/shared/startup-plugin-convergence-plan.js"),
@@ -156,7 +161,7 @@ async function runStartupUpgradeConvergence(params: {
156161
}),
157162
);
158163
if (!plan.required) {
159-
return [];
164+
return null;
160165
}
161166
const { runPostCorePluginConvergence } = await measureStartupPreflightStep(
162167
"plugin-convergence-import",
@@ -185,7 +190,7 @@ async function runStartupUpgradeConvergence(params: {
185190
if (warnings.length > 0) {
186191
note(warnings.map((warning) => `- ${warning}`).join("\n"), "Doctor warnings");
187192
}
188-
return warnings;
193+
return warnings.length > 0 ? { kind: "plugin-verification", messages: warnings } : null;
189194
}
190195

191196
function formatStartupMigrationFailure(params: { warnings: string[]; blockers: string[] }): string {
@@ -200,6 +205,16 @@ function formatStartupMigrationFailure(params: { warnings: string[]; blockers: s
200205
].join("\n");
201206
}
202207

208+
function formatStartupPluginVerificationFailure(
209+
diagnostic: StartupPluginVerificationDiagnostic,
210+
): string {
211+
return [
212+
"OpenClaw plugin verification failed; refusing to report the gateway ready.",
213+
...diagnostic.messages.map((message) => `- ${message}`),
214+
"Resolve the plugin verification errors above, then restart the container.",
215+
].join("\n");
216+
}
217+
203218
function throwStartupMigrationGuardRejected(): never {
204219
throw new Error(
205220
"OpenClaw startup migrations were skipped because the selected config changed during startup; refusing to report the gateway ready. Retry startup so the new config can be validated.",
@@ -460,20 +475,29 @@ export async function runDoctorConfigPreflight(
460475
? startupMigrationHeartbeatError
461476
: new Error("OpenClaw startup migration lease heartbeat failed.");
462477
}
463-
const blockers =
464-
startupMigrationWarnings.length > 0
465-
? []
466-
: snapshot.valid
467-
? await runStartupUpgradeConvergence({ cfg: baseConfig, env: process.env })
468-
: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'];
469-
if (startupMigrationWarnings.length > 0 || blockers.length > 0) {
478+
if (startupMigrationWarnings.length > 0) {
470479
throw new Error(
471480
formatStartupMigrationFailure({
472481
warnings: startupMigrationWarnings,
473-
blockers,
482+
blockers: [],
474483
}),
475484
);
476485
}
486+
if (!snapshot.valid) {
487+
throw new Error(
488+
formatStartupMigrationFailure({
489+
warnings: [],
490+
blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'],
491+
}),
492+
);
493+
}
494+
const pluginVerificationDiagnostic = await runStartupUpgradeConvergence({
495+
cfg: baseConfig,
496+
env: process.env,
497+
});
498+
if (pluginVerificationDiagnostic) {
499+
throw new Error(formatStartupPluginVerificationFailure(pluginVerificationDiagnostic));
500+
}
477501
startupCheckpoint?.recordSuccessfulStartupMigrations({
478502
env: startupMigrationEnv,
479503
lease: startupMigrationLease,

0 commit comments

Comments
 (0)