diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index eeb87e9d8439..0bd15c29b2a5 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -85,6 +85,7 @@ and external URLs. Registering another provider replaces the current provider. | `api.version` | `string?` | Plugin version (optional) | | `api.description` | `string?` | Plugin description (optional) | | `api.source` | `string` | Plugin source path | +| `api.runtimeSource` | `string?` | Selected runtime entrypoint path, when the loader has selected a runtime artifact | | `api.rootDir` | `string?` | Plugin root directory (optional) | | `api.config` | `OpenClawConfig` | Current config snapshot (active in-memory runtime snapshot when available) | | `api.pluginConfig` | `Record` | Plugin-specific config from `plugins.entries..config` | @@ -93,6 +94,14 @@ and external URLs. Registering another provider replaces the current provider. | `api.registrationMode` | `PluginRegistrationMode` | Current load mode; `"setup-runtime"` is the lightweight setup flow with runtime available | | `api.resolvePath(input)` | `(string) => string` | Resolve path relative to plugin root | +Use `api.runtimeSource` to locate private modules beside the selected runtime +entrypoint. It records the loader's source, standalone package, or bundled +artifact choice and always identifies the main runtime entry, even during +setup registration. `api.source` and `api.rootDir` retain discovery identity; +they can differ from the selected artifact. `runtimeSource` is absent when no +runtime artifact has been selected, including metadata-only APIs. This path is +a location fact, not authorization to invoke a retired plugin. + ## Where each section moved Every section of the single-page version now lives on this page or on one of diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index 098927734b57..6acc13b8cac7 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -10,6 +10,7 @@ type BuildPluginApiParams = { version?: string; description?: string; source: string; + runtimeSource?: string; rootDir?: string; registrationMode: OpenClawPluginApi["registrationMode"]; config: OpenClawConfig; @@ -131,6 +132,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi version: params.version, description: params.description, source: params.source, + runtimeSource: params.runtimeSource, rootDir: params.rootDir, registrationMode: params.registrationMode, config: params.config, diff --git a/src/plugins/plugin-api.types.ts b/src/plugins/plugin-api.types.ts index 2625b6e5eb10..859743f692e6 100644 --- a/src/plugins/plugin-api.types.ts +++ b/src/plugins/plugin-api.types.ts @@ -183,6 +183,8 @@ export type OpenClawPluginApi = { version?: string; description?: string; source: string; + /** Selected runtime entrypoint, independent of setup; absent without runtime artifact selection. */ + readonly runtimeSource?: string; rootDir?: string; registrationMode: PluginRegistrationMode; config: OpenClawConfig; diff --git a/src/plugins/plugin-runtime-artifact-binding.ts b/src/plugins/plugin-runtime-artifact-binding.ts index bea70fe42d23..b84ef3976419 100644 --- a/src/plugins/plugin-runtime-artifact-binding.ts +++ b/src/plugins/plugin-runtime-artifact-binding.ts @@ -54,6 +54,10 @@ export function hasCompletedPluginRuntimeRegistration(record: ArtifactBoundRecor ); } +export function getPluginRuntimeEntrySource(record: ArtifactBoundRecord): string | undefined { + return record[RUNTIME_ARTIFACT_SELECTION]?.runtimeEntry.source; +} + export function matchesPluginRuntimeArtifactSelection( record: ArtifactBoundRecord, params: RuntimeArtifactSelectionInput & { rootDir: string; source: string }, diff --git a/src/plugins/plugin-runtime-artifact-resolution.test.ts b/src/plugins/plugin-runtime-artifact-resolution.test.ts index 00e44b80d865..10ccbf2db2b6 100644 --- a/src/plugins/plugin-runtime-artifact-resolution.test.ts +++ b/src/plugins/plugin-runtime-artifact-resolution.test.ts @@ -79,6 +79,45 @@ afterEach(() => { }); describe("resolvePluginRuntimeArtifact", () => { + it.each(["source", "package-local", "root-bundled"])( + "exposes the selected %s runtime entry to registration", + (layout) => { + const fixture = createBundledPluginFixture(); + const entry = + layout === "source" + ? fixture.source + : layout === "package-local" + ? path.join(fixture.rootDir, "dist", "index.js") + : fixture.builtSource; + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync( + entry, + `export default { + id: "fixture", + register(api) { + api.registerService({ id: api.runtimeSource ?? "missing runtime source", start() {} }); + } + };\n`, + ); + const registry = withEnv( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: path.dirname(fixture.rootDir), + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + }, + () => + loadOpenClawPlugins({ + cache: false, + config: { plugins: { allow: ["fixture"], entries: { fixture: { enabled: true } } } }, + onlyPluginIds: ["fixture"], + preferBuiltPluginArtifacts: layout !== "source", + }), + ); + expect(registry.services.map(({ service }) => service.id)).toEqual([entry]); + expect(registry.plugins[0]?.source).toBe(fixture.source); + }, + ); + it.each(["missing", "present", "staging-symlink", "canonical-directory-symlink"])( "keeps the execution entry and boundary together for a %s canonical entry", (layout) => { diff --git a/src/plugins/registry-api.ts b/src/plugins/registry-api.ts index c91a1a1d78e7..69900ff500be 100644 --- a/src/plugins/registry-api.ts +++ b/src/plugins/registry-api.ts @@ -15,6 +15,7 @@ import { schedulePluginSessionTurn, unschedulePluginSessionTurnsByTag, } from "./host-hook-scheduled-turns.js"; +import { getPluginRuntimeEntrySource } from "./plugin-runtime-artifact-binding.js"; import { isPluginRegistryActivated, isPluginRegistryRetired } from "./registry-lifecycle.js"; import type { PluginRegistrars } from "./registry-registrars.js"; import type { PluginRuntimeResolver } from "./registry-runtime.js"; @@ -130,6 +131,7 @@ export function createPluginApiFactory( version: record.version, description: record.description, source: record.source, + runtimeSource: getPluginRuntimeEntrySource(record), rootDir: record.rootDir, registrationMode, config: params.config, diff --git a/src/plugins/registry.runtime-artifact.test.ts b/src/plugins/registry.runtime-artifact.test.ts new file mode 100644 index 000000000000..8a1423d3a7b2 --- /dev/null +++ b/src/plugins/registry.runtime-artifact.test.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createPluginRecord } from "./loader-records.js"; +import { bindPluginRuntimeArtifactSelection } from "./plugin-runtime-artifact-binding.js"; +import { createPluginRegistry } from "./registry.js"; +import { createPluginRuntime } from "./runtime/index.js"; + +describe("plugin API runtime entrypoint", () => { + it("preserves the selected main entry during setup registration without guessing an unselected entry", () => { + const builder = createPluginRegistry({ + logger: console, + runtime: createPluginRuntime(), + activateGlobalSideEffects: false, + }); + const rootDir = path.resolve("plugins/fixture"); + const source = path.join(rootDir, "index.ts"); + const record = createPluginRecord({ + id: "fixture", + source, + rootDir, + origin: "global", + enabled: true, + configSchema: false, + }); + expect(builder.createApi(record, { config: {} }).runtimeSource).toBeUndefined(); + + const runtimeSource = path.join(rootDir, "dist/index.js"); + bindPluginRuntimeArtifactSelection(record, { + runtimeEntry: { source: runtimeSource, rootDir }, + setupEntry: { source: path.join(rootDir, "dist/setup.js"), rootDir }, + }); + const api = builder.createApi(record, { config: {}, registrationMode: "setup-runtime" }); + expect(api.runtimeSource).toBe(runtimeSource); + expect(api.source).toBe(source); + expect(api.rootDir).toBe(rootDir); + }); +});