feat(tokenjuice): bundle the native adapter (#69946)

* feat(plugins): register embedded extension factories

* feat(tokenjuice): bundle the native adapter

* fix(tokenjuice): gate the bundled embedded extension seam

* fix(tokenjuice): refresh runtime sidecar baseline

* fix(plugins): harden bundled embedded extensions

* fix(plugins): install source bundled runtime deps

* fix(tokenjuice): sync lockfile importer

* fix(plugins): validate reused runtime dep versions

* fix(plugins): restore tokenjuice CI contract

* fix(plugins): remove tokenjuice dts bridge

* fix(tokenjuice): repair openclaw type shim

* fix(plugins): harden bundled runtime deps

* fix(plugins): keep source checkout runtime deps local

* fix(plugins): isolate bundled runtime dep installs

* fix(cli): keep plugin startup registration non-activating

* fix(cli): keep loader overrides out of plugin cli options
This commit is contained in:
Vincent Koc
2026-04-21 23:58:37 -07:00
committed by GitHub
parent 201385548c
commit 91ac485246
38 changed files with 1338 additions and 133 deletions

4
.github/labeler.yml vendored
View File

@@ -241,6 +241,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/open-prose/**"
"extensions: tokenjuice":
- changed-files:
- any-glob-to-any-file:
- "extensions/tokenjuice/**"
"extensions: webhooks":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1,2 +1,2 @@
bd14f9118c8359c8ab0a7da984be28a319e82fadb004f55dc5888c0a07d411d3 plugin-sdk-api-baseline.json
ef09464bba3712998c0accf9a4e551ba31af4d7a2f77ce01120a1f4b48ca4ac5 plugin-sdk-api-baseline.jsonl
6f605be396ee42efbe26cfd0cc90d7710ca378959aecd6388dd81a5b97996b43 plugin-sdk-api-baseline.json
9c34c7c068f6d3bc5cf44817fe14c470c1c091595296f829e1efb4d6e7ba3599 plugin-sdk-api-baseline.jsonl

View File

@@ -162,6 +162,7 @@ A single plugin can register any number of capabilities via the `api` object:
| Video generation | `api.registerVideoGenerationProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Web fetch | `api.registerWebFetchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Web search | `api.registerWebSearchProvider(...)` | [Provider Plugins](/plugins/sdk-provider-plugins#step-5-add-extra-capabilities) |
| Embedded Pi extension | `api.registerEmbeddedExtensionFactory(...)` | [SDK Overview](/plugins/sdk-overview#registration-api) |
| Agent tools | `api.registerTool(...)` | Below |
| Custom commands | `api.registerCommand(...)` | [Entry Points](/plugins/sdk-entrypoints) |
| Event hooks | `api.registerHook(...)` | [Entry Points](/plugins/sdk-entrypoints) |
@@ -170,6 +171,11 @@ A single plugin can register any number of capabilities via the `api` object:
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
Use `api.registerEmbeddedExtensionFactory(...)` when a plugin needs Pi-native
embedded-runner hooks such as async `tool_result` rewriting before the final
tool result message is emitted. Prefer regular OpenClaw plugin hooks when the
work does not need Pi extension timing.
If your plugin registers custom gateway RPC methods, keep them on a
plugin-specific prefix. Core admin namespaces (`config.*`,
`exec.approvals.*`, `wizard.*`, `update.*`) stay reserved and always resolve to

View File

@@ -382,6 +382,7 @@ read without importing the plugin runtime.
```json
{
"contracts": {
"embeddedExtensionFactories": ["pi"],
"speechProviders": ["openai"],
"realtimeTranscriptionProviders": ["openai"],
"realtimeVoiceProviders": ["openai"],
@@ -397,17 +398,18 @@ read without importing the plugin runtime.
Each list is optional:
| Field | Type | What it means |
| -------------------------------- | ---------- | -------------------------------------------------------------- |
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. |
| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. |
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. |
| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. |
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. |
| Field | Type | What it means |
| -------------------------------- | ---------- | ----------------------------------------------------------------- |
| `embeddedExtensionFactories` | `string[]` | Embedded runtime ids a bundled plugin may register factories for. |
| `speechProviders` | `string[]` | Speech provider ids this plugin owns. |
| `realtimeTranscriptionProviders` | `string[]` | Realtime-transcription provider ids this plugin owns. |
| `realtimeVoiceProviders` | `string[]` | Realtime-voice provider ids this plugin owns. |
| `mediaUnderstandingProviders` | `string[]` | Media-understanding provider ids this plugin owns. |
| `imageGenerationProviders` | `string[]` | Image-generation provider ids this plugin owns. |
| `videoGenerationProviders` | `string[]` | Video-generation provider ids this plugin owns. |
| `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. |
| `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. |
| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. |
## mediaUnderstandingProviderMetadata reference

View File

@@ -343,22 +343,31 @@ methods:
### Infrastructure
| Method | What it registers |
| ---------------------------------------------- | --------------------------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
| `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section |
| `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus |
| Method | What it registers |
| ----------------------------------------------- | --------------------------------------- |
| `api.registerHook(events, handler, opts?)` | Event hook |
| `api.registerHttpRoute(params)` | Gateway HTTP endpoint |
| `api.registerGatewayMethod(name, handler)` | Gateway RPC method |
| `api.registerCli(registrar, opts?)` | CLI subcommand |
| `api.registerService(service)` | Background service |
| `api.registerInteractiveHandler(registration)` | Interactive handler |
| `api.registerEmbeddedExtensionFactory(factory)` | Pi embedded-runner extension factory |
| `api.registerMemoryPromptSupplement(builder)` | Additive memory-adjacent prompt section |
| `api.registerMemoryCorpusSupplement(adapter)` | Additive memory search/read corpus |
Reserved core admin namespaces (`config.*`, `exec.approvals.*`, `wizard.*`,
`update.*`) always stay `operator.admin`, even if a plugin tries to assign a
narrower gateway method scope. Prefer plugin-specific prefixes for
plugin-owned methods.
Use `api.registerEmbeddedExtensionFactory(...)` when a plugin needs Pi-native
event timing during OpenClaw embedded runs, for example async `tool_result`
rewrites that must happen before the final tool-result message is emitted.
This is a bundled-plugin seam today: only bundled plugins may register one, and
they must declare `contracts.embeddedExtensionFactories: ["pi"]` in
`openclaw.plugin.json`. Keep normal OpenClaw plugin hooks for everything that
does not require that lower-level seam.
### CLI registration metadata
`api.registerCli(registrar, opts?)` accepts two kinds of top-level metadata:

View File

@@ -0,0 +1,52 @@
import fs from "node:fs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
const { tokenjuiceFactory, createTokenjuiceOpenClawEmbeddedExtension } = vi.hoisted(() => {
const tokenjuiceFactory = vi.fn();
const createTokenjuiceOpenClawEmbeddedExtension = vi.fn(() => tokenjuiceFactory);
return {
tokenjuiceFactory,
createTokenjuiceOpenClawEmbeddedExtension,
};
});
vi.mock("./runtime-api.js", () => ({
createTokenjuiceOpenClawEmbeddedExtension,
}));
import plugin from "./index.js";
describe("tokenjuice bundled plugin", () => {
beforeEach(() => {
createTokenjuiceOpenClawEmbeddedExtension.mockClear();
tokenjuiceFactory.mockClear();
});
it("is opt-in by default", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
) as { enabledByDefault?: unknown };
expect(manifest.enabledByDefault).toBeUndefined();
});
it("registers the tokenjuice embedded extension factory", () => {
const registerEmbeddedExtensionFactory = vi.fn();
plugin.register(
createTestPluginApi({
id: "tokenjuice",
name: "tokenjuice",
source: "test",
config: {},
pluginConfig: {},
runtime: {} as never,
registerEmbeddedExtensionFactory,
}),
);
expect(createTokenjuiceOpenClawEmbeddedExtension).toHaveBeenCalledTimes(1);
expect(registerEmbeddedExtensionFactory).toHaveBeenCalledWith(tokenjuiceFactory);
});
});

View File

@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createTokenjuiceOpenClawEmbeddedExtension } from "./runtime-api.js";
export default definePluginEntry({
id: "tokenjuice",
name: "tokenjuice",
description: "Compacts exec and bash tool results with tokenjuice reducers.",
register(api) {
api.registerEmbeddedExtensionFactory(createTokenjuiceOpenClawEmbeddedExtension());
},
});

View File

@@ -0,0 +1,36 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
type TokenjuicePackageManifest = {
dependencies?: Record<string, string>;
openclaw?: {
bundle?: {
stageRuntimeDependencies?: boolean;
};
};
};
type TokenjuicePluginManifest = {
contracts?: {
embeddedExtensionFactories?: string[];
};
};
describe("tokenjuice package manifest", () => {
it("opts into staging bundled runtime dependencies", () => {
const packageJson = JSON.parse(
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"),
) as TokenjuicePackageManifest;
expect(packageJson.dependencies?.tokenjuice).toBe("0.6.1");
expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true);
});
it("declares Pi embedded extension factory ownership in the manifest contract", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
) as TokenjuicePluginManifest;
expect(manifest.contracts?.embeddedExtensionFactories).toEqual(["pi"]);
});
});

View File

@@ -0,0 +1,13 @@
{
"id": "tokenjuice",
"name": "tokenjuice",
"description": "Compacts exec and bash tool results with tokenjuice reducers.",
"contracts": {
"embeddedExtensionFactories": ["pi"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@openclaw/tokenjuice",
"version": "2026.4.21",
"description": "Bundled tokenjuice exec output compaction plugin",
"type": "module",
"dependencies": {
"tokenjuice": "0.6.1"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"bundle": {
"stageRuntimeDependencies": true
}
}
}

View File

@@ -0,0 +1 @@
export { createTokenjuiceOpenClawEmbeddedExtension } from "tokenjuice/openclaw";

View File

@@ -0,0 +1,5 @@
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
declare module "tokenjuice/openclaw" {
export function createTokenjuiceOpenClawEmbeddedExtension(): ExtensionFactory;
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -1560,6 +1560,7 @@
"pdfjs-dist": "^5.6.205",
"proxy-agent": "^8.0.1",
"qrcode-terminal": "^0.12.0",
"semver": "7.7.4",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.9",
"tar": "7.5.13",
@@ -1587,7 +1588,6 @@
"oxfmt": "0.45.0",
"oxlint": "^1.60.0",
"oxlint-tsgolint": "^0.21.1",
"semver": "7.7.4",
"signal-utils": "0.21.1",
"tsdown": "0.21.9",
"tsx": "^4.21.0",

23
pnpm-lock.yaml generated
View File

@@ -143,6 +143,9 @@ importers:
qrcode-terminal:
specifier: ^0.12.0
version: 0.12.0
semver:
specifier: 7.7.4
version: 7.7.4
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -219,9 +222,6 @@ importers:
oxlint-tsgolint:
specifier: ^0.21.1
version: 0.21.1
semver:
specifier: 7.7.4
version: 7.7.4
signal-utils:
specifier: 0.21.1
version: 0.21.1(signal-polyfill@0.2.2)
@@ -1210,6 +1210,16 @@ importers:
specifier: workspace:*
version: link:../..
extensions/tokenjuice:
dependencies:
tokenjuice:
specifier: 0.6.1
version: 0.6.1
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/together:
devDependencies:
'@openclaw/plugin-sdk':
@@ -7146,6 +7156,11 @@ packages:
token-stream@1.0.0:
resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==}
tokenjuice@0.6.1:
resolution: {integrity: sha512-9Vg9303NeNrTa9n7gQhiHsXfgi7b61bi26zxoAobW/pKIuMOUD/G04+5NPKAbpj+TSKaSEivZZp79222oHbdEA==}
engines: {node: '>=20'}
hasBin: true
token-types@6.1.2:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
@@ -14217,6 +14232,8 @@ snapshots:
token-stream@1.0.0: {}
tokenjuice@0.6.1: {}
token-types@6.1.2:
dependencies:
'@borewit/text-codec': 0.2.2

View File

@@ -32,6 +32,7 @@
"dist/extensions/telegram/runtime-api.js",
"dist/extensions/telegram/runtime-setter-api.js",
"dist/extensions/tlon/runtime-api.js",
"dist/extensions/tokenjuice/runtime-api.js",
"dist/extensions/twitch/runtime-api.js",
"dist/extensions/voice-call/runtime-api.js",
"dist/extensions/webhooks/runtime-api.js",

View File

@@ -0,0 +1,303 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { afterEach, describe, expect, it } from "vitest";
import { listEmbeddedExtensionFactories } from "../plugins/embedded-extension-factory.js";
import { clearPluginLoaderCache, loadOpenClawPlugins } from "../plugins/loader.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { buildEmbeddedExtensionFactories } from "./pi-embedded-runner/extensions.js";
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const tempDirs: string[] = [];
function createTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-embedded-ext-"));
tempDirs.push(dir);
return dir;
}
function writeTempPlugin(params: {
dir: string;
id: string;
body: string;
manifest?: Record<string, unknown>;
filename?: string;
}): string {
const pluginDir = path.join(params.dir, params.id);
fs.mkdirSync(pluginDir, { recursive: true });
const file = path.join(pluginDir, params.filename ?? `${params.id}.mjs`);
fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: params.id,
...params.manifest,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return file;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
clearPluginLoaderCache();
setActivePluginRegistry(createEmptyPluginRegistry());
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
});
describe("buildEmbeddedExtensionFactories", () => {
it("includes plugin-registered embedded extension factories and restores them from cache", async () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
dir: tmp,
id: "embedded-ext",
filename: "index.mjs",
manifest: {
contracts: {
embeddedExtensionFactories: ["pi"],
},
},
body: `export default { id: "embedded-ext", register(api) {
api.registerEmbeddedExtensionFactory((pi) => {
pi.on("session_start", () => undefined);
});
} };`,
});
const options = {
config: {
plugins: {
entries: {
"embedded-ext": {
enabled: true,
},
},
},
},
};
loadOpenClawPlugins(options);
const firstFactories = buildEmbeddedExtensionFactories({
cfg: undefined,
sessionManager: SessionManager.inMemory(),
provider: "openai",
modelId: "gpt-5.4",
model: undefined,
});
expect(firstFactories).toHaveLength(1);
expect(listEmbeddedExtensionFactories()).toHaveLength(1);
setActivePluginRegistry(createEmptyPluginRegistry());
expect(listEmbeddedExtensionFactories()).toHaveLength(0);
loadOpenClawPlugins(options);
const cachedFactories = buildEmbeddedExtensionFactories({
cfg: undefined,
sessionManager: SessionManager.inMemory(),
provider: "openai",
modelId: "gpt-5.4",
model: undefined,
});
expect(cachedFactories).toHaveLength(1);
const handlers = new Map<string, Function>();
await cachedFactories[0]?.({
on(event: string, handler: Function) {
handlers.set(event, handler);
},
} as never);
expect(handlers.has("session_start")).toBe(true);
});
it("rejects embedded extension factories from non-bundled plugins even when they declare the Pi manifest contract", () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const pluginFile = writeTempPlugin({
dir: tmp,
id: "embedded-ext",
manifest: {
contracts: {
embeddedExtensionFactories: ["pi"],
},
},
body: `export default { id: "embedded-ext", register(api) {
api.registerEmbeddedExtensionFactory((pi) => {
pi.on("session_start", () => undefined);
});
} };`,
});
const registry = loadOpenClawPlugins({
workspaceDir: tmp,
config: {
plugins: {
load: { paths: [pluginFile] },
allow: ["embedded-ext"],
},
},
});
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
pluginId: "embedded-ext",
message: "only bundled plugins can register Pi embedded extension factories",
}),
);
expect(listEmbeddedExtensionFactories()).toHaveLength(0);
expect(
buildEmbeddedExtensionFactories({
cfg: undefined,
sessionManager: SessionManager.inMemory(),
provider: "openai",
modelId: "gpt-5.4",
model: undefined,
}),
).toHaveLength(0);
});
it("rejects bundled plugins that omit the Pi embedded extension manifest contract", () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
dir: tmp,
id: "embedded-ext",
filename: "index.mjs",
body: `export default { id: "embedded-ext", register(api) {
api.registerEmbeddedExtensionFactory((pi) => {
pi.on("session_start", () => undefined);
});
} };`,
});
const registry = loadOpenClawPlugins({
config: {
plugins: {
entries: {
"embedded-ext": {
enabled: true,
},
},
},
},
});
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
pluginId: "embedded-ext",
message:
'plugin must declare contracts.embeddedExtensionFactories: ["pi"] to register Pi embedded extension factories',
}),
);
expect(listEmbeddedExtensionFactories()).toHaveLength(0);
});
it("rejects non-function embedded extension factories from bundled plugins", () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
dir: tmp,
id: "embedded-ext",
filename: "index.mjs",
manifest: {
contracts: {
embeddedExtensionFactories: ["pi"],
},
},
body: `export default { id: "embedded-ext", register(api) {
api.registerEmbeddedExtensionFactory("not-a-function");
} };`,
});
const registry = loadOpenClawPlugins({
config: {
plugins: {
entries: {
"embedded-ext": {
enabled: true,
},
},
},
},
});
expect(registry.diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
pluginId: "embedded-ext",
message: "embedded extension factory must be a function",
}),
);
expect(listEmbeddedExtensionFactories()).toHaveLength(0);
});
it("contains embedded extension factory failures so one bad plugin cannot crash setup", async () => {
const tmp = createTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
dir: tmp,
id: "embedded-ext",
filename: "index.mjs",
manifest: {
contracts: {
embeddedExtensionFactories: ["pi"],
},
},
body: `export default { id: "embedded-ext", register(api) {
api.registerEmbeddedExtensionFactory(() => {
throw new Error("boom");
});
} };`,
});
loadOpenClawPlugins({
config: {
plugins: {
entries: {
"embedded-ext": {
enabled: true,
},
},
},
},
});
const factories = buildEmbeddedExtensionFactories({
cfg: undefined,
sessionManager: SessionManager.inMemory(),
provider: "openai",
modelId: "gpt-5.4",
model: undefined,
});
expect(factories).toHaveLength(1);
await expect(
factories[0]?.({
on() {},
} as never),
).resolves.toBeUndefined();
});
});

View File

@@ -1,5 +1,6 @@
import type { ExtensionFactory, SessionManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { listEmbeddedExtensionFactories } from "../../plugins/embedded-extension-factory.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { resolveContextWindowInfo } from "../context-window-guard.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
@@ -114,6 +115,7 @@ export function buildEmbeddedExtensionFactories(params: {
if (pruningFactory) {
factories.push(pruningFactory);
}
factories.push(...listEmbeddedExtensionFactories());
return factories;
}

View File

@@ -88,6 +88,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
embeddedExtensionFactories: [],
textTransforms: [],
agentHarnesses: [],
gatewayHandlers: {},

View File

@@ -22,6 +22,7 @@ function createStubPluginRegistry(): PluginRegistry {
musicGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
embeddedExtensionFactories: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],

View File

@@ -48,6 +48,7 @@ export type BuildPluginApiParams = {
| "registerContextEngine"
| "registerCompactionProvider"
| "registerAgentHarness"
| "registerEmbeddedExtensionFactory"
| "registerDetachedTaskRuntime"
| "registerMemoryCapability"
| "registerMemoryPromptSection"
@@ -99,6 +100,8 @@ const noopRegisterCommand: OpenClawPluginApi["registerCommand"] = () => {};
const noopRegisterContextEngine: OpenClawPluginApi["registerContextEngine"] = () => {};
const noopRegisterCompactionProvider: OpenClawPluginApi["registerCompactionProvider"] = () => {};
const noopRegisterAgentHarness: OpenClawPluginApi["registerAgentHarness"] = () => {};
const noopRegisterEmbeddedExtensionFactory: OpenClawPluginApi["registerEmbeddedExtensionFactory"] =
() => {};
const noopRegisterDetachedTaskRuntime: OpenClawPluginApi["registerDetachedTaskRuntime"] = () => {};
const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {};
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
@@ -166,6 +169,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
registerCompactionProvider:
handlers.registerCompactionProvider ?? noopRegisterCompactionProvider,
registerAgentHarness: handlers.registerAgentHarness ?? noopRegisterAgentHarness,
registerEmbeddedExtensionFactory:
handlers.registerEmbeddedExtensionFactory ?? noopRegisterEmbeddedExtensionFactory,
registerDetachedTaskRuntime:
handlers.registerDetachedTaskRuntime ?? noopRegisterDetachedTaskRuntime,
registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability,

View File

@@ -111,20 +111,16 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
});
});
it("falls back to npm.cmd through shell on Windows", () => {
const runner = resolveBundledRuntimeDepsNpmRunner({
env: {},
execPath: "C:\\Program Files\\nodejs\\node.exe",
existsSync: () => false,
npmArgs: ["install"],
platform: "win32",
});
expect(runner).toEqual({
command: "npm.cmd",
args: ["install"],
shell: true,
});
it("refuses Windows shell fallback when no safe npm executable is available", () => {
expect(() =>
resolveBundledRuntimeDepsNpmRunner({
env: {},
execPath: "C:\\Program Files\\nodejs\\node.exe",
existsSync: () => false,
npmArgs: ["install"],
platform: "win32",
}),
).toThrow("Unable to resolve a safe npm executable on Windows");
});
it("prefixes PATH with the active Node directory on POSIX", () => {
@@ -151,7 +147,9 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
describe("installBundledRuntimeDeps", () => {
it("uses the npm cmd shim on Windows", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.spyOn(fs, "existsSync").mockReturnValue(false);
vi.spyOn(fs, "existsSync").mockImplementation(
(candidate) => candidate === "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
);
spawnSyncMock.mockReturnValue({
pid: 123,
output: [],
@@ -164,15 +162,18 @@ describe("installBundledRuntimeDeps", () => {
installBundledRuntimeDeps({
installRoot: "C:\\openclaw",
missingSpecs: ["acpx@0.5.3"],
env: { npm_config_prefix: "C:\\prefix", PATH: "C:\\node" },
env: {
npm_config_prefix: "C:\\prefix",
PATH: "C:\\node",
npm_execpath: "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
},
});
expect(spawnSyncMock).toHaveBeenCalledWith(
"npm.cmd",
["install", "--ignore-scripts", "acpx@0.5.3"],
expect.any(String),
["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "install", "--ignore-scripts", "acpx@0.5.3"],
expect.objectContaining({
cwd: "C:\\openclaw",
shell: true,
env: expect.objectContaining({
npm_config_legacy_peer_deps: "true",
npm_config_package_lock: "false",
@@ -191,6 +192,65 @@ describe("installBundledRuntimeDeps", () => {
);
});
it("uses an isolated execution root and copies node_modules back when requested", () => {
const installRoot = makeTempDir();
const installExecutionRoot = makeTempDir();
spawnSyncMock.mockImplementation((_command, _args, options) => {
const cwd = String(options?.cwd ?? "");
fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true });
fs.writeFileSync(
path.join(cwd, "node_modules", "tokenjuice", "package.json"),
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
);
return {
pid: 123,
output: [],
stdout: "",
stderr: "",
signal: null,
status: 0,
};
});
installBundledRuntimeDeps({
installRoot,
installExecutionRoot,
missingSpecs: ["tokenjuice@0.6.1"],
env: {},
});
expect(
JSON.parse(fs.readFileSync(path.join(installExecutionRoot, "package.json"), "utf8")),
).toEqual({
name: "openclaw-runtime-deps-install",
private: true,
});
expect(
JSON.parse(
fs.readFileSync(
path.join(installRoot, "node_modules", "tokenjuice", "package.json"),
"utf8",
),
),
).toEqual({
name: "tokenjuice",
version: "0.6.1",
});
expect(spawnSyncMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
cwd: installExecutionRoot,
}),
);
});
it("rejects invalid install specs before spawning npm", () => {
expect(() =>
createBundledRuntimeDepsInstallArgs(["tokenjuice@https://evil.example/t.tgz"]),
).toThrow("Unsupported bundled runtime dependency spec for tokenjuice");
});
it("includes spawn errors in install failures", () => {
spawnSyncMock.mockReturnValue({
pid: 0,
@@ -457,6 +517,191 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
});
it("installs missing runtime deps for source-checkout bundled plugins", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
tokenjuice: "0.6.1",
},
}),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
installDeps: (params) => {
calls.push(params);
},
pluginId: "tokenjuice",
pluginRoot,
});
expect(result).toEqual({
installedSpecs: ["tokenjuice@0.6.1"],
retainSpecs: ["tokenjuice@0.6.1"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
});
expect(calls).toEqual([
{
installRoot,
missingSpecs: ["tokenjuice@0.6.1"],
installSpecs: ["tokenjuice@0.6.1"],
},
]);
expect(installRoot).toContain(stageDir);
expect(installRoot).not.toBe(pluginRoot);
});
it("keeps source-checkout bundled runtime deps in the plugin root by default", () => {
const packageRoot = makeTempDir();
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
tokenjuice: "0.6.1",
},
}),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: {},
installDeps: (params) => {
calls.push(params);
},
pluginId: "tokenjuice",
pluginRoot,
});
expect(result).toEqual({
installedSpecs: ["tokenjuice@0.6.1"],
retainSpecs: ["tokenjuice@0.6.1"],
});
expect(calls).toEqual([
{
installRoot: pluginRoot,
installExecutionRoot: expect.stringContaining(
path.join(".local", "bundled-plugin-runtime-deps"),
),
missingSpecs: ["tokenjuice@0.6.1"],
installSpecs: ["tokenjuice@0.6.1"],
},
]);
expect(resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} })).toBe(pluginRoot);
});
it("does not trust package-root runtime deps for source-checkout bundled plugins", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), {
recursive: true,
});
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
tokenjuice: "0.6.1",
},
}),
);
fs.writeFileSync(
path.join(packageRoot, "node_modules", "tokenjuice", "package.json"),
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
installDeps: (params) => {
calls.push(params);
},
pluginId: "tokenjuice",
pluginRoot,
});
expect(result).toEqual({
installedSpecs: ["tokenjuice@0.6.1"],
retainSpecs: ["tokenjuice@0.6.1"],
});
expect(calls).toEqual([
{
installRoot: resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
}),
missingSpecs: ["tokenjuice@0.6.1"],
installSpecs: ["tokenjuice@0.6.1"],
},
]);
});
it("does not reuse mismatched package-root runtime deps for source-checkout bundled plugins", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), {
recursive: true,
});
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
tokenjuice: "0.6.1",
},
}),
);
fs.writeFileSync(
path.join(packageRoot, "node_modules", "tokenjuice", "package.json"),
JSON.stringify({ name: "tokenjuice", version: "0.6.0" }),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
installDeps: (params) => {
calls.push(params);
},
pluginId: "tokenjuice",
pluginRoot,
});
expect(result).toEqual({
installedSpecs: ["tokenjuice@0.6.1"],
retainSpecs: ["tokenjuice@0.6.1"],
});
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
});
expect(calls).toEqual([
{
installRoot,
missingSpecs: ["tokenjuice@0.6.1"],
installSpecs: ["tokenjuice@0.6.1"],
},
]);
expect(installRoot).toContain(stageDir);
expect(installRoot).not.toBe(pluginRoot);
});
it("skips install when staged plugin-local runtime deps are present", () => {
const packageRoot = makeTempDir();
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
@@ -489,7 +734,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
});
it("skips install when runtime deps resolve from the package root", () => {
it("does not trust runtime deps that only resolve from the package root", () => {
const packageRoot = makeTempDir();
const pluginRoot = path.join(packageRoot, "dist", "extensions", "openai");
fs.mkdirSync(path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai"), {
@@ -508,20 +753,31 @@ describe("ensureBundledPluginRuntimeDeps", () => {
path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai", "package.json"),
JSON.stringify({ name: "@mariozechner/pi-ai", version: "0.68.1" }),
);
const calls: BundledRuntimeDepsInstallParams[] = [];
const result = ensureBundledPluginRuntimeDeps({
env: {},
installDeps: () => {
throw new Error("package-root runtime deps should not reinstall");
installDeps: (params) => {
calls.push(params);
},
pluginId: "openai",
pluginRoot,
});
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
expect(result).toEqual({
installedSpecs: ["@mariozechner/pi-ai@0.68.1"],
retainSpecs: ["@mariozechner/pi-ai@0.68.1"],
});
expect(calls).toEqual([
{
installRoot: pluginRoot,
missingSpecs: ["@mariozechner/pi-ai@0.68.1"],
installSpecs: ["@mariozechner/pi-ai@0.68.1"],
},
]);
});
it("installs only deps missing from plugin and package-root resolution", () => {
it("installs deps that are only present in the package root", () => {
const packageRoot = makeTempDir();
const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex");
fs.mkdirSync(path.join(packageRoot, "node_modules", "ws"), { recursive: true });
@@ -551,13 +807,13 @@ describe("ensureBundledPluginRuntimeDeps", () => {
});
expect(result).toEqual({
installedSpecs: ["zod@^4.3.6"],
installedSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
retainSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
});
expect(calls).toEqual([
{
installRoot: pluginRoot,
missingSpecs: ["zod@^4.3.6"],
missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
installSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
},
]);
@@ -607,6 +863,56 @@ describe("ensureBundledPluginRuntimeDeps", () => {
]);
});
it("rejects unsupported remote runtime dependency specs", () => {
const packageRoot = makeTempDir();
const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
tokenjuice: "https://evil.example/tokenjuice.tgz",
},
}),
);
expect(() =>
ensureBundledPluginRuntimeDeps({
env: {},
installDeps: () => {
throw new Error("should not attempt install");
},
pluginId: "tokenjuice",
pluginRoot,
}),
).toThrow("Unsupported bundled runtime dependency spec for tokenjuice");
});
it("rejects invalid runtime dependency names before resolving sentinels", () => {
const packageRoot = makeTempDir();
const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({
dependencies: {
"../escape": "0.6.1",
},
}),
);
expect(() =>
ensureBundledPluginRuntimeDeps({
env: {},
installDeps: () => {
throw new Error("should not attempt install");
},
pluginId: "tokenjuice",
pluginRoot,
}),
).toThrow("Invalid bundled runtime dependency name");
});
it("rehydrates source-checkout dist deps from cache after rebuilds", () => {
const packageRoot = makeTempDir();
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });

View File

@@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { normalizePluginsConfig } from "./config-state.js";
import { satisfies, validRange, validSemver } from "./semver.runtime.js";
export type RuntimeDepEntry = {
name: string;
@@ -24,6 +25,7 @@ export type RuntimeDepConflict = {
export type BundledRuntimeDepsInstallParams = {
installRoot: string;
installExecutionRoot?: string;
missingSpecs: string[];
installSpecs?: string[];
};
@@ -45,11 +47,108 @@ export type BundledRuntimeDepsNpmRunner = {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
shell?: boolean;
};
const BUNDLED_RUNTIME_DEP_SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
function normalizeInstallableRuntimeDepName(rawName: string): string | null {
const depName = rawName.trim();
if (depName === "") {
return null;
}
const segments = depName.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
return null;
}
if (segments.length === 1) {
return BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(segments[0] ?? "") ? depName : null;
}
if (segments.length !== 2 || !segments[0]?.startsWith("@")) {
return null;
}
const scope = segments[0].slice(1);
const packageName = segments[1];
return BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(scope) &&
BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(packageName ?? "")
? depName
: null;
}
function normalizeInstallableRuntimeDepVersion(rawVersion: unknown): string | null {
if (typeof rawVersion !== "string") {
return null;
}
const version = rawVersion.trim();
if (version === "" || version.toLowerCase().startsWith("workspace:")) {
return null;
}
if (validSemver(version)) {
return version;
}
const rangePrefix = version[0];
if ((rangePrefix === "^" || rangePrefix === "~") && validSemver(version.slice(1))) {
return version;
}
return null;
}
function parseInstallableRuntimeDep(
name: string,
rawVersion: unknown,
): { name: string; version: string } | null {
if (typeof rawVersion !== "string") {
return null;
}
const version = rawVersion.trim();
if (version === "" || version.toLowerCase().startsWith("workspace:")) {
return null;
}
const normalizedName = normalizeInstallableRuntimeDepName(name);
if (!normalizedName) {
throw new Error(`Invalid bundled runtime dependency name: ${name}`);
}
const normalizedVersion = normalizeInstallableRuntimeDepVersion(version);
if (!normalizedVersion) {
throw new Error(
`Unsupported bundled runtime dependency spec for ${normalizedName}: ${version}`,
);
}
return { name: normalizedName, version: normalizedVersion };
}
function parseInstallableRuntimeDepSpec(spec: string): { name: string; version: string } {
const atIndex = spec.lastIndexOf("@");
if (atIndex <= 0 || atIndex === spec.length - 1) {
throw new Error(`Invalid bundled runtime dependency install spec: ${spec}`);
}
const parsed = parseInstallableRuntimeDep(spec.slice(0, atIndex), spec.slice(atIndex + 1));
if (!parsed) {
throw new Error(`Invalid bundled runtime dependency install spec: ${spec}`);
}
return parsed;
}
function dependencySentinelPath(depName: string): string {
return path.join("node_modules", ...depName.split("/"), "package.json");
const normalizedDepName = normalizeInstallableRuntimeDepName(depName);
if (!normalizedDepName) {
throw new Error(`Invalid bundled runtime dependency name: ${depName}`);
}
return path.join("node_modules", ...normalizedDepName.split("/"), "package.json");
}
function resolveDependencySentinelAbsolutePath(rootDir: string, depName: string): string {
const nodeModulesDir = path.resolve(rootDir, "node_modules");
const sentinelPath = path.resolve(rootDir, dependencySentinelPath(depName));
if (sentinelPath !== nodeModulesDir && !sentinelPath.startsWith(`${nodeModulesDir}${path.sep}`)) {
throw new Error(`Blocked runtime dependency path escape for ${depName}`);
}
return sentinelPath;
}
function readInstalledDependencyVersion(rootDir: string, depName: string): string | null {
const parsed = readJsonObject(resolveDependencySentinelAbsolutePath(rootDir, depName));
const version = parsed && typeof parsed.version === "string" ? parsed.version.trim() : "";
return version || null;
}
function readJsonObject(filePath: string): JsonObject | null {
@@ -71,17 +170,6 @@ function collectRuntimeDeps(packageJson: JsonObject): Record<string, unknown> {
};
}
function normalizeInstallableRuntimeDepVersion(rawVersion: unknown): string | null {
if (typeof rawVersion !== "string") {
return null;
}
const version = rawVersion.trim();
if (version === "" || version.toLowerCase().startsWith("workspace:")) {
return null;
}
return version;
}
function isSourceCheckoutRoot(packageRoot: string): boolean {
return (
fs.existsSync(path.join(packageRoot, ".git")) &&
@@ -90,12 +178,13 @@ function isSourceCheckoutRoot(packageRoot: string): boolean {
);
}
function isSourceCheckoutBundledPluginRoot(pluginRoot: string): boolean {
const extensionsDir = path.dirname(pluginRoot);
function resolveSourceCheckoutBundledPluginPackageRoot(pluginRoot: string): string | null {
const extensionsDir = path.dirname(path.resolve(pluginRoot));
if (path.basename(extensionsDir) !== "extensions") {
return false;
return null;
}
return isSourceCheckoutRoot(path.dirname(extensionsDir));
const packageRoot = path.dirname(extensionsDir);
return isSourceCheckoutRoot(packageRoot) ? packageRoot : null;
}
function resolveSourceCheckoutDistPackageRoot(pluginRoot: string): string | null {
@@ -111,24 +200,11 @@ function resolveSourceCheckoutDistPackageRoot(pluginRoot: string): string | null
return isSourceCheckoutRoot(packageRoot) ? packageRoot : null;
}
function resolveBundledRuntimeDependencySearchRoots(params: {
installRoot: string;
pluginRoot: string;
}): string[] {
const roots = new Set<string>([params.installRoot]);
const pluginRoot = path.resolve(params.pluginRoot);
const extensionsDir = path.dirname(pluginRoot);
const buildDir = path.dirname(extensionsDir);
if (
path.basename(extensionsDir) !== "extensions" ||
(path.basename(buildDir) !== "dist" && path.basename(buildDir) !== "dist-runtime")
) {
return [...roots];
}
roots.add(extensionsDir);
roots.add(buildDir);
roots.add(path.dirname(buildDir));
return [...roots];
function resolveSourceCheckoutPackageRoot(pluginRoot: string): string | null {
return (
resolveSourceCheckoutBundledPluginPackageRoot(pluginRoot) ??
resolveSourceCheckoutDistPackageRoot(pluginRoot)
);
}
function resolveBundledPluginPackageRoot(pluginRoot: string): string | null {
@@ -178,6 +254,7 @@ function readRetainedRuntimeDepsManifest(installRoot: string): string[] {
}
function writeRetainedRuntimeDepsManifest(installRoot: string, specs: readonly string[]): void {
fs.mkdirSync(installRoot, { recursive: true });
fs.writeFileSync(
path.join(installRoot, RETAINED_RUNTIME_DEPS_MANIFEST),
`${JSON.stringify({ specs: [...specs].toSorted((left, right) => left.localeCompare(right)) }, null, 2)}\n`,
@@ -230,7 +307,7 @@ function resolveSourceCheckoutRuntimeDepsCacheDir(params: {
pluginRoot: string;
installSpecs: readonly string[];
}): string | null {
const packageRoot = resolveSourceCheckoutDistPackageRoot(params.pluginRoot);
const packageRoot = resolveSourceCheckoutPackageRoot(params.pluginRoot);
if (!packageRoot) {
return null;
}
@@ -246,10 +323,28 @@ function hasAllDependencySentinels(rootDir: string, deps: readonly { name: strin
return deps.every((dep) => fs.existsSync(path.join(rootDir, dependencySentinelPath(dep.name))));
}
function hasDependencySentinel(searchRoots: readonly string[], dep: { name: string }): boolean {
return searchRoots.some((rootDir) =>
fs.existsSync(path.join(rootDir, dependencySentinelPath(dep.name))),
);
function isInstalledDependencyVersionSatisfied(installedVersion: string, spec: string): boolean {
const normalizedInstalledVersion = validSemver(installedVersion);
const normalizedRange = validRange(spec);
if (normalizedInstalledVersion && normalizedRange) {
return satisfies(normalizedInstalledVersion, normalizedRange, {
includePrerelease: true,
});
}
return installedVersion === spec;
}
function hasDependencySentinel(
searchRoots: readonly string[],
dep: { name: string; version: string },
): boolean {
return searchRoots.some((rootDir) => {
const installedVersion = readInstalledDependencyVersion(rootDir, dep.name);
return (
typeof installedVersion === "string" &&
isInstalledDependencyVersionSatisfied(installedVersion, dep.version)
);
});
}
function replaceNodeModulesDir(targetDir: string, sourceDir: string): void {
@@ -328,6 +423,9 @@ export function createBundledRuntimeDepsInstallEnv(env: NodeJS.ProcessEnv): Node
}
export function createBundledRuntimeDepsInstallArgs(missingSpecs: readonly string[]): string[] {
missingSpecs.forEach((spec) => {
parseInstallableRuntimeDepSpec(spec);
});
return ["install", "--ignore-scripts", ...missingSpecs];
}
@@ -384,11 +482,7 @@ export function resolveBundledRuntimeDepsNpmRunner(params: {
args: params.npmArgs,
};
}
return {
command: "npm.cmd",
args: params.npmArgs,
shell: true,
};
throw new Error("Unable to resolve a safe npm executable on Windows");
}
const pathKey = resolvePathEnvKey(env, platform);
@@ -513,15 +607,15 @@ function collectBundledPluginRuntimeDeps(params: {
continue;
}
for (const [name, rawVersion] of Object.entries(collectRuntimeDeps(packageJson))) {
const version = normalizeInstallableRuntimeDepVersion(rawVersion);
if (!version) {
const dep = parseInstallableRuntimeDep(name, rawVersion);
if (!dep) {
continue;
}
const byVersion = versionMap.get(name) ?? new Map<string, Set<string>>();
const pluginIds = byVersion.get(version) ?? new Set<string>();
const byVersion = versionMap.get(dep.name) ?? new Map<string, Set<string>>();
const pluginIds = byVersion.get(dep.version) ?? new Set<string>();
pluginIds.add(pluginId);
byVersion.set(version, pluginIds);
versionMap.set(name, byVersion);
byVersion.set(dep.version, pluginIds);
versionMap.set(dep.name, byVersion);
}
}
@@ -599,7 +693,7 @@ export function scanBundledPluginRuntimeDeps(params: {
const packageInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(params.packageRoot, {
env: params.env,
});
const packageSearchRoots = [packageInstallRoot, params.packageRoot, extensionsDir];
const packageSearchRoots = [packageInstallRoot];
const missing = deps.filter(
(dep) =>
!hasDependencySentinel(packageSearchRoots, dep) &&
@@ -608,7 +702,7 @@ export function scanBundledPluginRuntimeDeps(params: {
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
env: params.env,
});
return !hasDependencySentinel([installRoot, pluginRoot], dep);
return !hasDependencySentinel([installRoot], dep);
}),
);
return { deps, missing, conflicts };
@@ -680,9 +774,13 @@ export function createBundledRuntimeDependencyAliasMap(params: {
for (const name of Object.keys(collectRuntimeDeps(packageJson)).toSorted((a, b) =>
a.localeCompare(b),
)) {
const target = path.join(params.installRoot, "node_modules", ...name.split("/"));
const normalizedName = normalizeInstallableRuntimeDepName(name);
if (!normalizedName) {
continue;
}
const target = path.join(params.installRoot, "node_modules", ...normalizedName.split("/"));
if (fs.existsSync(path.join(target, "package.json"))) {
aliases[name] = target;
aliases[normalizedName] = target;
}
}
return aliases;
@@ -690,21 +788,30 @@ export function createBundledRuntimeDependencyAliasMap(params: {
export function installBundledRuntimeDeps(params: {
installRoot: string;
installExecutionRoot?: string;
missingSpecs: string[];
env: NodeJS.ProcessEnv;
}): void {
const installExecutionRoot = params.installExecutionRoot ?? params.installRoot;
fs.mkdirSync(params.installRoot, { recursive: true });
fs.mkdirSync(installExecutionRoot, { recursive: true });
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
fs.writeFileSync(
path.join(installExecutionRoot, "package.json"),
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
"utf8",
);
}
const installEnv = createBundledRuntimeDepsInstallEnv(params.env);
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
env: installEnv,
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),
});
const result = spawnSync(npmRunner.command, npmRunner.args, {
cwd: params.installRoot,
cwd: installExecutionRoot,
encoding: "utf8",
env: npmRunner.env ?? installEnv,
stdio: "pipe",
shell: npmRunner.shell ?? false,
});
if (result.status !== 0 || result.error) {
const output = [result.error?.message, result.stderr, result.stdout]
@@ -713,6 +820,13 @@ export function installBundledRuntimeDeps(params: {
.trim();
throw new Error(output || "npm install failed");
}
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
const stagedNodeModulesDir = path.join(installExecutionRoot, "node_modules");
if (!fs.existsSync(stagedNodeModulesDir)) {
throw new Error("npm install did not produce node_modules");
}
replaceNodeModulesDir(path.join(params.installRoot, "node_modules"), stagedNodeModulesDir);
}
}
export function ensureBundledPluginRuntimeDeps(params: {
@@ -723,9 +837,6 @@ export function ensureBundledPluginRuntimeDeps(params: {
retainSpecs?: readonly string[];
installDeps?: (params: BundledRuntimeDepsInstallParams) => void;
}): BundledRuntimeDepsEnsureResult {
if (isSourceCheckoutBundledPluginRoot(params.pluginRoot)) {
return { installedSpecs: [], retainSpecs: [] };
}
if (
params.config &&
!isBundledPluginConfiguredForRuntimeDeps({
@@ -741,10 +852,7 @@ export function ensureBundledPluginRuntimeDeps(params: {
return { installedSpecs: [], retainSpecs: [] };
}
const deps = Object.entries(collectRuntimeDeps(packageJson))
.map(([name, rawVersion]) => {
const version = normalizeInstallableRuntimeDepVersion(rawVersion);
return version ? { name, version } : null;
})
.map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion))
.filter((entry): entry is { name: string; version: string } => Boolean(entry));
if (deps.length === 0) {
return { installedSpecs: [], retainSpecs: [] };
@@ -753,15 +861,11 @@ export function ensureBundledPluginRuntimeDeps(params: {
const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot, {
env: params.env,
});
const dependencySearchRoots = resolveBundledRuntimeDependencySearchRoots({
installRoot,
pluginRoot: params.pluginRoot,
});
const dependencySpecs = deps
.map((dep) => `${dep.name}@${dep.version}`)
.toSorted((left, right) => left.localeCompare(right));
const missingSpecs = deps
.filter((dep) => !hasDependencySentinel(dependencySearchRoots, dep))
.filter((dep) => !hasDependencySentinel([installRoot], dep))
.map((dep) => `${dep.name}@${dep.version}`)
.toSorted((left, right) => left.localeCompare(right));
if (missingSpecs.length === 0) {
@@ -776,6 +880,12 @@ export function ensureBundledPluginRuntimeDeps(params: {
pluginRoot: params.pluginRoot,
installSpecs,
});
const installExecutionRoot =
cacheDir &&
path.resolve(installRoot) === path.resolve(params.pluginRoot) &&
resolveSourceCheckoutBundledPluginPackageRoot(params.pluginRoot)
? cacheDir
: undefined;
if (
restoreSourceCheckoutRuntimeDepsFromCache({
cacheDir,
@@ -791,10 +901,11 @@ export function ensureBundledPluginRuntimeDeps(params: {
((installParams) =>
installBundledRuntimeDeps({
installRoot: installParams.installRoot,
installExecutionRoot: installParams.installExecutionRoot,
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
env: params.env,
}));
install({ installRoot, missingSpecs, installSpecs });
install({ installRoot, installExecutionRoot, missingSpecs, installSpecs });
writeRetainedRuntimeDepsManifest(installRoot, installSpecs);
storeSourceCheckoutRuntimeDepsCache({ cacheDir, installRoot });
return { installedSpecs: missingSpecs, retainSpecs: installSpecs };

View File

@@ -1,3 +1,4 @@
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildPluginApi } from "./api-builder.js";
import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js";
@@ -6,10 +7,10 @@ import type {
AnyAgentTool,
AgentHarness,
CliBackendPlugin,
OpenClawPluginApi,
ImageGenerationProviderPlugin,
MediaUnderstandingProviderPlugin,
MusicGenerationProviderPlugin,
OpenClawPluginApi,
OpenClawPluginCliCommandDescriptor,
OpenClawPluginCliRegistrar,
PluginTextTransformRegistration,
@@ -35,6 +36,7 @@ export type CapturedPluginRegistration = {
cliRegistrars: CapturedPluginCliRegistration[];
cliBackends: CliBackendPlugin[];
textTransforms: PluginTextTransformRegistration[];
embeddedExtensionFactories: ExtensionFactory[];
speechProviders: SpeechProviderPlugin[];
realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[];
realtimeVoiceProviders: RealtimeVoiceProviderPlugin[];
@@ -57,6 +59,7 @@ export function createCapturedPluginRegistration(params?: {
const cliRegistrars: CapturedPluginCliRegistration[] = [];
const cliBackends: CliBackendPlugin[] = [];
const textTransforms: PluginTextTransformRegistration[] = [];
const embeddedExtensionFactories: ExtensionFactory[] = [];
const speechProviders: SpeechProviderPlugin[] = [];
const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = [];
const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = [];
@@ -81,6 +84,7 @@ export function createCapturedPluginRegistration(params?: {
cliRegistrars,
cliBackends,
textTransforms,
embeddedExtensionFactories,
speechProviders,
realtimeTranscriptionProviders,
realtimeVoiceProviders,
@@ -131,6 +135,9 @@ export function createCapturedPluginRegistration(params?: {
registerAgentHarness(harness: AgentHarness) {
agentHarnesses.push(harness);
},
registerEmbeddedExtensionFactory(factory: ExtensionFactory) {
embeddedExtensionFactories.push(factory);
},
registerCliBackend(backend: CliBackendPlugin) {
cliBackends.push(backend);
},

View File

@@ -108,14 +108,16 @@ export async function loadPluginCliCommandRegistryWithContext(params: {
primaryCommand?: string;
loaderOptions?: PluginCliLoaderOptions;
}): Promise<PluginCliRegistryLoadResult> {
const onlyPluginIds = resolvePrimaryCommandPluginIds(params.context, params.primaryCommand);
return {
...params.context,
registry: loadOpenClawPlugins(
buildPluginCliLoaderParams(
params.context,
{ primaryCommand: params.primaryCommand },
params.loaderOptions,
),
buildPluginRuntimeLoadOptions(params.context, {
...params.loaderOptions,
...(onlyPluginIds.length > 0 ? { onlyPluginIds } : {}),
activate: false,
cache: false,
}),
),
};
}

View File

@@ -298,6 +298,8 @@ describe("registerPluginCliCommands", () => {
autoEnabledReasons: {
demo: ["demo configured"],
},
activate: false,
cache: false,
}),
);
expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled();

View File

@@ -0,0 +1,8 @@
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
import { getActivePluginRegistry } from "./runtime.js";
export const PI_EMBEDDED_EXTENSION_RUNTIME_ID = "pi";
export function listEmbeddedExtensionFactories(): ExtensionFactory[] {
return getActivePluginRegistry()?.embeddedExtensionFactories?.map((entry) => entry.factory) ?? [];
}

View File

@@ -933,6 +933,78 @@ module.exports = {
expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded");
});
it("keeps bundled runtime dep install logs off non-activating loads", () => {
const bundledDir = makeTempDir();
const plugin = writePlugin({
id: "discord",
dir: path.join(bundledDir, "discord"),
filename: "index.cjs",
body: `module.exports = { id: "discord", register() {} };`,
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
fs.writeFileSync(
path.join(plugin.dir, "package.json"),
JSON.stringify(
{
name: "@openclaw/discord",
version: "1.0.0",
dependencies: {
"discord-runtime": "1.0.0",
},
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "discord",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const registry = loadOpenClawPlugins({
cache: false,
activate: false,
logger,
config: {
plugins: {
enabled: true,
},
},
bundledRuntimeDepsInstaller: ({ installRoot }) => {
fs.mkdirSync(path.join(installRoot, "node_modules", "discord-runtime"), {
recursive: true,
});
fs.writeFileSync(
path.join(installRoot, "node_modules", "discord-runtime", "package.json"),
JSON.stringify({ name: "discord-runtime", version: "1.0.0" }),
"utf-8",
);
},
});
expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded");
expect(logger.info).not.toHaveBeenCalledWith(
"[plugins] discord installed bundled runtime deps: discord-runtime@1.0.0",
);
});
it("does not repair disabled bundled plugin runtime deps", () => {
const bundledDir = makeTempDir();
const plugin = writePlugin({
@@ -1190,6 +1262,88 @@ module.exports = {
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
});
it("loads source-checkout bundled runtime deps without mirroring the repo tree", () => {
const packageRoot = makeTempDir();
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
const bundledDir = path.join(packageRoot, "extensions");
const plugin = writePlugin({
id: "tokenjuice",
dir: path.join(bundledDir, "tokenjuice"),
filename: "index.cjs",
body: `
const runtimeDep = require("external-runtime");
module.exports = {
id: "tokenjuice",
register(api) {
api.registerCommand({ name: "external-runtime", handler: () => runtimeDep.marker });
}
};
`,
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
fs.writeFileSync(
path.join(plugin.dir, "package.json"),
JSON.stringify(
{
name: "@openclaw/tokenjuice",
version: "1.0.0",
dependencies: {
"external-runtime": "1.0.0",
},
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "tokenjuice",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
const installRoots: string[] = [];
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
},
},
bundledRuntimeDepsInstaller: ({ installRoot }) => {
installRoots.push(fs.realpathSync(installRoot));
const depRoot = path.join(installRoot, "node_modules", "external-runtime");
fs.mkdirSync(depRoot, { recursive: true });
fs.writeFileSync(
path.join(depRoot, "package.json"),
JSON.stringify({ name: "external-runtime", version: "1.0.0", main: "index.cjs" }),
"utf-8",
);
fs.writeFileSync(
path.join(depRoot, "index.cjs"),
"module.exports = { marker: 'source-checkout-ok' };\n",
"utf-8",
);
},
});
expect(installRoots).toEqual([fs.realpathSync(plugin.dir)]);
expect(registry.plugins.find((entry) => entry.id === "tokenjuice")?.status).toBe("loaded");
expect(resolveLoadedPluginSource(registry, "tokenjuice")).toBe(
fs.realpathSync(path.join(plugin.dir, "index.cjs")),
);
});
it("registers standalone text transforms", () => {
useNoBundledPlugins();
const plugin = writePlugin({

View File

@@ -276,6 +276,7 @@ type PluginRegistrySnapshot = {
musicGenerationProviders: PluginRegistry["musicGenerationProviders"];
webFetchProviders: PluginRegistry["webFetchProviders"];
webSearchProviders: PluginRegistry["webSearchProviders"];
embeddedExtensionFactories: PluginRegistry["embeddedExtensionFactories"];
memoryEmbeddingProviders: PluginRegistry["memoryEmbeddingProviders"];
agentHarnesses: PluginRegistry["agentHarnesses"];
httpRoutes: PluginRegistry["httpRoutes"];
@@ -312,6 +313,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho
musicGenerationProviders: [...registry.musicGenerationProviders],
webFetchProviders: [...registry.webFetchProviders],
webSearchProviders: [...registry.webSearchProviders],
embeddedExtensionFactories: [...registry.embeddedExtensionFactories],
memoryEmbeddingProviders: [...registry.memoryEmbeddingProviders],
agentHarnesses: [...registry.agentHarnesses],
httpRoutes: [...registry.httpRoutes],
@@ -347,6 +349,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr
registry.musicGenerationProviders = snapshot.arrays.musicGenerationProviders;
registry.webFetchProviders = snapshot.arrays.webFetchProviders;
registry.webSearchProviders = snapshot.arrays.webSearchProviders;
registry.embeddedExtensionFactories = snapshot.arrays.embeddedExtensionFactories;
registry.memoryEmbeddingProviders = snapshot.arrays.memoryEmbeddingProviders;
registry.agentHarnesses = snapshot.arrays.agentHarnesses;
registry.httpRoutes = snapshot.arrays.httpRoutes;
@@ -1912,9 +1915,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
(left, right) => left.localeCompare(right),
),
);
logger.info(
`[plugins] ${record.id} installed bundled runtime deps: ${depsInstallResult.installedSpecs.join(", ")}`,
);
if (shouldActivate) {
logger.info(
`[plugins] ${record.id} installed bundled runtime deps: ${depsInstallResult.installedSpecs.join(", ")}`,
);
}
}
if (path.resolve(installRoot) !== path.resolve(pluginRoot)) {
registerBundledRuntimeDependencyNodePath(installRoot);

View File

@@ -230,6 +230,7 @@ export type PluginManifest = {
};
export type PluginManifestContracts = {
embeddedExtensionFactories?: string[];
memoryEmbeddingProviders?: string[];
speechProviders?: string[];
realtimeTranscriptionProviders?: string[];
@@ -416,6 +417,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
return undefined;
}
const embeddedExtensionFactories = normalizeTrimmedStringList(value.embeddedExtensionFactories);
const memoryEmbeddingProviders = normalizeTrimmedStringList(value.memoryEmbeddingProviders);
const speechProviders = normalizeTrimmedStringList(value.speechProviders);
const realtimeTranscriptionProviders = normalizeTrimmedStringList(
@@ -430,6 +432,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
const webSearchProviders = normalizeTrimmedStringList(value.webSearchProviders);
const tools = normalizeTrimmedStringList(value.tools);
const contracts = {
...(embeddedExtensionFactories.length > 0 ? { embeddedExtensionFactories } : {}),
...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}),
...(speechProviders.length > 0 ? { speechProviders } : {}),
...(realtimeTranscriptionProviders.length > 0 ? { realtimeTranscriptionProviders } : {}),

View File

@@ -20,6 +20,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
musicGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
embeddedExtensionFactories: [],
memoryEmbeddingProviders: [],
agentHarnesses: [],
gatewayHandlers: {},

View File

@@ -1,3 +1,4 @@
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
import type { AgentHarness } from "../agents/harness/types.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { OperatorScope } from "../gateway/operator-scopes.js";
@@ -144,6 +145,14 @@ export type PluginWebSearchProviderRegistration =
PluginOwnedProviderRegistration<WebSearchProviderPlugin>;
export type PluginMemoryEmbeddingProviderRegistration =
PluginOwnedProviderRegistration<MemoryEmbeddingProviderAdapter>;
export type PluginEmbeddedExtensionFactoryRegistration = {
pluginId: string;
pluginName?: string;
rawFactory: ExtensionFactory;
factory: ExtensionFactory;
source: string;
rootDir?: string;
};
export type PluginAgentHarnessRegistration = {
pluginId: string;
pluginName?: string;
@@ -281,6 +290,7 @@ export type PluginRegistry = {
musicGenerationProviders: PluginMusicGenerationProviderRegistration[];
webFetchProviders: PluginWebFetchProviderRegistration[];
webSearchProviders: PluginWebSearchProviderRegistration[];
embeddedExtensionFactories: PluginEmbeddedExtensionFactoryRegistration[];
memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[];
agentHarnesses: PluginAgentHarnessRegistration[];
gatewayHandlers: GatewayRequestHandlers;

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
import {
getRegisteredAgentHarness,
registerAgentHarness as registerGlobalAgentHarness,
@@ -35,6 +36,7 @@ import {
getRegisteredCompactionProvider,
registerCompactionProvider,
} from "./compaction-provider.js";
import { PI_EMBEDDED_EXTENSION_RUNTIME_ID } from "./embedded-extension-factory.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
import {
@@ -196,6 +198,69 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registry.diagnostics.push(diag);
};
const registerPiEmbeddedExtensionFactory = (
record: PluginRecord,
factory: Parameters<OpenClawPluginApi["registerEmbeddedExtensionFactory"]>[0],
) => {
if (record.origin !== "bundled") {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "only bundled plugins can register Pi embedded extension factories",
});
return;
}
if (
!(record.contracts?.embeddedExtensionFactories ?? []).includes(
PI_EMBEDDED_EXTENSION_RUNTIME_ID,
)
) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message:
'plugin must declare contracts.embeddedExtensionFactories: ["pi"] to register Pi embedded extension factories',
});
return;
}
if (typeof (factory as unknown) !== "function") {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "embedded extension factory must be a function",
});
return;
}
if (
registry.embeddedExtensionFactories.some(
(entry) => entry.pluginId === record.id && entry.rawFactory === factory,
)
) {
return;
}
const safeFactory: ExtensionFactory = async (pi) => {
try {
await factory(pi);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
registryParams.logger.warn(
`[plugins] embedded extension factory failed for ${record.id}: ${detail}`,
);
}
};
registry.embeddedExtensionFactories.push({
pluginId: record.id,
pluginName: record.name,
rawFactory: factory,
factory: safeFactory,
source: record.source,
rootDir: record.rootDir,
});
};
const registerTool = (
record: PluginRecord,
tool: AnyAgentTool | OpenClawPluginToolFactory,
@@ -1271,6 +1336,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
registerCompactionProvider(provider, { ownerPluginId: record.id });
},
registerEmbeddedExtensionFactory: (factory) => {
registerPiEmbeddedExtensionFactory(record, factory);
},
registerMemoryCapability: (capability) => {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({

View File

@@ -0,0 +1,18 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const semver = require("semver") as {
satisfies(version: string, range: string, options?: { includePrerelease?: boolean }): boolean;
valid(version: string): string | null;
validRange(range: string): string | null;
};
export const satisfies = (
version: string,
range: string,
options?: { includePrerelease?: boolean },
): boolean => semver.satisfies(version, range, options);
export const validSemver = (version: string): string | null => semver.valid(version);
export const validRange = (range: string): string | null => semver.validRange(range);

View File

@@ -128,6 +128,7 @@ export function createPluginLoadResult(
musicGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
embeddedExtensionFactories: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],

View File

@@ -1,7 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { ExtensionFactory, ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { Command } from "commander";
import type {
ApiKeyCredential,
@@ -2007,6 +2007,8 @@ export type OpenClawPluginApi = {
) => void;
/** Register an agent harness implementation. */
registerAgentHarness: (harness: AgentHarness) => void;
/** Register a Pi embedded extension factory for OpenClaw embedded runs. Only bundled plugins may use this seam, and `contracts.embeddedExtensionFactories` must include `"pi"`. */
registerEmbeddedExtensionFactory: (factory: ExtensionFactory) => void;
/** Register the active detached task runtime for this plugin (exclusive slot). */
registerDetachedTaskRuntime: (
runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime,

View File

@@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
musicGenerationProviders: [],
webFetchProviders: [],
webSearchProviders: [],
embeddedExtensionFactories: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],

View File

@@ -41,6 +41,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerContextEngine() {},
registerCompactionProvider() {},
registerAgentHarness() {},
registerEmbeddedExtensionFactory() {},
registerDetachedTaskRuntime() {},
registerMemoryCapability() {},
registerMemoryPromptSection() {},