From 66add9fcd96a7262914107c7aac8c69d8c0392ab Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 21 Apr 2026 18:17:19 -0400 Subject: [PATCH] perf(cli): lazy-load doctor plugin paths (#69840) Merged via squash. Prepared head SHA: ebf93ad913b71471891d5df5d42644572b057040 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 6 + docs/plugins/sdk-entrypoints.md | 21 ++ docs/tools/clawhub.md | 8 +- src/channels/plugins/module-loader.test.ts | 24 ++ src/channels/plugins/module-loader.ts | 23 +- src/commands/doctor-config-flow.ts | 110 +++++---- src/commands/doctor.ts | 8 +- src/flows/doctor-health-contributions.ts | 123 +++++----- src/flows/doctor-health.ts | 38 ++- src/plugins/discovery.test.ts | 113 ++++++++- src/plugins/discovery.ts | 229 +++++++++++++++---- src/plugins/doctor-contract-registry.test.ts | 63 ++++- src/plugins/doctor-contract-registry.ts | 11 +- src/plugins/manifest.ts | 2 + src/plugins/native-module-require.ts | 25 ++ 15 files changed, 608 insertions(+), 196 deletions(-) create mode 100644 src/plugins/native-module-require.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b366602b819..03881b305f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 3cd11df080c..1797245ec5a 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -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. + **Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins) or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides. diff --git a/docs/tools/clawhub.md b/docs/tools/clawhub.md index 728beb5a5cd..ef6f7478e66 100644 --- a/docs/tools/clawhub.md +++ b/docs/tools/clawhub.md @@ -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 diff --git a/src/channels/plugins/module-loader.test.ts b/src/channels/plugins/module-loader.test.ts index e895e9859c7..3447b95f3cd 100644 --- a/src/channels/plugins/module-loader.test.ts +++ b/src/channels/plugins/module-loader.test.ts @@ -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( + 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", () => ({ diff --git a/src/channels/plugins/module-loader.ts b/src/channels/plugins/module-loader.ts index 9fe38f634a8..b49e75f853f 100644 --- a/src/channels/plugins/module-loader.ts +++ b/src/channels/plugins/module-loader.ts @@ -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); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 66baf8d350a..cf988110433 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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; @@ -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"); } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 08d9aac3c62..89bce662b40 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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 { + const doctorHealth = await import("../flows/doctor-health.js"); + await doctorHealth.doctorCommand(runtime, options); +} diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 40c92a8f588..a8a0ddc86fa 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -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 { + 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 { + 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 } async function runGatewayAuthHealth(ctx: DoctorHealthFlowContext): Promise { + 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 } async function runClaudeCliHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteClaudeCliHealth } = await import("../commands/doctor-claude-cli.js"); noteClaudeCliHealth(ctx.cfg); } async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise { + 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 } async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Promise { + 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 { + 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 { + const { noteStateIntegrity } = await import("../commands/doctor-state-integrity.js"); await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath); } async function runSessionLocksHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteSessionLockHealth } = await import("../commands/doctor-session-locks.js"); await noteSessionLockHealth({ shouldRepair: ctx.prompter.shouldRepair }); } async function runLegacyCronHealth(ctx: DoctorHealthFlowContext): Promise { + 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 } async function runSandboxHealth(ctx: DoctorHealthFlowContext): Promise { + 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 { + 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 { + 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 { + const { noteSecurityWarnings } = await import("../commands/doctor-security.js"); await noteSecurityWarnings(ctx.cfg); } async function runBrowserHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteChromeMcpBrowserReadiness } = await import("../commands/doctor-browser.js"); await noteChromeMcpBrowserReadiness(ctx.cfg); } async function runOpenAIOAuthTlsHealth(ctx: DoctorHealthFlowContext): Promise { + 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 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 { + const { noteWorkspaceStatus } = await import("../commands/doctor-workspace-status.js"); noteWorkspaceStatus(ctx.cfg); } async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise { + const { noteBootstrapFileSize } = await import("../commands/doctor-bootstrap-size.js"); await noteBootstrapFileSize(ctx.cfg); } async function runShellCompletionHealth(ctx: DoctorHealthFlowContext): Promise { + const { doctorShellCompletion } = await import("../commands/doctor-completion.js"); await doctorShellCompletion(ctx.runtime, ctx.prompter, { nonInteractive: ctx.options.nonInteractive, }); } async function runGatewayHealthChecks(ctx: DoctorHealthFlowContext): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const { readConfigFileSnapshot } = await import("../config/config.js"); const finalSnapshot = await readConfigFileSnapshot(); if (finalSnapshot.exists && !finalSnapshot.valid) { _ctx.runtime.error("Invalid config:"); diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index 442ae1d7220..9a8553f4c82 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -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."); diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 345befd4439..11f36048c9e 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 3232cfbe7e0..8a4a401667a 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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, diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index d83eb2ad937..a7c0ec4eade 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -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", () => { diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index c928634cec0..615890ac247 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -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; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index d725345eed1..2b319ce1092 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -877,7 +877,9 @@ export type OpenClawPackageSetupFeatures = { export type OpenClawPackageManifest = { extensions?: string[]; + runtimeExtensions?: string[]; setupEntry?: string; + runtimeSetupEntry?: string; setupFeatures?: OpenClawPackageSetupFeatures; channel?: PluginPackageChannel; install?: PluginPackageInstall; diff --git a/src/plugins/native-module-require.ts b/src/plugins/native-module-require.ts new file mode 100644 index 00000000000..ef642afcb07 --- /dev/null +++ b/src/plugins/native-module-require.ts @@ -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 }; + } +}