perf(cli): lazy-load doctor plugin paths (#69840)

Merged via squash.

Prepared head SHA: ebf93ad913
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-21 18:17:19 -04:00
committed by GitHub
parent 0e1d324dd8
commit 66add9fcd9
15 changed files with 608 additions and 196 deletions

View File

@@ -2,6 +2,12 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras.
## 2026.4.21
### Changes

View File

@@ -13,6 +13,27 @@ read_when:
Every plugin exports a default entry object. The SDK provides three helpers for
creating them.
For installed plugins, `package.json` should point runtime loading at built
JavaScript when available:
```json
{
"openclaw": {
"extensions": ["./src/index.ts"],
"runtimeExtensions": ["./dist/index.js"],
"setupEntry": "./src/setup-entry.ts",
"runtimeSetupEntry": "./dist/setup-entry.js"
}
}
```
`extensions` and `setupEntry` remain valid source entries for workspace and git
checkout development. `runtimeExtensions` and `runtimeSetupEntry` are preferred
when OpenClaw loads an installed package and let npm packages avoid runtime
TypeScript compilation. If an installed package only declares a TypeScript
source entry, OpenClaw will use a matching built `dist/*.js` peer when one
exists, then fall back to the TypeScript source.
<Tip>
**Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins)
or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides.

View File

@@ -297,7 +297,8 @@ Code plugins must include the required OpenClaw metadata in `package.json`:
"version": "1.0.0",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"],
"extensions": ["./src/index.ts"],
"runtimeExtensions": ["./dist/index.js"],
"compat": {
"pluginApi": ">=2026.3.24-beta.2",
"minGatewayVersion": "2026.3.24-beta.2"
@@ -310,6 +311,11 @@ Code plugins must include the required OpenClaw metadata in `package.json`:
}
```
Published packages should ship built JavaScript and point `runtimeExtensions`
at that output. Git checkout installs can still fall back to TypeScript source
when no built files exist, but built runtime entries avoid runtime TypeScript
compilation in startup, doctor, and plugin loading paths.
## Advanced details (technical)
### Versioning and tags

View File

@@ -68,6 +68,30 @@ describe("channel plugin module loader helpers", () => {
expect(isJavaScriptModulePath("/tmp/entry.ts")).toBe(false);
});
it("uses native require for eligible JavaScript modules before falling back to Jiti", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ ok: false })));
vi.doMock("jiti", () => ({
createJiti,
}));
const loaderModule = await importFreshModule<typeof import("./module-loader.js")>(
import.meta.url,
"./module-loader.js?scope=native-require",
);
const rootDir = createTempDir();
const modulePath = path.join(rootDir, "dist", "extensions", "demo", "index.cjs");
fs.mkdirSync(path.dirname(modulePath), { recursive: true });
fs.writeFileSync(modulePath, "module.exports = { ok: true };\n", "utf8");
expect(
loaderModule.loadChannelPluginModule({
modulePath,
rootDir,
shouldTryNativeRequire: () => true,
}),
).toEqual({ ok: true });
expect(createJiti).not.toHaveBeenCalled();
});
it("keeps Windows dist loads off Jiti native import", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ ok: true })));
vi.doMock("jiti", () => ({

View File

@@ -1,14 +1,12 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { openBoundaryFileSync } from "../../infra/boundary-file-read.js";
import {
getCachedPluginJitiLoader,
type PluginJitiLoaderCache,
} from "../../plugins/jiti-loader-cache.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
const nodeRequire = createRequire(import.meta.url);
import { tryNativeRequireJavaScriptModule } from "../../plugins/native-module-require.js";
export { isJavaScriptModulePath } from "../../plugins/native-module-require.js";
function createModuleLoader() {
const jitiLoaders: PluginJitiLoaderCache = new Map();
@@ -27,12 +25,6 @@ function createModuleLoader() {
let loadModule = createModuleLoader();
export function isJavaScriptModulePath(modulePath: string): boolean {
return [".js", ".mjs", ".cjs"].includes(
normalizeLowercaseStringOrEmpty(path.extname(modulePath)),
);
}
export function resolveCompiledBundledModulePath(modulePath: string): string {
const compiledDistModulePath = modulePath.replace(
`${path.sep}dist-runtime${path.sep}`,
@@ -91,11 +83,12 @@ export function loadChannelPluginModule(params: {
}
const safePath = opened.path;
fs.closeSync(opened.fd);
if (process.platform === "win32" && params.shouldTryNativeRequire?.(safePath)) {
try {
return nodeRequire(safePath);
} catch {
// Fall back to the Jiti loader path when require() cannot handle the entry.
if (params.shouldTryNativeRequire?.(safePath)) {
const nativeModule = tryNativeRequireJavaScriptModule(safePath, {
allowWindows: true,
});
if (nativeModule.ok) {
return nativeModule.moduleExport;
}
}
return loadModule(safePath)(safePath);

View File

@@ -1,12 +1,7 @@
import { formatCliCommand } from "../cli/command-format.js";
import { findLegacyConfigIssues } from "../config/legacy.js";
import { CONFIG_PATH } from "../config/paths.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
collectRelevantDoctorPluginIds,
listPluginDoctorLegacyConfigRules,
} from "../plugins/doctor-contract-registry.js";
import { note } from "../terminal/note.js";
import { noteOpencodeProviderOverrides } from "./doctor-config-analysis.js";
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
@@ -14,12 +9,6 @@ import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
import type { DoctorOptions } from "./doctor-prompter.js";
import { emitDoctorNotes } from "./doctor/emit-notes.js";
import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js";
import { runDoctorRepairSequence } from "./doctor/repair-sequencing.js";
import {
collectChannelDoctorMutableAllowlistWarnings,
collectChannelDoctorStaleConfigMutations,
runChannelDoctorConfigSequences,
} from "./doctor/shared/channel-doctor.js";
import {
applyLegacyCompatibilityStep,
applyUnknownConfigKeyStep,
@@ -29,7 +18,6 @@ import {
collectMissingDefaultAccountBindingWarnings,
collectMissingExplicitDefaultAccountWarnings,
} from "./doctor/shared/default-account-warnings.js";
import { collectDoctorPreviewWarnings } from "./doctor/shared/preview-warnings.js";
function hasLegacyInternalHookHandlers(raw: unknown): boolean {
const handlers = (raw as { hooks?: { internal?: { handlers?: unknown } } })?.hooks?.internal
@@ -37,6 +25,17 @@ function hasLegacyInternalHookHandlers(raw: unknown): boolean {
return Array.isArray(handlers) && handlers.length > 0;
}
function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
const channels =
cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels)
? cfg.channels
: null;
if (!channels) {
return [];
}
return Object.keys(channels).filter((channelId) => channelId !== "defaults");
}
export async function loadAndMaybeMigrateDoctorConfig(params: {
options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
@@ -58,16 +57,20 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
doctorFixCommand,
});
({ cfg, candidate, pendingChanges, fixHints } = legacyStep.state);
const pluginLegacyIssues =
snapshot.parsed === snapshot.sourceConfig
? []
: findLegacyConfigIssues(
snapshot.parsed,
snapshot.parsed,
listPluginDoctorLegacyConfigRules({
pluginIds: collectRelevantDoctorPluginIds(snapshot.parsed),
}),
);
const pluginLegacyIssues = await (async () => {
if (snapshot.parsed === snapshot.sourceConfig) {
return [];
}
const { collectRelevantDoctorPluginIds, listPluginDoctorLegacyConfigRules } =
await import("../plugins/doctor-contract-registry.js");
return findLegacyConfigIssues(
snapshot.parsed,
snapshot.parsed,
listPluginDoctorLegacyConfigRules({
pluginIds: collectRelevantDoctorPluginIds(snapshot.parsed),
}),
);
})();
const seenLegacyIssues = new Set(
snapshot.legacyIssues.map((issue) => `${issue.path}:${issue.message}`),
);
@@ -117,6 +120,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}));
}
const { applyPluginAutoEnable } = await import("../config/plugin-auto-enable.js");
const autoEnable = applyPluginAutoEnable({ config: candidate, env: process.env });
if (autoEnable.changes.length > 0) {
note(autoEnable.changes.join("\n"), "Doctor changes");
@@ -128,28 +132,38 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}));
}
const channelDoctorSequence = await runChannelDoctorConfigSequences({
cfg: candidate,
env: process.env,
shouldRepair,
});
emitDoctorNotes({
note,
changeNotes: channelDoctorSequence.changeNotes,
warningNotes: channelDoctorSequence.warningNotes,
});
for (const staleCleanup of await collectChannelDoctorStaleConfigMutations(candidate)) {
if (staleCleanup.changes.length === 0) {
continue;
}
note(staleCleanup.changes.join("\n"), "Doctor changes");
({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({
state: { cfg, candidate, pendingChanges, fixHints },
mutation: staleCleanup,
const hasConfiguredChannels = collectConfiguredChannelIds(candidate).length > 0;
let collectMutableAllowlistWarnings:
| typeof import("./doctor/shared/channel-doctor.js").collectChannelDoctorMutableAllowlistWarnings
| undefined;
if (hasConfiguredChannels) {
const channelDoctor = await import("./doctor/shared/channel-doctor.js");
collectMutableAllowlistWarnings = channelDoctor.collectChannelDoctorMutableAllowlistWarnings;
const channelDoctorSequence = await channelDoctor.runChannelDoctorConfigSequences({
cfg: candidate,
env: process.env,
shouldRepair,
fixHint: `Run "${doctorFixCommand}" to remove stale channel plugin references.`,
}));
});
emitDoctorNotes({
note,
changeNotes: channelDoctorSequence.changeNotes,
warningNotes: channelDoctorSequence.warningNotes,
});
for (const staleCleanup of await channelDoctor.collectChannelDoctorStaleConfigMutations(
candidate,
)) {
if (staleCleanup.changes.length === 0) {
continue;
}
note(staleCleanup.changes.join("\n"), "Doctor changes");
({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({
state: { cfg, candidate, pendingChanges, fixHints },
mutation: staleCleanup,
shouldRepair,
fixHint: `Run "${doctorFixCommand}" to remove stale channel plugin references.`,
}));
}
}
const missingDefaultAccountBindingWarnings =
@@ -163,6 +177,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}
if (shouldRepair) {
const { runDoctorRepairSequence } = await import("./doctor/repair-sequencing.js");
const repairSequence = await runDoctorRepairSequence({
state: { cfg, candidate, pendingChanges, fixHints },
doctorFixCommand,
@@ -174,6 +189,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
warningNotes: repairSequence.warningNotes,
});
} else {
const { collectDoctorPreviewWarnings } = await import("./doctor/shared/preview-warnings.js");
emitDoctorNotes({
note,
warningNotes: await collectDoctorPreviewWarnings({
@@ -183,9 +199,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
});
}
const mutableAllowlistWarnings = await collectChannelDoctorMutableAllowlistWarnings({
cfg: candidate,
});
const mutableAllowlistWarnings = collectMutableAllowlistWarnings
? await collectMutableAllowlistWarnings({
cfg: candidate,
})
: [];
if (mutableAllowlistWarnings.length > 0) {
note(mutableAllowlistWarnings.join("\n"), "Doctor warnings");
}

View File

@@ -1 +1,7 @@
export { doctorCommand } from "../flows/doctor-health.js";
import type { RuntimeEnv } from "../runtime.js";
import type { DoctorOptions } from "./doctor-prompter.js";
export async function doctorCommand(runtime?: RuntimeEnv, options?: DoctorOptions): Promise<void> {
const doctorHealth = await import("../flows/doctor-health.js");
await doctorHealth.doctorCommand(runtime, options);
}

View File

@@ -1,64 +1,9 @@
import fs from "node:fs";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import {
getModelRefStatus,
resolveConfiguredModelRef,
resolveHooksGmailModel,
} from "../agents/model-selection.js";
import { formatCliCommand } from "../cli/command-format.js";
import { maybeRepairLegacyOAuthProfileIds } from "../commands/doctor-auth-legacy-oauth.js";
import { noteAuthProfileHealth, noteLegacyCodexProviderOverride } from "../commands/doctor-auth.js";
import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js";
import { noteChromeMcpBrowserReadiness } from "../commands/doctor-browser.js";
import { maybeRepairBundledPluginRuntimeDeps } from "../commands/doctor-bundled-plugin-runtime-deps.js";
import { noteClaudeCliHealth } from "../commands/doctor-claude-cli.js";
import { doctorShellCompletion } from "../commands/doctor-completion.js";
import { maybeRepairLegacyCronStore } from "../commands/doctor-cron.js";
import { noteDevicePairingHealth } from "../commands/doctor-device-pairing.js";
import { maybeRepairGatewayDaemon } from "../commands/doctor-gateway-daemon-flow.js";
import { checkGatewayHealth, probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
import {
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
} from "../commands/doctor-gateway-services.js";
import {
maybeRepairMemoryRecallHealth,
noteMemoryRecallHealth,
noteMemorySearchHealth,
} from "../commands/doctor-memory-search.js";
import {
noteMacLaunchAgentOverrides,
noteMacLaunchctlGatewayEnvOverrides,
} from "../commands/doctor-platform-notes.js";
import { maybeRepairLegacyPluginManifestContracts } from "../commands/doctor-plugin-manifests.js";
import type { probeGatewayMemoryStatus } from "../commands/doctor-gateway-health.js";
import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js";
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "../commands/doctor-sandbox.js";
import { noteSecurityWarnings } from "../commands/doctor-security.js";
import { noteSessionLockHealth } from "../commands/doctor-session-locks.js";
import { noteStateIntegrity, noteWorkspaceBackupTip } from "../commands/doctor-state-integrity.js";
import {
detectLegacyStateMigrations,
runLegacyStateMigrations,
} from "../commands/doctor-state-migrations.js";
import { noteWorkspaceStatus } from "../commands/doctor-workspace-status.js";
import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } from "../commands/doctor-workspace.js";
import { noteOpenAIOAuthTlsPrerequisites } from "../commands/oauth-tls-preflight.js";
import { applyWizardMetadata, randomToken } from "../commands/onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "../commands/systemd-linger.js";
import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveGatewayService } from "../daemon/service.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import type { buildGatewayConnectionDetails } from "../gateway/call.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import { maybeRunDoctorStartupChannelMaintenance } from "./doctor-startup-channel-maintenance.js";
import type { FlowContribution } from "./types.js";
export type DoctorFlowMode = "local" | "remote";
@@ -115,6 +60,9 @@ function createDoctorHealthContribution(params: {
}
async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { formatCliCommand } = await import("../cli/command-format.js");
const { hasAmbiguousGatewayAuthModeConfig } = await import("../gateway/auth-mode-policy.js");
const { note } = await import("../terminal/note.js");
if (!ctx.cfg.gateway?.mode) {
const lines = [
"gateway.mode is unset; gateway start will be blocked.",
@@ -140,6 +88,12 @@ async function runGatewayConfigHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairLegacyOAuthProfileIds } =
await import("../commands/doctor-auth-legacy-oauth.js");
const { noteAuthProfileHealth, noteLegacyCodexProviderOverride } =
await import("../commands/doctor-auth.js");
const { buildGatewayConnectionDetails } = await import("../gateway/call.js");
const { note } = await import("../terminal/note.js");
ctx.cfg = await maybeRepairLegacyOAuthProfileIds(ctx.cfg, ctx.prompter);
await noteAuthProfileHealth({
cfg: ctx.cfg,
@@ -154,6 +108,10 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise<void>
}
async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { resolveSecretInputRef } = await import("../config/types.secrets.js");
const { resolveGatewayAuth } = await import("../gateway/auth.js");
const { note } = await import("../terminal/note.js");
const { randomToken } = await import("../commands/onboard-helpers.js");
if (resolveDoctorMode(ctx.cfg) !== "local" || !ctx.sourceConfigValid) {
return;
}
@@ -213,10 +171,14 @@ async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise<void>
}
async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteClaudeCliHealth } = await import("../commands/doctor-claude-cli.js");
noteClaudeCliHealth(ctx.cfg);
}
async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
await import("../commands/doctor-state-migrations.js");
const { note } = await import("../terminal/note.js");
const legacyState = await detectLegacyStateMigrations({ cfg: ctx.cfg });
if (legacyState.preview.length === 0) {
return;
@@ -244,6 +206,8 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
}
async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairLegacyPluginManifestContracts } =
await import("../commands/doctor-plugin-manifests.js");
await maybeRepairLegacyPluginManifestContracts({
env: process.env,
runtime: ctx.runtime,
@@ -252,6 +216,8 @@ async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Prom
}
async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairBundledPluginRuntimeDeps } =
await import("../commands/doctor-bundled-plugin-runtime-deps.js");
await maybeRepairBundledPluginRuntimeDeps({
runtime: ctx.runtime,
prompter: ctx.prompter,
@@ -260,14 +226,17 @@ async function runBundledPluginRuntimeDepsHealth(ctx: DoctorHealthFlowContext):
}
async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteStateIntegrity } = await import("../commands/doctor-state-integrity.js");
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath);
}
async function runSessionLocksHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteSessionLockHealth } = await import("../commands/doctor-session-locks.js");
await noteSessionLockHealth({ shouldRepair: ctx.prompter.shouldRepair });
}
async function runLegacyCronHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairLegacyCronStore } = await import("../commands/doctor-cron.js");
await maybeRepairLegacyCronStore({
cfg: ctx.cfg,
options: ctx.options,
@@ -276,11 +245,17 @@ async function runLegacyCronHealth(ctx: DoctorHealthFlowContext): Promise<void>
}
async function runSandboxHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairSandboxImages, noteSandboxScopeWarnings } =
await import("../commands/doctor-sandbox.js");
ctx.cfg = await maybeRepairSandboxImages(ctx.cfg, ctx.runtime, ctx.prompter);
noteSandboxScopeWarnings(ctx.cfg);
}
async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairGatewayServiceConfig, maybeScanExtraGatewayServices } =
await import("../commands/doctor-gateway-services.js");
const { noteMacLaunchAgentOverrides, noteMacLaunchctlGatewayEnvOverrides } =
await import("../commands/doctor-platform-notes.js");
await maybeScanExtraGatewayServices(ctx.options, ctx.runtime, ctx.prompter);
await maybeRepairGatewayServiceConfig(
ctx.cfg,
@@ -293,6 +268,8 @@ async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise<v
}
async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRunDoctorStartupChannelMaintenance } =
await import("./doctor-startup-channel-maintenance.js");
await maybeRunDoctorStartupChannelMaintenance({
cfg: ctx.cfg,
env: process.env,
@@ -302,14 +279,17 @@ async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext):
}
async function runSecurityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteSecurityWarnings } = await import("../commands/doctor-security.js");
await noteSecurityWarnings(ctx.cfg);
}
async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js");
await noteChromeMcpBrowserReadiness(ctx.cfg);
}
async function runOpenAIOAuthTlsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteOpenAIOAuthTlsPrerequisites } = await import("../commands/oauth-tls-preflight.js");
await noteOpenAIOAuthTlsPrerequisites({
cfg: ctx.cfg,
deep: ctx.options.deep === true,
@@ -320,6 +300,11 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise<void>
if (!ctx.cfg.hooks?.gmail?.model?.trim()) {
return;
}
const { DEFAULT_MODEL, DEFAULT_PROVIDER } = await import("../agents/defaults.js");
const { loadModelCatalog } = await import("../agents/model-catalog.js");
const { getModelRefStatus, resolveConfiguredModelRef, resolveHooksGmailModel } =
await import("../agents/model-selection.js");
const { note } = await import("../terminal/note.js");
const hooksModelRef = resolveHooksGmailModel({
cfg: ctx.cfg,
defaultProvider: DEFAULT_PROVIDER,
@@ -365,6 +350,9 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<voi
) {
return;
}
const { resolveGatewayService } = await import("../daemon/service.js");
const { ensureSystemdUserLingerInteractive } = await import("../commands/systemd-linger.js");
const { note } = await import("../terminal/note.js");
const service = resolveGatewayService();
let loaded = false;
try {
@@ -388,20 +376,25 @@ async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runWorkspaceStatusHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteWorkspaceStatus } = await import("../commands/doctor-workspace-status.js");
noteWorkspaceStatus(ctx.cfg);
}
async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteBootstrapFileSize } = await import("../commands/doctor-bootstrap-size.js");
await noteBootstrapFileSize(ctx.cfg);
}
async function runShellCompletionHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { doctorShellCompletion } = await import("../commands/doctor-completion.js");
await doctorShellCompletion(ctx.runtime, ctx.prompter, {
nonInteractive: ctx.options.nonInteractive,
});
}
async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise<void> {
const { checkGatewayHealth, probeGatewayMemoryStatus } =
await import("../commands/doctor-gateway-health.js");
const { healthOk } = await checkGatewayHealth({
runtime: ctx.runtime,
cfg: ctx.cfg,
@@ -417,6 +410,8 @@ async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairMemoryRecallHealth, noteMemoryRecallHealth, noteMemorySearchHealth } =
await import("../commands/doctor-memory-search.js");
if (ctx.prompter.shouldRepair) {
await maybeRepairMemoryRecallHealth({
cfg: ctx.cfg,
@@ -432,6 +427,7 @@ async function runMemorySearchHealthContribution(ctx: DoctorHealthFlowContext):
}
async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { noteDevicePairingHealth } = await import("../commands/doctor-device-pairing.js");
await noteDevicePairingHealth({
cfg: ctx.cfg,
healthOk: ctx.healthOk ?? false,
@@ -439,6 +435,7 @@ async function runDevicePairingHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairGatewayDaemon } = await import("../commands/doctor-gateway-daemon-flow.js");
await maybeRepairGatewayDaemon({
cfg: ctx.cfg,
runtime: ctx.runtime,
@@ -450,6 +447,11 @@ async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise<voi
}
async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { formatCliCommand } = await import("../cli/command-format.js");
const { applyWizardMetadata } = await import("../commands/onboard-helpers.js");
const { CONFIG_PATH, writeConfigFile } = await import("../config/config.js");
const { logConfigUpdated } = await import("../config/logging.js");
const { shortenHomePath } = await import("../utils.js");
const shouldWriteConfig =
ctx.configResult.shouldWriteConfig ||
JSON.stringify(ctx.cfg) !== JSON.stringify(ctx.cfgForPersistence);
@@ -475,6 +477,12 @@ async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Prom
if (ctx.options.workspaceSuggestions === false) {
return;
}
const { resolveAgentWorkspaceDir, resolveDefaultAgentId } =
await import("../agents/agent-scope.js");
const { noteWorkspaceBackupTip } = await import("../commands/doctor-state-integrity.js");
const { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } =
await import("../commands/doctor-workspace.js");
const { note } = await import("../terminal/note.js");
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
noteWorkspaceBackupTip(workspaceDir);
if (await shouldSuggestMemorySystem(workspaceDir)) {
@@ -483,6 +491,7 @@ async function runWorkspaceSuggestionsHealth(ctx: DoctorHealthFlowContext): Prom
}
async function runFinalConfigValidationHealth(_ctx: DoctorHealthFlowContext): Promise<void> {
const { readConfigFileSnapshot } = await import("../config/config.js");
const finalSnapshot = await readConfigFileSnapshot();
if (finalSnapshot.exists && !finalSnapshot.valid) {
_ctx.runtime.error("Invalid config:");

View File

@@ -1,37 +1,29 @@
import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
import { loadAndMaybeMigrateDoctorConfig } from "../commands/doctor-config-flow.js";
import { noteSourceInstallIssues } from "../commands/doctor-install.js";
import { noteStartupOptimizationHints } from "../commands/doctor-platform-notes.js";
import { createDoctorPrompter, type DoctorOptions } from "../commands/doctor-prompter.js";
import { maybeRepairUiProtocolFreshness } from "../commands/doctor-ui.js";
import { maybeOfferUpdateBeforeDoctor } from "../commands/doctor-update.js";
import { printWizardHeader } from "../commands/onboard-helpers.js";
import { CONFIG_PATH } from "../config/config.js";
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
import type { DoctorOptions } from "../commands/doctor-prompter.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { runDoctorHealthContributions } from "./doctor-health-contributions.js";
const intro = (message: string) => clackIntro(stylePromptTitle(message) ?? message);
const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? message);
export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {},
) {
const prompter = createDoctorPrompter({ runtime, options });
printWizardHeader(runtime);
export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions = {}) {
const effectiveRuntime = runtime ?? (await import("../runtime.js")).defaultRuntime;
const { createDoctorPrompter } = await import("../commands/doctor-prompter.js");
const { printWizardHeader } = await import("../commands/onboard-helpers.js");
const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options });
printWizardHeader(effectiveRuntime);
intro("OpenClaw doctor");
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");
const root = await resolveOpenClawPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
const { maybeOfferUpdateBeforeDoctor } = await import("../commands/doctor-update.js");
const updateResult = await maybeOfferUpdateBeforeDoctor({
runtime,
runtime: effectiveRuntime,
options,
root,
confirm: (p) => prompter.confirm(p),
@@ -41,16 +33,21 @@ export async function doctorCommand(
return;
}
await maybeRepairUiProtocolFreshness(runtime, prompter);
const { maybeRepairUiProtocolFreshness } = await import("../commands/doctor-ui.js");
const { noteSourceInstallIssues } = await import("../commands/doctor-install.js");
const { noteStartupOptimizationHints } = await import("../commands/doctor-platform-notes.js");
await maybeRepairUiProtocolFreshness(effectiveRuntime, prompter);
noteSourceInstallIssues(root);
noteStartupOptimizationHints();
const { loadAndMaybeMigrateDoctorConfig } = await import("../commands/doctor-config-flow.js");
const configResult = await loadAndMaybeMigrateDoctorConfig({
options,
confirm: (p) => prompter.confirm(p),
});
const { CONFIG_PATH } = await import("../config/config.js");
const ctx = {
runtime,
runtime: effectiveRuntime,
options,
prompter,
configResult,
@@ -59,6 +56,7 @@ export async function doctorCommand(
sourceConfigValid: configResult.sourceConfigValid ?? true,
configPath: configResult.path ?? CONFIG_PATH,
};
const { runDoctorHealthContributions } = await import("./doctor-health-contributions.js");
await runDoctorHealthContributions(ctx);
outro("Doctor complete.");

View File

@@ -68,12 +68,20 @@ function writePluginPackageManifest(params: {
packageDir: string;
packageName: string;
extensions: string[];
runtimeExtensions?: string[];
setupEntry?: string;
runtimeSetupEntry?: string;
}) {
fs.writeFileSync(
path.join(params.packageDir, "package.json"),
JSON.stringify({
name: params.packageName,
openclaw: { extensions: params.extensions },
openclaw: {
extensions: params.extensions,
...(params.runtimeExtensions ? { runtimeExtensions: params.runtimeExtensions } : {}),
...(params.setupEntry ? { setupEntry: params.setupEntry } : {}),
...(params.runtimeSetupEntry ? { runtimeSetupEntry: params.runtimeSetupEntry } : {}),
},
}),
"utf-8",
);
@@ -400,6 +408,109 @@ describe("discoverOpenClawPlugins", () => {
expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] });
});
it("uses explicit runtime extension entries for installed package plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "runtime-pack");
mkdirSafe(path.join(pluginDir, "src"));
mkdirSafe(path.join(pluginDir, "dist"));
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/runtime-pack",
extensions: ["./src/index.ts"],
runtimeExtensions: ["./dist/index.js"],
setupEntry: "./src/setup-entry.ts",
runtimeSetupEntry: "./dist/setup-entry.js",
});
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts"));
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
writePluginEntry(path.join(pluginDir, "dist", "setup-entry.js"));
const { candidates } = await discoverWithStateDir(stateDir, {});
const candidate = findCandidateById(candidates, "runtime-pack");
expect(fs.realpathSync(candidate?.source ?? "")).toBe(
fs.realpathSync(path.join(pluginDir, "dist", "index.js")),
);
expect(fs.realpathSync(candidate?.setupSource ?? "")).toBe(
fs.realpathSync(path.join(pluginDir, "dist", "setup-entry.js")),
);
});
it("infers built dist entries for installed TypeScript package plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "built-peer-pack");
mkdirSafe(path.join(pluginDir, "src"));
mkdirSafe(path.join(pluginDir, "dist"));
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/built-peer-pack",
extensions: ["src/index.ts"],
setupEntry: "src/setup-entry.ts",
});
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts"));
writePluginEntry(path.join(pluginDir, "src", "index.js"));
writePluginEntry(path.join(pluginDir, "src", "setup-entry.js"));
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
writePluginEntry(path.join(pluginDir, "dist", "setup-entry.js"));
const { candidates } = await discoverWithStateDir(stateDir, {});
const candidate = findCandidateById(candidates, "built-peer-pack");
expect(fs.realpathSync(candidate?.source ?? "")).toBe(
fs.realpathSync(path.join(pluginDir, "dist", "index.js")),
);
expect(fs.realpathSync(candidate?.setupSource ?? "")).toBe(
fs.realpathSync(path.join(pluginDir, "dist", "setup-entry.js")),
);
});
it("preserves nested entry paths when inferring installed dist entries", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "nested-pack");
mkdirSafe(path.join(pluginDir, "plugin"));
mkdirSafe(path.join(pluginDir, "dist", "plugin"));
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/nested-pack",
extensions: ["./plugin/index.ts"],
});
writePluginEntry(path.join(pluginDir, "plugin", "index.ts"));
writePluginEntry(path.join(pluginDir, "dist", "plugin", "index.js"));
const { candidates } = await discoverWithStateDir(stateDir, {});
const candidate = findCandidateById(candidates, "nested-pack");
expect(fs.realpathSync(candidate?.source ?? "")).toBe(
fs.realpathSync(path.join(pluginDir, "dist", "plugin", "index.js")),
);
});
it("keeps workspace package TypeScript entries unless runtime entries are explicit", () => {
const stateDir = makeTempDir();
const workspaceDir = path.join(stateDir, "workspace");
const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-pack");
mkdirSafe(path.join(pluginDir, "src"));
mkdirSafe(path.join(pluginDir, "dist"));
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/workspace-pack",
extensions: ["./src/index.ts"],
});
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
writePluginEntry(path.join(pluginDir, "dist", "index.js"));
const { candidates } = discoverOpenClawPlugins({
workspaceDir,
env: buildDiscoveryEnv(stateDir),
});
expect(fs.realpathSync(findCandidateById(candidates, "workspace-pack")?.source ?? "")).toBe(
fs.realpathSync(path.join(pluginDir, "src", "index.ts")),
);
});
it("does not discover nested node_modules copies under installed plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "opik-openclaw");

View File

@@ -611,6 +611,155 @@ function resolvePackageEntrySource(params: {
return openCandidate(source);
}
function isTypeScriptPackageEntry(entryPath: string): boolean {
return [".ts", ".mts", ".cts"].includes(normalizeLowercaseStringOrEmpty(path.extname(entryPath)));
}
function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
return origin === "config" || origin === "global";
}
function listBuiltRuntimeEntryCandidates(entryPath: string): string[] {
if (!isTypeScriptPackageEntry(entryPath)) {
return [];
}
const normalized = entryPath.replace(/\\/g, "/");
const withoutExtension = normalized.replace(/\.[^.]+$/u, "");
const normalizedRelative = normalized.replace(/^\.\//u, "");
const distWithoutExtension = normalizedRelative.startsWith("src/")
? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}`
: `./dist/${withoutExtension.replace(/^\.\//u, "")}`;
const withJavaScriptExtensions = (basePath: string) => [
`${basePath}.js`,
`${basePath}.mjs`,
`${basePath}.cjs`,
];
const candidates = [
...withJavaScriptExtensions(distWithoutExtension),
...withJavaScriptExtensions(withoutExtension),
];
return [...new Set(candidates)].filter((candidate) => candidate !== normalized);
}
function resolveExistingPackageEntrySource(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
if (!fs.existsSync(source)) {
return null;
}
return resolvePackageEntrySource(params);
}
function normalizePackageManifestStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
}
function resolvePackageRuntimeEntrySource(params: {
packageDir: string;
entryPath: string;
runtimeEntryPath?: string;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
if (params.runtimeEntryPath) {
const runtimeSource = resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.runtimeEntryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (runtimeSource) {
return runtimeSource;
}
}
if (shouldInferBuiltRuntimeEntry(params.origin)) {
for (const candidate of listBuiltRuntimeEntryCandidates(params.entryPath)) {
const runtimeSource = resolveExistingPackageEntrySource({
packageDir: params.packageDir,
entryPath: candidate,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (runtimeSource) {
return runtimeSource;
}
}
}
return resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
}
function resolvePackageSetupSource(params: {
packageDir: string;
manifest: PackageManifest | null;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry);
if (!setupEntryPath) {
return null;
}
return resolvePackageRuntimeEntrySource({
packageDir: params.packageDir,
entryPath: setupEntryPath,
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
}
function resolvePackageRuntimeExtensionEntries(params: {
packageDir: string;
manifest: PackageManifest | null;
extensions: readonly string[];
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string[] {
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions);
return params.extensions.flatMap((entryPath, index) => {
const source = resolvePackageRuntimeEntrySource({
packageDir: params.packageDir,
entryPath,
runtimeEntryPath:
runtimeExtensions.length === params.extensions.length
? runtimeExtensions[index]
: undefined,
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
return source ? [source] : [];
});
}
function discoverInDirectory(params: {
dir: string;
origin: PluginOrigin;
@@ -678,30 +827,26 @@ function discoverInDirectory(params: {
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const manifestId = resolveIdHintManifestId(fullPath, rejectHardlinks);
const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry;
const setupSource =
typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0
? resolvePackageEntrySource({
packageDir: fullPath,
entryPath: setupEntryPath,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
})
: null;
const setupSource = resolvePackageSetupSource({
packageDir: fullPath,
manifest,
origin: params.origin,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (extensions.length > 0) {
for (const extPath of extensions) {
const resolved = resolvePackageEntrySource({
packageDir: fullPath,
entryPath: extPath,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (!resolved) {
continue;
}
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
packageDir: fullPath,
manifest,
extensions,
origin: params.origin,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
for (const resolved of resolvedRuntimeSources) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
@@ -818,30 +963,26 @@ function discoverFromPath(params: {
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const manifestId = resolveIdHintManifestId(resolved, rejectHardlinks);
const setupEntryPath = getPackageManifestMetadata(manifest ?? undefined)?.setupEntry;
const setupSource =
typeof setupEntryPath === "string" && setupEntryPath.trim().length > 0
? resolvePackageEntrySource({
packageDir: resolved,
entryPath: setupEntryPath,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
})
: null;
const setupSource = resolvePackageSetupSource({
packageDir: resolved,
manifest,
origin: params.origin,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (extensions.length > 0) {
for (const extPath of extensions) {
const source = resolvePackageEntrySource({
packageDir: resolved,
entryPath: extPath,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (!source) {
continue;
}
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
packageDir: resolved,
manifest,
extensions,
origin: params.origin,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
for (const source of resolvedRuntimeSources) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,

View File

@@ -63,26 +63,69 @@ describe("doctor-contract-registry getJiti", () => {
it("prefers doctor-contract-api over the broader contract-api surface", () => {
const pluginRoot = makeTempDir();
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
fs.writeFileSync(
path.join(pluginRoot, "doctor-contract-api.js"),
"export default {};\n",
path.join(pluginRoot, "doctor-contract-api.cjs"),
"module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'demo', 'doctor'], message: 'doctor contract' }] };\n",
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "contract-api.cjs"),
"module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'demo', 'broad'], message: 'broad contract' }] };\n",
"utf-8",
);
fs.writeFileSync(path.join(pluginRoot, "contract-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "test-plugin", rootDir: pluginRoot }],
diagnostics: [],
});
listPluginDoctorLegacyConfigRules({
workspaceDir: pluginRoot,
env: {},
try {
expect(
listPluginDoctorLegacyConfigRules({
workspaceDir: pluginRoot,
env: {},
}),
).toEqual([
{
path: ["plugins", "entries", "demo", "doctor"],
message: "doctor contract",
},
]);
expect(mocks.createJiti).not.toHaveBeenCalled();
} finally {
platformSpy.mockRestore();
}
});
it("uses native require for compatible JavaScript contract modules", () => {
const pluginRoot = makeTempDir();
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
fs.writeFileSync(
path.join(pluginRoot, "doctor-contract-api.cjs"),
"module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'demo', 'legacy'], message: 'legacy demo key' }] };\n",
"utf-8",
);
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "test-plugin", rootDir: pluginRoot }],
diagnostics: [],
});
expect(mocks.createJiti).toHaveBeenCalledTimes(1);
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(
path.join(pluginRoot, "doctor-contract-api.js"),
);
try {
expect(
listPluginDoctorLegacyConfigRules({
workspaceDir: pluginRoot,
env: {},
}),
).toEqual([
{
path: ["plugins", "entries", "demo", "legacy"],
message: "legacy demo key",
},
]);
expect(mocks.createJiti).not.toHaveBeenCalled();
} finally {
platformSpy.mockRestore();
}
});
it("narrows touched-path doctor ids for scoped dry-run validation", () => {

View File

@@ -7,6 +7,7 @@ import { asNullableRecord } from "../shared/record-coerce.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
import { resolvePluginCacheInputs, type PluginSourceRoots } from "./roots.js";
const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
@@ -51,6 +52,14 @@ function getJiti(modulePath: string) {
});
}
function loadPluginDoctorContractModule(modulePath: string): PluginDoctorContractModule {
const nativeModule = tryNativeRequireJavaScriptModule(modulePath);
if (nativeModule.ok) {
return nativeModule.moduleExport as PluginDoctorContractModule;
}
return getJiti(modulePath)(modulePath) as PluginDoctorContractModule;
}
function buildDoctorContractCacheKey(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
@@ -225,7 +234,7 @@ function loadPluginDoctorContractEntry(
}
let mod: PluginDoctorContractModule;
try {
mod = getJiti(contractSource)(contractSource) as PluginDoctorContractModule;
mod = loadPluginDoctorContractModule(contractSource);
} catch {
cache.set(record.id, null);
return null;

View File

@@ -877,7 +877,9 @@ export type OpenClawPackageSetupFeatures = {
export type OpenClawPackageManifest = {
extensions?: string[];
runtimeExtensions?: string[];
setupEntry?: string;
runtimeSetupEntry?: string;
setupFeatures?: OpenClawPackageSetupFeatures;
channel?: PluginPackageChannel;
install?: PluginPackageInstall;

View File

@@ -0,0 +1,25 @@
import { createRequire } from "node:module";
import path from "node:path";
const nodeRequire = createRequire(import.meta.url);
export function isJavaScriptModulePath(modulePath: string): boolean {
return [".js", ".mjs", ".cjs"].includes(path.extname(modulePath).toLowerCase());
}
export function tryNativeRequireJavaScriptModule(
modulePath: string,
options: { allowWindows?: boolean } = {},
): { ok: true; moduleExport: unknown } | { ok: false } {
if (process.platform === "win32" && options.allowWindows !== true) {
return { ok: false };
}
if (!isJavaScriptModulePath(modulePath)) {
return { ok: false };
}
try {
return { ok: true, moduleExport: nodeRequire(modulePath) };
} catch {
return { ok: false };
}
}