feat(plugins): report dependency install state

This commit is contained in:
Peter Steinberger
2026-05-02 19:50:35 +01:00
parent f4e70ec333
commit 4a3ad3963b
12 changed files with 291 additions and 12 deletions

View File

@@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
### Fixes
- Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.

View File

@@ -237,11 +237,17 @@ openclaw plugins search <query> --json
Switch from the table view to per-plugin detail lines with source/origin/version/activation metadata.
</ParamField>
<ParamField path="--json" type="boolean">
Machine-readable inventory plus registry diagnostics.
Machine-readable inventory plus registry diagnostics and package dependency install state.
</ParamField>
<Note>
`plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process.
`plugins list --json` includes each plugin's `dependencyStatus` from `package.json`
`dependencies` and `optionalDependencies`. OpenClaw checks whether those package
names are present along the plugin's normal Node `node_modules` lookup path; it
does not import plugin runtime code, run a package manager, or repair missing
dependencies.
</Note>
`plugins search` is a remote ClawHub catalog lookup. It does not inspect local

View File

@@ -120,6 +120,8 @@ installed under OpenClaw's managed plugin roots. npm dependencies may be hoisted
within OpenClaw's managed npm root; install/update scans that managed root before
trust and uninstall removes npm-managed packages through npm. External plugins
and custom load paths must still be installed through `openclaw plugins install`.
Use `openclaw plugins list --json` to see the static `dependencyStatus` for each
visible plugin without importing runtime code or repairing dependencies.
See [Plugin dependency resolution](/plugins/dependency-resolution) for the
install-time lifecycle.

View File

@@ -32,6 +32,10 @@ import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from ".
import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { resolvePluginSourceRoots } from "./roots.js";
import {
normalizePluginDependencySpecs,
type PluginDependencySpecMap,
} from "./status-dependencies.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
const SCANNED_DIRECTORY_IGNORE_NAMES = new Set([
@@ -61,6 +65,8 @@ export type PluginCandidate = {
packageDescription?: string;
packageDir?: string;
packageManifest?: OpenClawPackageManifest;
packageDependencies?: PluginDependencySpecMap;
packageOptionalDependencies?: PluginDependencySpecMap;
bundledManifest?: PluginManifest;
bundledManifestPath?: string;
};
@@ -494,6 +500,10 @@ function addCandidate(params: {
}
params.seen.add(resolved);
const manifest = params.manifest ?? null;
const packageDependencies = normalizePluginDependencySpecs({
dependencies: manifest?.dependencies,
optionalDependencies: manifest?.optionalDependencies,
});
params.candidates.push({
idHint: params.idHint,
source: resolved,
@@ -508,6 +518,8 @@ function addCandidate(params: {
packageDescription: normalizeOptionalString(manifest?.description),
packageDir: params.packageDir,
packageManifest: getPackageManifestMetadata(manifest ?? undefined),
packageDependencies: packageDependencies.dependencies,
packageOptionalDependencies: packageDependencies.optionalDependencies,
bundledManifest: params.bundledManifest,
bundledManifestPath: params.bundledManifestPath,
});
@@ -518,6 +530,7 @@ function discoverBundleInRoot(params: {
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
manifest?: PackageManifest | null;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
@@ -554,6 +567,8 @@ function discoverBundleInRoot(params: {
bundleFormat,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest: params.manifest,
packageDir: params.rootDir,
realpathCache: params.realpathCache,
});
return "added";
@@ -683,6 +698,7 @@ function discoverInDirectory(params: {
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
@@ -882,6 +898,7 @@ function discoverFromPath(params: {
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,

View File

@@ -14,6 +14,10 @@ import {
type PackageManifest,
} from "./manifest.js";
import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js";
import {
normalizePluginDependencySpecs,
type PluginDependencySpecMap,
} from "./status-dependencies.js";
function resolvePackageJsonPath(record: InstalledPluginIndexRecord): string | undefined {
if (!record.packageJson?.path) {
@@ -101,9 +105,11 @@ function resolveFallbackPluginSource(record: InstalledPluginIndexRecord): string
return path.join(rootDir, DEFAULT_PLUGIN_ENTRY_CANDIDATES[0]);
}
function resolveInstalledPackageManifest(
record: InstalledPluginIndexRecord,
): OpenClawPackageManifest | undefined {
function resolveInstalledPackageMetadata(record: InstalledPluginIndexRecord): {
packageManifest?: OpenClawPackageManifest;
packageDependencies?: PluginDependencySpecMap;
packageOptionalDependencies?: PluginDependencySpecMap;
} {
const fallbackPackageManifest = record.packageChannel
? {
channel: record.packageChannel,
@@ -114,17 +120,25 @@ function resolveInstalledPackageManifest(
? path.resolve(rootDir, record.packageJson.path)
: undefined;
if (!packageJsonPath) {
return fallbackPackageManifest;
return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {};
}
const relative = path.relative(rootDir, packageJsonPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return fallbackPackageManifest;
return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {};
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PackageManifest;
const packageManifest = getPackageManifestMetadata(packageJson);
const dependencies = normalizePluginDependencySpecs({
dependencies: packageJson.dependencies,
optionalDependencies: packageJson.optionalDependencies,
});
if (!packageManifest) {
return fallbackPackageManifest;
return {
...(fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {}),
packageDependencies: dependencies.dependencies,
packageOptionalDependencies: dependencies.optionalDependencies,
};
}
const channel =
record.packageChannel || packageManifest.channel
@@ -134,17 +148,21 @@ function resolveInstalledPackageManifest(
}
: undefined;
return {
...packageManifest,
...(channel ? { channel } : {}),
packageManifest: {
...packageManifest,
...(channel ? { channel } : {}),
},
packageDependencies: dependencies.dependencies,
packageOptionalDependencies: dependencies.optionalDependencies,
};
} catch {
return fallbackPackageManifest;
return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {};
}
}
function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate {
const rootDir = resolveInstalledPluginRootDir(record);
const packageManifest = resolveInstalledPackageManifest(record);
const packageMetadata = resolveInstalledPackageMetadata(record);
return {
idHint: record.pluginId,
source: record.source ?? resolveFallbackPluginSource(record),
@@ -155,7 +173,15 @@ function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate
...(record.bundleFormat ? { bundleFormat: record.bundleFormat } : {}),
...(record.packageName ? { packageName: record.packageName } : {}),
...(record.packageVersion ? { packageVersion: record.packageVersion } : {}),
...(packageManifest ? { packageManifest } : {}),
...(packageMetadata.packageManifest
? { packageManifest: packageMetadata.packageManifest }
: {}),
...(packageMetadata.packageDependencies
? { packageDependencies: packageMetadata.packageDependencies }
: {}),
...(packageMetadata.packageOptionalDependencies
? { packageOptionalDependencies: packageMetadata.packageOptionalDependencies }
: {}),
packageDir: rootDir,
};
}

View File

@@ -46,6 +46,7 @@ import { checkMinHostVersion } from "./min-host-version.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
import type { PluginKind } from "./plugin-kind.types.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import type { PluginDependencySpecMap } from "./status-dependencies.js";
/**
* Resolve a plugin source path, falling back from .ts to .js when the
@@ -130,6 +131,8 @@ export type PluginManifestRecord = {
activation?: PluginManifestActivation;
setup?: PluginManifestSetup;
packageManifest?: OpenClawPackageManifest;
packageDependencies?: PluginDependencySpecMap;
packageOptionalDependencies?: PluginDependencySpecMap;
packageChannel?: PluginPackageChannel;
packageInstall?: PluginPackageInstall;
qaRunners?: PluginManifestQaRunner[];
@@ -316,6 +319,8 @@ function buildRecord(params: {
activation: params.manifest.activation,
setup: params.manifest.setup,
packageManifest: params.candidate.packageManifest,
packageDependencies: params.candidate.packageDependencies,
packageOptionalDependencies: params.candidate.packageOptionalDependencies,
packageChannel: params.candidate.packageManifest?.channel,
packageInstall: params.candidate.packageManifest?.install,
qaRunners: params.manifest.qaRunners,
@@ -385,6 +390,8 @@ function buildBundleRecord(params: {
packageVersion: params.candidate.packageVersion,
packageDescription: params.candidate.packageDescription,
packageManifest: params.candidate.packageManifest,
packageDependencies: params.candidate.packageDependencies,
packageOptionalDependencies: params.candidate.packageOptionalDependencies,
packageChannel: params.candidate.packageManifest?.channel,
packageInstall: params.candidate.packageManifest?.install,
format: "bundle",

View File

@@ -1737,6 +1737,8 @@ export type PackageManifest = {
name?: string;
version?: string;
description?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
} & Partial<Record<ManifestKey, OpenClawPackageManifest>>;
export function getPackageManifestMetadata(

View File

@@ -30,6 +30,7 @@ import type { PluginManifestContracts } from "./manifest.js";
import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js";
import type { PluginKind } from "./plugin-kind.types.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { PluginDependencyStatus } from "./status-dependencies.js";
import type {
CliBackendPlugin,
ImageGenerationProviderPlugin,
@@ -377,6 +378,7 @@ export type PluginRecord = {
configJsonSchema?: JsonSchemaObject;
contracts?: PluginManifestContracts;
memorySlotSelected?: boolean;
dependencyStatus?: PluginDependencyStatus;
};
export type PluginRegistry = {

View File

@@ -0,0 +1,135 @@
import fs from "node:fs";
import path from "node:path";
export type PluginDependencySpecMap = Record<string, string>;
export type PluginDependencyEntry = {
name: string;
spec: string;
installed: boolean;
optional: boolean;
resolvedPath?: string;
};
export type PluginDependencyStatus = {
hasDependencies: boolean;
installed: boolean;
requiredInstalled: boolean;
optionalInstalled: boolean;
missing: string[];
missingOptional: string[];
dependencies: PluginDependencyEntry[];
optionalDependencies: PluginDependencyEntry[];
};
function normalizeDependencyMap(raw: unknown): PluginDependencySpecMap {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const normalized: PluginDependencySpecMap = {};
for (const [name, spec] of Object.entries(raw)) {
const normalizedName = name.trim();
if (!normalizedName || typeof spec !== "string" || !spec.trim()) {
continue;
}
normalized[normalizedName] = spec.trim();
}
return normalized;
}
export function normalizePluginDependencySpecs(params: {
dependencies?: unknown;
optionalDependencies?: unknown;
}): {
dependencies: PluginDependencySpecMap;
optionalDependencies: PluginDependencySpecMap;
} {
return {
dependencies: normalizeDependencyMap(params.dependencies),
optionalDependencies: normalizeDependencyMap(params.optionalDependencies),
};
}
function dependencyPathSegments(name: string): string[] | null {
const segments = name.split("/");
if (segments.length === 1 && segments[0]) {
return [segments[0]];
}
if (segments.length === 2 && segments[0]?.startsWith("@") && segments[1]) {
return segments;
}
return null;
}
function findDependencyPackageDir(params: { fromDir: string; name: string }): string | undefined {
const segments = dependencyPathSegments(params.name);
if (!segments) {
return undefined;
}
let current = path.resolve(params.fromDir);
while (true) {
const candidate = path.join(current, "node_modules", ...segments);
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
function buildDependencyEntries(params: {
rootDir: string | undefined;
dependencies: PluginDependencySpecMap;
optional: boolean;
}): PluginDependencyEntry[] {
return Object.entries(params.dependencies)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([name, spec]) => {
const resolvedPath = params.rootDir
? findDependencyPackageDir({ fromDir: params.rootDir, name })
: undefined;
return {
name,
spec,
installed: resolvedPath !== undefined,
optional: params.optional,
...(resolvedPath ? { resolvedPath } : {}),
};
});
}
export function buildPluginDependencyStatus(params: {
rootDir?: string;
dependencies?: PluginDependencySpecMap;
optionalDependencies?: PluginDependencySpecMap;
}): PluginDependencyStatus {
const dependencies = buildDependencyEntries({
rootDir: params.rootDir,
dependencies: params.dependencies ?? {},
optional: false,
});
const optionalDependencies = buildDependencyEntries({
rootDir: params.rootDir,
dependencies: params.optionalDependencies ?? {},
optional: true,
});
const missing = dependencies.filter((entry) => !entry.installed).map((entry) => entry.name);
const missingOptional = optionalDependencies
.filter((entry) => !entry.installed)
.map((entry) => entry.name);
const requiredInstalled = missing.length === 0;
const optionalInstalled = missingOptional.length === 0;
return {
hasDependencies: dependencies.length > 0 || optionalDependencies.length > 0,
installed: requiredInstalled,
requiredInstalled,
optionalInstalled,
missing,
missingOptional,
dependencies,
optionalDependencies,
};
}

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { refreshPluginRegistry } from "./plugin-registry.js";
import { buildPluginRegistrySnapshotReport, buildPluginSnapshotReport } from "./status.js";
@@ -73,6 +74,69 @@ describe("buildPluginRegistrySnapshotReport", () => {
expect(isColdPluginRuntimeLoaded(fixture)).toBe(false);
});
it("reports package dependency install state without importing plugin runtime", () => {
const rootDir = makeTempDir();
const fixture = createColdPluginFixture({
rootDir,
pluginId: "dependency-demo",
packageJson: {
dependencies: {
"missing-required": "1.0.0",
"present-required": "1.0.0",
},
optionalDependencies: {
"missing-optional": "1.0.0",
},
},
manifest: {
id: "dependency-demo",
name: "Dependency Demo",
},
});
fs.mkdirSync(path.join(rootDir, "node_modules", "present-required"), { recursive: true });
const report = buildPluginRegistrySnapshotReport({
config: {
plugins: {
load: { paths: [fixture.rootDir] },
},
},
});
const plugin = report.plugins.find((entry) => entry.id === "dependency-demo");
expect(plugin?.dependencyStatus).toMatchObject({
hasDependencies: true,
installed: false,
requiredInstalled: false,
optionalInstalled: false,
missing: ["missing-required"],
missingOptional: ["missing-optional"],
dependencies: [
{
name: "missing-required",
spec: "1.0.0",
installed: false,
optional: false,
},
{
name: "present-required",
spec: "1.0.0",
installed: true,
optional: false,
},
],
optionalDependencies: [
{
name: "missing-optional",
spec: "1.0.0",
installed: false,
optional: true,
},
],
});
expect(isColdPluginRuntimeLoaded(fixture)).toBe(false);
});
it("replays persisted list metadata without importing plugin runtime", async () => {
const fixture = createColdPluginFixture({
rootDir: makeTempDir(),

View File

@@ -37,6 +37,7 @@ import {
resolvePluginRuntimeLoadContext,
} from "./runtime/load-context.js";
import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js";
import { buildPluginDependencyStatus } from "./status-dependencies.js";
import type { PluginHookName, PluginLogger } from "./types.js";
export type PluginStatusReport = PluginRegistry & {
@@ -212,6 +213,11 @@ function buildPluginRecordFromInstalledIndex(
hookCount: 0,
configSchema: false,
contracts: {},
dependencyStatus: buildPluginDependencyStatus({
rootDir: plugin.rootDir,
dependencies: manifest?.packageDependencies,
optionalDependencies: manifest?.packageOptionalDependencies,
}),
};
}
@@ -363,6 +369,14 @@ function buildPluginReport(
Object.assign({}, plugin, {
imported: plugin.format !== `bundle` && importedPluginIds.has(plugin.id),
version: resolveReportedPluginVersion(plugin, params?.env),
dependencyStatus:
plugin.dependencyStatus ??
buildPluginDependencyStatus({
rootDir: plugin.rootDir,
dependencies: metadataSnapshot?.byPluginId.get(plugin.id)?.packageDependencies,
optionalDependencies: metadataSnapshot?.byPluginId.get(plugin.id)
?.packageOptionalDependencies,
}),
}),
),
};

View File

@@ -17,6 +17,7 @@ type ColdPluginFixtureOptions = {
pluginId?: string;
packageName?: string;
packageVersion?: string;
packageJson?: Record<string, unknown>;
providerId?: string;
channelId?: string;
authChoiceId?: string;
@@ -37,6 +38,7 @@ export function createColdPluginFixture(options: ColdPluginFixtureOptions): Cold
{
name: options.packageName ?? "@example/openclaw-cold-control-plane",
version: options.packageVersion ?? "1.0.0",
...options.packageJson,
openclaw: { extensions: ["./index.cjs"] },
},
null,