mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
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:
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
52
extensions/tokenjuice/index.test.ts
Normal file
52
extensions/tokenjuice/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
11
extensions/tokenjuice/index.ts
Normal file
11
extensions/tokenjuice/index.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
36
extensions/tokenjuice/manifest.test.ts
Normal file
36
extensions/tokenjuice/manifest.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
13
extensions/tokenjuice/openclaw.plugin.json
Normal file
13
extensions/tokenjuice/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
20
extensions/tokenjuice/package.json
Normal file
20
extensions/tokenjuice/package.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1
extensions/tokenjuice/runtime-api.ts
Normal file
1
extensions/tokenjuice/runtime-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createTokenjuiceOpenClawEmbeddedExtension } from "tokenjuice/openclaw";
|
||||
5
extensions/tokenjuice/tokenjuice-openclaw.ts
Normal file
5
extensions/tokenjuice/tokenjuice-openclaw.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
declare module "tokenjuice/openclaw" {
|
||||
export function createTokenjuiceOpenClawEmbeddedExtension(): ExtensionFactory;
|
||||
}
|
||||
16
extensions/tokenjuice/tsconfig.json
Normal file
16
extensions/tokenjuice/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
303
src/agents/pi-embedded-runner.extensions.test.ts
Normal file
303
src/agents/pi-embedded-runner.extensions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -22,6 +22,7 @@ function createStubPluginRegistry(): PluginRegistry {
|
||||
musicGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,6 +298,8 @@ describe("registerPluginCliCommands", () => {
|
||||
autoEnabledReasons: {
|
||||
demo: ["demo configured"],
|
||||
},
|
||||
activate: false,
|
||||
cache: false,
|
||||
}),
|
||||
);
|
||||
expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled();
|
||||
|
||||
8
src/plugins/embedded-extension-factory.ts
Normal file
8
src/plugins/embedded-extension-factory.ts
Normal 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) ?? [];
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
musicGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
18
src/plugins/semver.runtime.ts
Normal file
18
src/plugins/semver.runtime.ts
Normal 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);
|
||||
@@ -128,6 +128,7 @@ export function createPluginLoadResult(
|
||||
musicGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
musicGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerContextEngine() {},
|
||||
registerCompactionProvider() {},
|
||||
registerAgentHarness() {},
|
||||
registerEmbeddedExtensionFactory() {},
|
||||
registerDetachedTaskRuntime() {},
|
||||
registerMemoryCapability() {},
|
||||
registerMemoryPromptSection() {},
|
||||
|
||||
Reference in New Issue
Block a user