From 91ac48524610ab77cf2e104f91fe0a2bf5b13631 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 21 Apr 2026 23:58:37 -0700 Subject: [PATCH] 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 --- .github/labeler.yml | 4 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/building-plugins.md | 6 + docs/plugins/manifest.md | 24 +- docs/plugins/sdk-overview.md | 29 +- extensions/tokenjuice/index.test.ts | 52 +++ extensions/tokenjuice/index.ts | 11 + extensions/tokenjuice/manifest.test.ts | 36 ++ extensions/tokenjuice/openclaw.plugin.json | 13 + extensions/tokenjuice/package.json | 20 + extensions/tokenjuice/runtime-api.ts | 1 + extensions/tokenjuice/tokenjuice-openclaw.ts | 5 + extensions/tokenjuice/tsconfig.json | 16 + package.json | 2 +- pnpm-lock.yaml | 23 +- .../lib/bundled-runtime-sidecar-paths.json | 1 + .../pi-embedded-runner.extensions.test.ts | 303 +++++++++++++++ src/agents/pi-embedded-runner/extensions.ts | 2 + src/gateway/server-plugins.test.ts | 1 + src/gateway/test-helpers.plugin-registry.ts | 1 + src/plugins/api-builder.ts | 5 + src/plugins/bundled-runtime-deps.test.ts | 358 ++++++++++++++++-- src/plugins/bundled-runtime-deps.ts | 251 ++++++++---- src/plugins/captured-registration.ts | 9 +- src/plugins/cli-registry-loader.ts | 12 +- src/plugins/cli.test.ts | 2 + src/plugins/embedded-extension-factory.ts | 8 + src/plugins/loader.test.ts | 154 ++++++++ src/plugins/loader.ts | 11 +- src/plugins/manifest.ts | 3 + src/plugins/registry-empty.ts | 1 + src/plugins/registry-types.ts | 10 + src/plugins/registry.ts | 68 ++++ src/plugins/semver.runtime.ts | 18 + src/plugins/status.test-helpers.ts | 1 + src/plugins/types.ts | 4 +- src/test-utils/channel-plugins.ts | 1 + test/helpers/plugins/plugin-api.ts | 1 + 38 files changed, 1338 insertions(+), 133 deletions(-) create mode 100644 extensions/tokenjuice/index.test.ts create mode 100644 extensions/tokenjuice/index.ts create mode 100644 extensions/tokenjuice/manifest.test.ts create mode 100644 extensions/tokenjuice/openclaw.plugin.json create mode 100644 extensions/tokenjuice/package.json create mode 100644 extensions/tokenjuice/runtime-api.ts create mode 100644 extensions/tokenjuice/tokenjuice-openclaw.ts create mode 100644 extensions/tokenjuice/tsconfig.json create mode 100644 src/agents/pi-embedded-runner.extensions.test.ts create mode 100644 src/plugins/embedded-extension-factory.ts create mode 100644 src/plugins/semver.runtime.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index a833b4b4d3e..5076dab1581 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index df980c8b9e1..0b887d73e66 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 0ebfde9ca0a..3f2903c1221 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 4bdfaea915a..728b3e86b83 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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 diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index a435e54497c..54f036b4d5b 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -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: diff --git a/extensions/tokenjuice/index.test.ts b/extensions/tokenjuice/index.test.ts new file mode 100644 index 00000000000..79d7008bbcd --- /dev/null +++ b/extensions/tokenjuice/index.test.ts @@ -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); + }); +}); diff --git a/extensions/tokenjuice/index.ts b/extensions/tokenjuice/index.ts new file mode 100644 index 00000000000..cf7e0ae5cfa --- /dev/null +++ b/extensions/tokenjuice/index.ts @@ -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()); + }, +}); diff --git a/extensions/tokenjuice/manifest.test.ts b/extensions/tokenjuice/manifest.test.ts new file mode 100644 index 00000000000..2e3069bba18 --- /dev/null +++ b/extensions/tokenjuice/manifest.test.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; + +type TokenjuicePackageManifest = { + dependencies?: Record; + 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"]); + }); +}); diff --git a/extensions/tokenjuice/openclaw.plugin.json b/extensions/tokenjuice/openclaw.plugin.json new file mode 100644 index 00000000000..de95bb592a5 --- /dev/null +++ b/extensions/tokenjuice/openclaw.plugin.json @@ -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": {} + } +} diff --git a/extensions/tokenjuice/package.json b/extensions/tokenjuice/package.json new file mode 100644 index 00000000000..eac2883cde3 --- /dev/null +++ b/extensions/tokenjuice/package.json @@ -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 + } + } +} diff --git a/extensions/tokenjuice/runtime-api.ts b/extensions/tokenjuice/runtime-api.ts new file mode 100644 index 00000000000..8bea9c69c37 --- /dev/null +++ b/extensions/tokenjuice/runtime-api.ts @@ -0,0 +1 @@ +export { createTokenjuiceOpenClawEmbeddedExtension } from "tokenjuice/openclaw"; diff --git a/extensions/tokenjuice/tokenjuice-openclaw.ts b/extensions/tokenjuice/tokenjuice-openclaw.ts new file mode 100644 index 00000000000..977ecce0358 --- /dev/null +++ b/extensions/tokenjuice/tokenjuice-openclaw.ts @@ -0,0 +1,5 @@ +import type { ExtensionFactory } from "@mariozechner/pi-coding-agent"; + +declare module "tokenjuice/openclaw" { + export function createTokenjuiceOpenClawEmbeddedExtension(): ExtensionFactory; +} diff --git a/extensions/tokenjuice/tsconfig.json b/extensions/tokenjuice/tsconfig.json new file mode 100644 index 00000000000..b8a85a99ac3 --- /dev/null +++ b/extensions/tokenjuice/tsconfig.json @@ -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" + ] +} diff --git a/package.json b/package.json index d91f7ea773a..e06b322cf12 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5425cc39874..6b83a640242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json index c6a69ab43ad..5cb6a3d3b2d 100644 --- a/scripts/lib/bundled-runtime-sidecar-paths.json +++ b/scripts/lib/bundled-runtime-sidecar-paths.json @@ -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", diff --git a/src/agents/pi-embedded-runner.extensions.test.ts b/src/agents/pi-embedded-runner.extensions.test.ts new file mode 100644 index 00000000000..d0ed99175d3 --- /dev/null +++ b/src/agents/pi-embedded-runner.extensions.test.ts @@ -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; + 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(); + 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(); + }); +}); diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 9e627dfc2e6..f17533c9f6f 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -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; } diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 29d821bf23f..bc329db52f2 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -88,6 +88,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + embeddedExtensionFactories: [], textTransforms: [], agentHarnesses: [], gatewayHandlers: {}, diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index e495104a601..8acc27c8fba 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -22,6 +22,7 @@ function createStubPluginRegistry(): PluginRegistry { musicGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + embeddedExtensionFactories: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index de9e645574e..4c465b31912 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -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, diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 2012e506276..9de96295fb3 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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 }); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 4f29816df3b..097ad321d41 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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 { }; } -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([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>(); - const pluginIds = byVersion.get(version) ?? new Set(); + const byVersion = versionMap.get(dep.name) ?? new Map>(); + const pluginIds = byVersion.get(dep.version) ?? new Set(); 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 }; diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 7c53dcb099f..4bfe9e43813 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -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); }, diff --git a/src/plugins/cli-registry-loader.ts b/src/plugins/cli-registry-loader.ts index be61358298e..e0e146bfc4f 100644 --- a/src/plugins/cli-registry-loader.ts +++ b/src/plugins/cli-registry-loader.ts @@ -108,14 +108,16 @@ export async function loadPluginCliCommandRegistryWithContext(params: { primaryCommand?: string; loaderOptions?: PluginCliLoaderOptions; }): Promise { + 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, + }), ), }; } diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index c9a7d3372dd..3b372dea011 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -298,6 +298,8 @@ describe("registerPluginCliCommands", () => { autoEnabledReasons: { demo: ["demo configured"], }, + activate: false, + cache: false, }), ); expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled(); diff --git a/src/plugins/embedded-extension-factory.ts b/src/plugins/embedded-extension-factory.ts new file mode 100644 index 00000000000..6348b70e52a --- /dev/null +++ b/src/plugins/embedded-extension-factory.ts @@ -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) ?? []; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index df3a0e2e73b..275fe84f595 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 15efe858606..39ca4f47f3f 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index fe5931f7b03..3714aeaa46c 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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 } : {}), diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index d3fae089f94..3807690c279 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -20,6 +20,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { musicGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + embeddedExtensionFactories: [], memoryEmbeddingProviders: [], agentHarnesses: [], gatewayHandlers: {}, diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 578bf42cdc2..d7e51905132 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -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; export type PluginMemoryEmbeddingProviderRegistration = PluginOwnedProviderRegistration; +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; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 2e7cc4837ae..c3c8705747f 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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[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({ diff --git a/src/plugins/semver.runtime.ts b/src/plugins/semver.runtime.ts new file mode 100644 index 00000000000..9aafc4da58f --- /dev/null +++ b/src/plugins/semver.runtime.ts @@ -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); diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index d76edf74555..18ba487ace4 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -128,6 +128,7 @@ export function createPluginLoadResult( musicGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + embeddedExtensionFactories: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 179e2a7eeee..1e160485f68 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 047a704d044..68c81a0ba78 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl musicGenerationProviders: [], webFetchProviders: [], webSearchProviders: [], + embeddedExtensionFactories: [], memoryEmbeddingProviders: [], textTransforms: [], agentHarnesses: [], diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index 33e922e79ed..76efa199af4 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -41,6 +41,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerContextEngine() {}, registerCompactionProvider() {}, registerAgentHarness() {}, + registerEmbeddedExtensionFactory() {}, registerDetachedTaskRuntime() {}, registerMemoryCapability() {}, registerMemoryPromptSection() {},