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

* feat(plugins): register embedded extension factories

* feat(tokenjuice): bundle the native adapter

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

* fix(tokenjuice): refresh runtime sidecar baseline

* fix(plugins): harden bundled embedded extensions

* fix(plugins): install source bundled runtime deps

* fix(tokenjuice): sync lockfile importer

* fix(plugins): validate reused runtime dep versions

* fix(plugins): restore tokenjuice CI contract

* fix(plugins): remove tokenjuice dts bridge

* fix(tokenjuice): repair openclaw type shim

* fix(plugins): harden bundled runtime deps

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

* fix(plugins): isolate bundled runtime dep installs

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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