diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index b66887e9e29..93b3af3ebe3 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -1024,7 +1024,7 @@ Important examples: | `openclaw.extensions` | Declares native plugin entrypoints. Must stay inside the plugin package directory. | | `openclaw.runtimeExtensions` | Declares built JavaScript runtime entrypoints for installed packages. Must stay inside the plugin package directory. | | `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. | -| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Must stay inside the plugin package directory. | +| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Requires `setupEntry`, must exist, and must stay inside the plugin package directory. | | `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. | | `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. | | `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. | diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 3ae01f6c610..1a24c9c1f09 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -28,9 +28,12 @@ JavaScript when available: `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. +TypeScript compilation. Explicit runtime entries are required: `runtimeSetupEntry` +requires `setupEntry`, and missing `runtimeExtensions` or `runtimeSetupEntry` +artifacts fail install/discovery instead of silently falling back to source. 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. All entry paths must stay inside the plugin package directory. Runtime entries and inferred built JavaScript peers do not make an escaping `extensions` or diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a2f31a6cd7d..c756c3e4504 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -130,7 +130,9 @@ peer such as `src/index.ts` to `dist/index.js`. Use `openclaw.runtimeExtensions` when published runtime files do not live at the same paths as the source entries. When present, `runtimeExtensions` must contain exactly one entry for every `extensions` entry. Mismatched lists fail install and -plugin discovery rather than silently falling back to source paths. +plugin discovery rather than silently falling back to source paths. If you also +publish `openclaw.setupEntry`, use `openclaw.runtimeSetupEntry` for its built +JavaScript peer; that file is required when declared. ```json { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 3d063dea905..9f197ba1480 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -791,6 +791,37 @@ describe("discoverOpenClawPlugins", () => { ); }); + it("rejects missing explicit runtime setup entries for installed package plugins", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "missing-runtime-setup-pack"); + mkdirSafe(path.join(pluginDir, "src")); + mkdirSafe(path.join(pluginDir, "dist")); + + writePluginPackageManifest({ + packageDir: pluginDir, + packageName: "@openclaw/missing-runtime-setup-pack", + extensions: ["./dist/index.js"], + setupEntry: "./src/setup-entry.ts", + runtimeSetupEntry: "./dist/setup-entry.js", + }); + writePluginEntry(path.join(pluginDir, "dist", "index.js")); + writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts")); + + const result = await discoverWithStateDir(stateDir, {}); + const candidate = findCandidateById(result.candidates, "missing-runtime-setup-pack"); + + expect(candidate).toBeDefined(); + expect(candidate?.setupSource).toBeUndefined(); + expect( + result.diagnostics.some( + (entry) => + entry.level === "error" && + entry.message.includes("runtime setup entry not found") && + entry.message.includes("./dist/setup-entry.js"), + ), + ).toBe(true); + }); + it("rejects package runtimeExtensions that do not match extension entries", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "extensions", "runtime-mismatch-pack"); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 338991ba872..c97745ee4a8 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -920,6 +920,38 @@ describe("installPluginFromArchive", () => { } }); + it("rejects package installs when runtimeSetupEntry is missing", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "missing-runtime-setup-plugin", + version: "1.0.0", + openclaw: { + extensions: ["./dist/index.js"], + setupEntry: "./src/setup-entry.ts", + runtimeSetupEntry: "./dist/setup-entry.js", + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n"); + fs.writeFileSync(path.join(pluginDir, "src", "setup-entry.ts"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("runtime setup entry not found"); + expect(result.error).toContain("./dist/setup-entry.js"); + } + }); + it("rejects package installs when an extension entry is a symlink escape", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); const outsideDir = path.join(path.dirname(pluginDir), "outside-symlink"); diff --git a/src/plugins/package-entry-resolution.ts b/src/plugins/package-entry-resolution.ts index c5e1d9e06d5..a8785829180 100644 --- a/src/plugins/package-entry-resolution.ts +++ b/src/plugins/package-entry-resolution.ts @@ -168,6 +168,64 @@ export async function validatePackageExtensionEntriesForInstall(params: { } } + const packageManifest = getPackageManifestMetadata(params.manifest); + const setupEntry = normalizeOptionalString(packageManifest?.setupEntry); + const runtimeSetupEntry = normalizeOptionalString(packageManifest?.runtimeSetupEntry); + if (runtimeSetupEntry && !setupEntry) { + return { + ok: false, + error: "package.json openclaw.runtimeSetupEntry requires openclaw.setupEntry", + }; + } + if (setupEntry) { + const sourceEntry = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: setupEntry, + label: "setup entry", + requireExisting: false, + }); + if (!sourceEntry.ok) { + return sourceEntry; + } + + if (runtimeSetupEntry) { + const runtimeResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: runtimeSetupEntry, + label: "runtime setup entry", + requireExisting: true, + }); + if (!runtimeResult.ok) { + return runtimeResult; + } + return { ok: true }; + } + + if (sourceEntry.exists) { + return { ok: true }; + } + + let foundBuiltSetupEntry = false; + for (const builtEntry of listBuiltRuntimeEntryCandidates(setupEntry)) { + const builtResult = await validatePackageExtensionEntry({ + packageDir: params.packageDir, + entry: builtEntry, + label: "inferred runtime setup entry", + requireExisting: false, + }); + if (!builtResult.ok) { + return builtResult; + } + if (builtResult.exists) { + foundBuiltSetupEntry = true; + break; + } + } + if (!foundBuiltSetupEntry) { + return { ok: false, error: `setup entry not found: ${setupEntry}` }; + } + } + return { ok: true }; } @@ -307,6 +365,7 @@ function resolvePackageRuntimeEntrySource(params: { packageRootRealPath?: string; entryPath: string; runtimeEntryPath?: string; + runtimeEntryLabel?: string; origin: PluginOrigin; sourceLabel: string; diagnostics: PluginDiagnostic[]; @@ -340,6 +399,12 @@ function resolvePackageRuntimeEntrySource(params: { if (runtimeSource) { return runtimeSource; } + params.diagnostics.push({ + level: "error", + message: `${params.runtimeEntryLabel ?? "runtime entry"} not found: ${params.runtimeEntryPath}`, + source: params.sourceLabel, + }); + return null; } if (shouldInferBuiltRuntimeEntry(params.origin)) { @@ -397,6 +462,7 @@ export function resolvePackageSetupSource(params: { : {}), entryPath: setupEntryPath, runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry), + runtimeEntryLabel: "runtime setup entry", origin: params.origin, sourceLabel: params.sourceLabel, diagnostics: params.diagnostics, @@ -435,6 +501,7 @@ export function resolvePackageRuntimeExtensionSources(params: { : {}), entryPath, runtimeEntryPath: runtimeResolution.runtimeExtensions[index], + runtimeEntryLabel: "runtime extension entry", origin: params.origin, sourceLabel: params.sourceLabel, diagnostics: params.diagnostics,