From 772bdeb18e366e1b706d7634c9f3636c2e5f2aeb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 16 Jun 2026 14:00:00 +0800 Subject: [PATCH] fix(plugins): repair missing required platform packages --- docs/plugins/manifest.md | 8 ++ docs/plugins/sdk-setup.md | 1 + extensions/codex/package.json | 10 +- extensions/codex/src/manifest.test.ts | 13 +++ src/infra/npm-managed-root.test.ts | 62 ++++++++++ src/infra/npm-managed-root.ts | 81 +++++++++++-- src/plugins/install.npm-spec.test.ts | 157 ++++++++++++++++++++++++++ src/plugins/install.ts | 123 ++++++++++++++++++++ src/plugins/manifest.ts | 1 + 9 files changed, 448 insertions(+), 8 deletions(-) diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 95aa5217908f..5de3abcc661d 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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 diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index fcb1f9941236..8090e45dbf2e 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -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. | diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 25cadecd78ad..e4f78fc6a588 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -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" diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts index 5a16810513f1..77afd689467f 100644 --- a/extensions/codex/src/manifest.test.ts +++ b/extensions/codex/src/manifest.test.ts @@ -6,6 +6,11 @@ import { MANAGED_CODEX_APP_SERVER_PACKAGE_VERSION } from "./app-server/version.j type CodexPackageManifest = { dependencies?: Record; devDependencies?: Record; + 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", + ]); }); }); diff --git a/src/infra/npm-managed-root.test.ts b/src/infra/npm-managed-root.test.ts index 9261f62cb970..ae1d030f519a 100644 --- a/src/infra/npm-managed-root.test.ts +++ b/src/infra/npm-managed-root.test.ts @@ -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( diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index e0f7350b9c51..2220851b3406 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -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): 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 | readonly string[]; +}): Promise { + const requiredPackageNames = new Set(params.requiredPackageNames); + if (requiredPackageNames.size === 0) { + return []; + } + const lockPath = path.join(params.npmRoot, "package-lock.json"); + const parsed = await readJson(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; diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 1f6eefc9b845..095f13a06528 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -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; + }; + 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 { 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"); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 983e54f6aac0..9fe19f8bb271 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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(); + 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; 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>; + 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, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index e3f63998503b..0071be16e72e 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1953,6 +1953,7 @@ export type PluginPackageInstall = { minHostVersion?: string; expectedIntegrity?: string; allowInvalidConfigRecovery?: boolean; + requiredPlatformPackages?: string[]; }; export type OpenClawPackageStartup = {