fix(plugins): repair missing required platform packages

This commit is contained in:
Vincent Koc
2026-06-16 14:00:00 +08:00
parent a24b0e3ce3
commit 772bdeb18e
9 changed files with 448 additions and 8 deletions
+8
View File
@@ -1278,6 +1278,7 @@ Important examples:
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
| `openclaw.install.requiredPlatformPackages` | npm package aliases that must materialize when their lockfile platform constraints match the current host. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
Manifest metadata decides which provider/channel/setup choices appear in
@@ -1290,6 +1291,13 @@ registry loading for non-bundled plugin sources. Invalid values are rejected;
newer-but-valid values skip external plugins on older hosts. Bundled source
plugins are assumed to be co-versioned with the host checkout.
`openclaw.install.requiredPlatformPackages` is for npm packages that expose
required native binaries through optional, platform-specific aliases. List the
bare npm package name for every supported platform alias. During npm install,
OpenClaw verifies only the declared alias whose lockfile constraints match the
current host. If npm reports success but omits that alias, OpenClaw retries once
with a fresh cache and rolls back the install if the alias is still missing.
`openclaw.compat.pluginApi` is enforced during package install for non-bundled
plugin sources. Use it for the OpenClaw plugin SDK/runtime API floor that the
package was built against. It can be stricter than `minHostVersion` when a
+1
View File
@@ -163,6 +163,7 @@ Example:
| `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z` or `>=x.y.z-prerelease`. |
| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. |
| `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. |
| `requiredPlatformPackages` | `string[]` | Required platform-specific npm aliases verified during npm install. |
<AccordionGroup>
<Accordion title="Onboarding behavior">
+9 -1
View File
@@ -23,7 +23,15 @@
"install": {
"npmSpec": "@openclaw/codex",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.1-beta.1"
"minHostVersion": ">=2026.5.1-beta.1",
"requiredPlatformPackages": [
"@openai/codex-linux-x64",
"@openai/codex-linux-arm64",
"@openai/codex-darwin-x64",
"@openai/codex-darwin-arm64",
"@openai/codex-win32-x64",
"@openai/codex-win32-arm64"
]
},
"compat": {
"pluginApi": ">=2026.6.8-beta.2"
+13
View File
@@ -6,6 +6,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j
type CodexPackageManifest = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
openclaw?: {
install?: {
requiredPlatformPackages?: string[];
};
};
};
describe("codex package manifest", () => {
@@ -18,5 +23,13 @@ describe("codex package manifest", () => {
expect(packageJson.dependencies?.["@openai/codex"]).toBe(
MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION,
);
expect(packageJson.openclaw?.install?.requiredPlatformPackages).toEqual([
"@openai/codex-linux-x64",
"@openai/codex-linux-arm64",
"@openai/codex-darwin-x64",
"@openai/codex-darwin-arm64",
"@openai/codex-win32-x64",
"@openai/codex-win32-arm64",
]);
});
});
+62
View File
@@ -9,6 +9,7 @@ import type { CommandOptions } from "../process/exec.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { captureEnv } from "../test-utils/env.js";
import {
listMissingRequiredPlatformPackages,
repairManagedNpmRootOpenClawPeer,
removeManagedNpmRootDependency,
readManagedNpmRootInstalledDependency,
@@ -99,6 +100,67 @@ function requireCommandOptions(
}
describe("managed npm root", () => {
it("finds explicitly required optional packages for the current platform", async () => {
const npmRoot = await makeTempRoot();
const matchingPackage = "@vendor/tool-platform";
const scriptedPackage = "@vendor/tool-scripted";
const foreignPackage = "@vendor/tool-foreign";
const unconstrainedPackage = "@vendor/tool-optional";
const unlistedPackage = "@vendor/tool-unlisted";
await fs.writeFile(
path.join(npmRoot, "package-lock.json"),
`${JSON.stringify({
lockfileVersion: 3,
packages: {
"": {},
[`node_modules/${matchingPackage}`]: {
optional: true,
os: [process.platform],
cpu: [process.arch],
},
[`node_modules/${scriptedPackage}`]: {
optional: true,
hasInstallScript: true,
os: [process.platform],
cpu: [process.arch],
},
[`node_modules/${foreignPackage}`]: {
optional: true,
os: [`not-${process.platform}`],
cpu: [process.arch],
},
[`node_modules/${unconstrainedPackage}`]: {
optional: true,
},
[`node_modules/${unlistedPackage}`]: {
optional: true,
os: [process.platform],
cpu: [process.arch],
},
},
})}\n`,
);
await expect(
listMissingRequiredPlatformPackages({
npmRoot,
requiredPackageNames: [
matchingPackage,
scriptedPackage,
foreignPackage,
unconstrainedPackage,
],
}),
).resolves.toEqual(
[matchingPackage, scriptedPackage]
.map((name) => ({
name,
packagePath: path.join(npmRoot, "node_modules", ...name.split("/")),
}))
.toSorted((left, right) => left.packagePath.localeCompare(right.packagePath)),
);
});
it("keeps existing plugin dependencies when adding another managed plugin", async () => {
const npmRoot = await makeTempRoot();
await fs.writeFile(
+74 -7
View File
@@ -374,13 +374,11 @@ function isUnsupportedOptionalLockPackage(value: unknown): boolean {
);
}
function readLockPackageName(location: string, value: unknown): string | undefined {
if (isRecord(value)) {
const packageName = readOptionalString(value.name);
if (packageName) {
return packageName;
}
}
function hasNpmPlatformConstraint(value: Record<string, unknown>): boolean {
return value.os !== undefined || value.cpu !== undefined || value.libc !== undefined;
}
function readLockPackageLocationName(location: string): string | undefined {
const parts = location.split("/");
for (let index = parts.length - 1; index >= 0; index -= 1) {
if (parts[index] !== "node_modules") {
@@ -399,10 +397,79 @@ function readLockPackageName(location: string, value: unknown): string | undefin
return undefined;
}
function readLockPackageName(location: string, value: unknown): string | undefined {
if (isRecord(value)) {
const packageName = readOptionalString(value.name);
if (packageName) {
return packageName;
}
}
return readLockPackageLocationName(location);
}
function resolveManagedNpmLockPackagePath(params: {
npmRoot: string;
location: string;
}): string | undefined {
const npmRoot = path.resolve(params.npmRoot);
const packagePath = path.resolve(npmRoot, ...params.location.split("/"));
const relativePath = path.relative(npmRoot, packagePath);
if (
!relativePath ||
relativePath === ".." ||
relativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativePath)
) {
return undefined;
}
return packagePath;
}
function isTopLevelLockPackageLocation(location: string): boolean {
return location.split("/").filter((part) => part === "node_modules").length === 1;
}
export type MissingRequiredPlatformPackage = {
name: string;
packagePath: string;
};
/** Lists explicitly required current-platform packages that npm recorded but did not materialize. */
export async function listMissingRequiredPlatformPackages(params: {
npmRoot: string;
requiredPackageNames: ReadonlySet<string> | readonly string[];
}): Promise<MissingRequiredPlatformPackage[]> {
const requiredPackageNames = new Set(params.requiredPackageNames);
if (requiredPackageNames.size === 0) {
return [];
}
const lockPath = path.join(params.npmRoot, "package-lock.json");
const parsed = await readJson<unknown>(lockPath);
if (!isRecord(parsed) || !isRecord(parsed.packages)) {
return [];
}
const missing: MissingRequiredPlatformPackage[] = [];
for (const [location, value] of Object.entries(parsed.packages)) {
if (
!isRecord(value) ||
value.optional !== true ||
!hasNpmPlatformConstraint(value) ||
isUnsupportedOptionalLockPackage(value)
) {
continue;
}
const name = readLockPackageLocationName(location);
const packagePath = resolveManagedNpmLockPackagePath({ npmRoot: params.npmRoot, location });
if (!name || !requiredPackageNames.has(name) || !isSafePackageName(name) || !packagePath) {
continue;
}
if (!(await pathExists(packagePath))) {
missing.push({ name, packagePath });
}
}
return missing.toSorted((left, right) => left.packagePath.localeCompare(right.packagePath));
}
function findLockPackageVersion(params: {
lockfile: ManagedNpmRootLockfile;
packageName: string;
+157
View File
@@ -287,6 +287,30 @@ function writeNpmRootPackageLock(params: {
);
}
function writeMissingCurrentPlatformOptionalPackage(params: {
npmRoot: string;
packageName: string;
packageLocation: string;
}): void {
const lockPath = path.join(params.npmRoot, "package-lock.json");
const lockfile = JSON.parse(fs.readFileSync(lockPath, "utf8")) as {
packages?: Record<string, unknown>;
};
lockfile.packages ??= {};
lockfile.packages[params.packageLocation] = {
name: params.packageName,
version: "1.0.0-platform",
optional: true,
os: [process.platform],
cpu: [process.arch],
};
fs.writeFileSync(lockPath, `${JSON.stringify(lockfile, null, 2)}\n`, "utf8");
fs.rmSync(path.join(params.npmRoot, ...params.packageLocation.split("/")), {
recursive: true,
force: true,
});
}
function readTextFileTree(dir: string, rootDir = dir): Record<string, string> {
return Object.fromEntries(
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
@@ -950,6 +974,139 @@ describe("installPluginFromNpmSpec", () => {
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, "missing-lock-plugin"))).toBe(false);
});
it("repairs omitted current-platform packages with a fresh npm cache", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
const packageName = "@openclaw/codex-fixture";
const platformPackage = "@vendor/codex-platform";
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
const platformPackageLocation = path.posix.join(
"node_modules",
packageName,
"node_modules",
platformPackage,
);
const warnings: string[] = [];
mockNpmViewAndInstall({
spec: `${packageName}@1.0.0`,
packageName,
version: "1.0.0",
pluginId: "codex-fixture",
npmRoot,
expectedDependencySpec: "1.0.0",
openclaw: {
extensions: ["./dist/index.js"],
install: { requiredPlatformPackages: [platformPackage] },
},
});
const delegate = runCommandWithTimeoutMock.getMockImplementation();
if (!delegate) {
throw new Error("expected npm mock implementation");
}
let managedInstallAttempts = 0;
let repairCacheDir = "";
runCommandWithTimeoutMock.mockImplementation(
async (argv: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
const result = await delegate(argv, options);
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
managedInstallAttempts += 1;
if (managedInstallAttempts === 1) {
writeMissingCurrentPlatformOptionalPackage({
npmRoot: npmProjectRoot,
packageName: platformPackage,
packageLocation: platformPackageLocation,
});
} else {
repairCacheDir = options.env?.npm_config_cache ?? "";
const packageDir = path.join(npmProjectRoot, ...platformPackageLocation.split("/"));
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({ name: platformPackage, version: "1.0.0-platform" }),
"utf8",
);
}
}
return result;
},
);
const result = await installPluginFromNpmSpec({
spec: `${packageName}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: (message) => warnings.push(message) },
});
expect(result.ok).toBe(true);
expect(managedInstallAttempts).toBe(2);
expect(repairCacheDir).toContain("openclaw-npm-cache-");
expect(fs.existsSync(repairCacheDir)).toBe(false);
expect(warnings).toContain(
`npm omitted current-platform package(s) ${platformPackage}; retrying once with a fresh cache.`,
);
});
it("rejects installs that still omit current-platform packages after repair", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
const packageName = "@openclaw/codex-fixture";
const platformPackage = "@vendor/codex-platform";
const npmProjectRoot = resolvePluginNpmProjectDir({ npmDir: npmRoot, packageName });
const platformPackageLocation = path.posix.join(
"node_modules",
packageName,
"node_modules",
platformPackage,
);
mockNpmViewAndInstall({
spec: `${packageName}@1.0.0`,
packageName,
version: "1.0.0",
pluginId: "codex-fixture",
npmRoot,
expectedDependencySpec: "1.0.0",
openclaw: {
extensions: ["./dist/index.js"],
install: { requiredPlatformPackages: [platformPackage] },
},
});
const delegate = runCommandWithTimeoutMock.getMockImplementation();
if (!delegate) {
throw new Error("expected npm mock implementation");
}
let managedInstallAttempts = 0;
runCommandWithTimeoutMock.mockImplementation(
async (argv: string[], options?: { cwd?: string }) => {
const result = await delegate(argv, options);
if (isManagedNpmInstallCommand(argv) && options?.cwd === npmProjectRoot) {
managedInstallAttempts += 1;
writeMissingCurrentPlatformOptionalPackage({
npmRoot: npmProjectRoot,
packageName: platformPackage,
packageLocation: platformPackageLocation,
});
}
return result;
},
);
const result = await installPluginFromNpmSpec({
spec: `${packageName}@1.0.0`,
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(managedInstallAttempts).toBe(2);
expect(result.error).toContain(
`npm install reported success but omitted required current-platform package(s): ${platformPackage}`,
);
expect(fs.existsSync(resolveTestPluginPackageDir(npmRoot, packageName))).toBe(false);
});
it("quarantines and rebuilds a corrupt managed npm project after npm from-argument failures", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const npmRoot = path.join(stateDir, "npm");
+123
View File
@@ -18,6 +18,7 @@ import {
import { resolveNpmIntegrityDriftWithDefaultMessage } from "../infra/npm-integrity.js";
import {
type ManagedNpmRootPeerDependencySnapshot,
listMissingRequiredPlatformPackages,
readManagedNpmRootInstalledDependency,
readManagedNpmRootPeerDependencySnapshot,
readOpenClawManagedNpmRootOverrides,
@@ -1070,6 +1071,41 @@ function resolveManagedNpmRootPackageDir(npmRoot: string, packageName: string):
return path.join(npmRoot, "node_modules", ...packageName.split("/"));
}
function resolveRequiredPlatformPackageNames(
packageMetadata?: OpenClawPackageManifest,
): { ok: true; packageNames: string[] } | { ok: false; error: string } {
const raw = packageMetadata?.install?.requiredPlatformPackages as unknown;
if (raw === undefined) {
return { ok: true, packageNames: [] };
}
if (!Array.isArray(raw)) {
return {
ok: false,
error: "package.json openclaw.install.requiredPlatformPackages must be an array",
};
}
const packageNames = new Set<string>();
for (const value of raw) {
if (typeof value !== "string") {
return {
ok: false,
error:
"package.json openclaw.install.requiredPlatformPackages must contain only npm package names",
};
}
const specError = validateRegistryNpmSpec(value);
const parsed = parseRegistryNpmSpec(value);
if (specError || !parsed || parsed.selectorKind !== "none") {
return {
ok: false,
error: `package.json openclaw.install.requiredPlatformPackages contains invalid package name: ${value}`,
};
}
packageNames.add(parsed.name);
}
return { ok: true, packageNames: [...packageNames] };
}
async function listNewManagedNpmRootPackageDirs(params: {
beforeInstallPackageNames: Set<string>;
npmRoot: string;
@@ -1407,6 +1443,93 @@ async function installPluginFromManagedNpmRoot(
"npm install could not settle managed peer dependencies after 10 sync passes; refusing to leave a partially reconciled plugin dependency tree.",
});
}
const packageManifestResult = await readOptionalPackageManifest({
runtime,
packageDir: installRoot,
});
if (!packageManifestResult.ok) {
return await rollbackFailedManagedNpmInstall(packageManifestResult);
}
const requiredPlatformPackageNames = resolveRequiredPlatformPackageNames(
packageManifestResult.manifest
? runtime.getPackageManifestMetadata(packageManifestResult.manifest)
: undefined,
);
if (!requiredPlatformPackageNames.ok) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: requiredPlatformPackageNames.error,
});
}
let omittedPlatformPackages: Awaited<ReturnType<typeof listMissingRequiredPlatformPackages>>;
try {
omittedPlatformPackages = await listMissingRequiredPlatformPackages({
npmRoot,
requiredPackageNames: requiredPlatformPackageNames.packageNames,
});
} catch (error) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `Failed to verify platform-specific npm dependencies for ${params.packageName}: ${String(error)}`,
});
}
if (omittedPlatformPackages.length > 0) {
const omittedPlatformPackageNames = omittedPlatformPackages.map((entry) => entry.name);
logger.warn?.(
`npm omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}; retrying once with a fresh cache.`,
);
let freshCacheDir: string | undefined;
try {
freshCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-npm-cache-"));
install = await runCommandWithTimeout(npmInstallArgs, {
...npmInstallOptions,
env: {
...npmInstallOptions.env,
NPM_CONFIG_CACHE: freshCacheDir,
npm_config_cache: freshCacheDir,
},
});
} catch (error) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `Failed to repair omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}: ${String(error)}`,
});
} finally {
if (freshCacheDir) {
try {
await fs.rm(freshCacheDir, { recursive: true, force: true });
} catch (error) {
logger.warn?.(
`Failed to remove temporary npm cache ${freshCacheDir}: ${String(error)}`,
);
}
}
}
if (install.code !== 0) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `npm install failed while repairing omitted current-platform package(s) ${omittedPlatformPackageNames.join(", ")}: ${formatNpmCommandFailureOutput(install)}`,
});
}
let stillOmittedPlatformPackages: typeof omittedPlatformPackages;
try {
stillOmittedPlatformPackages = await listMissingRequiredPlatformPackages({
npmRoot,
requiredPackageNames: requiredPlatformPackageNames.packageNames,
});
} catch (error) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `Failed to verify repaired platform-specific npm dependencies for ${params.packageName}: ${String(error)}`,
});
}
if (stillOmittedPlatformPackages.length > 0) {
return await rollbackFailedManagedNpmInstall({
ok: false,
error: `npm install reported success but omitted required current-platform package(s): ${stillOmittedPlatformPackages.map((entry) => entry.name).join(", ")}`,
});
}
}
if (params.packageName !== "openclaw") {
const repairedOpenClawPeer = await repairManagedNpmRootOpenClawPeer({
npmRoot,
+1
View File
@@ -1953,6 +1953,7 @@ export type PluginPackageInstall = {
minHostVersion?: string;
expectedIntegrity?: string;
allowInvalidConfigRecovery?: boolean;
requiredPlatformPackages?: string[];
};
export type OpenClawPackageStartup = {