mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 10:00:42 +00:00
feat(tokenjuice): bundle the native adapter (#69946)
* feat(plugins): register embedded extension factories * feat(tokenjuice): bundle the native adapter * fix(tokenjuice): gate the bundled embedded extension seam * fix(tokenjuice): refresh runtime sidecar baseline * fix(plugins): harden bundled embedded extensions * fix(plugins): install source bundled runtime deps * fix(tokenjuice): sync lockfile importer * fix(plugins): validate reused runtime dep versions * fix(plugins): restore tokenjuice CI contract * fix(plugins): remove tokenjuice dts bridge * fix(tokenjuice): repair openclaw type shim * fix(plugins): harden bundled runtime deps * fix(plugins): keep source checkout runtime deps local * fix(plugins): isolate bundled runtime dep installs * fix(cli): keep plugin startup registration non-activating * fix(cli): keep loader overrides out of plugin cli options
This commit is contained in:
@@ -48,6 +48,7 @@ export type BuildPluginApiParams = {
|
||||
| "registerContextEngine"
|
||||
| "registerCompactionProvider"
|
||||
| "registerAgentHarness"
|
||||
| "registerEmbeddedExtensionFactory"
|
||||
| "registerDetachedTaskRuntime"
|
||||
| "registerMemoryCapability"
|
||||
| "registerMemoryPromptSection"
|
||||
@@ -99,6 +100,8 @@ const noopRegisterCommand: OpenClawPluginApi["registerCommand"] = () => {};
|
||||
const noopRegisterContextEngine: OpenClawPluginApi["registerContextEngine"] = () => {};
|
||||
const noopRegisterCompactionProvider: OpenClawPluginApi["registerCompactionProvider"] = () => {};
|
||||
const noopRegisterAgentHarness: OpenClawPluginApi["registerAgentHarness"] = () => {};
|
||||
const noopRegisterEmbeddedExtensionFactory: OpenClawPluginApi["registerEmbeddedExtensionFactory"] =
|
||||
() => {};
|
||||
const noopRegisterDetachedTaskRuntime: OpenClawPluginApi["registerDetachedTaskRuntime"] = () => {};
|
||||
const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {};
|
||||
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
|
||||
@@ -166,6 +169,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
|
||||
registerCompactionProvider:
|
||||
handlers.registerCompactionProvider ?? noopRegisterCompactionProvider,
|
||||
registerAgentHarness: handlers.registerAgentHarness ?? noopRegisterAgentHarness,
|
||||
registerEmbeddedExtensionFactory:
|
||||
handlers.registerEmbeddedExtensionFactory ?? noopRegisterEmbeddedExtensionFactory,
|
||||
registerDetachedTaskRuntime:
|
||||
handlers.registerDetachedTaskRuntime ?? noopRegisterDetachedTaskRuntime,
|
||||
registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability,
|
||||
|
||||
@@ -111,20 +111,16 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to npm.cmd through shell on Windows", () => {
|
||||
const runner = resolveBundledRuntimeDepsNpmRunner({
|
||||
env: {},
|
||||
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
existsSync: () => false,
|
||||
npmArgs: ["install"],
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
expect(runner).toEqual({
|
||||
command: "npm.cmd",
|
||||
args: ["install"],
|
||||
shell: true,
|
||||
});
|
||||
it("refuses Windows shell fallback when no safe npm executable is available", () => {
|
||||
expect(() =>
|
||||
resolveBundledRuntimeDepsNpmRunner({
|
||||
env: {},
|
||||
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
existsSync: () => false,
|
||||
npmArgs: ["install"],
|
||||
platform: "win32",
|
||||
}),
|
||||
).toThrow("Unable to resolve a safe npm executable on Windows");
|
||||
});
|
||||
|
||||
it("prefixes PATH with the active Node directory on POSIX", () => {
|
||||
@@ -151,7 +147,9 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
|
||||
describe("installBundledRuntimeDeps", () => {
|
||||
it("uses the npm cmd shim on Windows", () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
vi.spyOn(fs, "existsSync").mockImplementation(
|
||||
(candidate) => candidate === "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
|
||||
);
|
||||
spawnSyncMock.mockReturnValue({
|
||||
pid: 123,
|
||||
output: [],
|
||||
@@ -164,15 +162,18 @@ describe("installBundledRuntimeDeps", () => {
|
||||
installBundledRuntimeDeps({
|
||||
installRoot: "C:\\openclaw",
|
||||
missingSpecs: ["acpx@0.5.3"],
|
||||
env: { npm_config_prefix: "C:\\prefix", PATH: "C:\\node" },
|
||||
env: {
|
||||
npm_config_prefix: "C:\\prefix",
|
||||
PATH: "C:\\node",
|
||||
npm_execpath: "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnSyncMock).toHaveBeenCalledWith(
|
||||
"npm.cmd",
|
||||
["install", "--ignore-scripts", "acpx@0.5.3"],
|
||||
expect.any(String),
|
||||
["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "install", "--ignore-scripts", "acpx@0.5.3"],
|
||||
expect.objectContaining({
|
||||
cwd: "C:\\openclaw",
|
||||
shell: true,
|
||||
env: expect.objectContaining({
|
||||
npm_config_legacy_peer_deps: "true",
|
||||
npm_config_package_lock: "false",
|
||||
@@ -191,6 +192,65 @@ describe("installBundledRuntimeDeps", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an isolated execution root and copies node_modules back when requested", () => {
|
||||
const installRoot = makeTempDir();
|
||||
const installExecutionRoot = makeTempDir();
|
||||
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
||||
const cwd = String(options?.cwd ?? "");
|
||||
fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(cwd, "node_modules", "tokenjuice", "package.json"),
|
||||
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
|
||||
);
|
||||
return {
|
||||
pid: 123,
|
||||
output: [],
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
signal: null,
|
||||
status: 0,
|
||||
};
|
||||
});
|
||||
|
||||
installBundledRuntimeDeps({
|
||||
installRoot,
|
||||
installExecutionRoot,
|
||||
missingSpecs: ["tokenjuice@0.6.1"],
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(installExecutionRoot, "package.json"), "utf8")),
|
||||
).toEqual({
|
||||
name: "openclaw-runtime-deps-install",
|
||||
private: true,
|
||||
});
|
||||
expect(
|
||||
JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(installRoot, "node_modules", "tokenjuice", "package.json"),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
name: "tokenjuice",
|
||||
version: "0.6.1",
|
||||
});
|
||||
expect(spawnSyncMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
cwd: installExecutionRoot,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid install specs before spawning npm", () => {
|
||||
expect(() =>
|
||||
createBundledRuntimeDepsInstallArgs(["tokenjuice@https://evil.example/t.tgz"]),
|
||||
).toThrow("Unsupported bundled runtime dependency spec for tokenjuice");
|
||||
});
|
||||
|
||||
it("includes spawn errors in install failures", () => {
|
||||
spawnSyncMock.mockReturnValue({
|
||||
pid: 0,
|
||||
@@ -457,6 +517,191 @@ describe("ensureBundledPluginRuntimeDeps", () => {
|
||||
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
|
||||
});
|
||||
|
||||
it("installs missing runtime deps for source-checkout bundled plugins", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const stageDir = makeTempDir();
|
||||
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
||||
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
dependencies: {
|
||||
tokenjuice: "0.6.1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const calls: BundledRuntimeDepsInstallParams[] = [];
|
||||
const result = ensureBundledPluginRuntimeDeps({
|
||||
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
||||
installDeps: (params) => {
|
||||
calls.push(params);
|
||||
},
|
||||
pluginId: "tokenjuice",
|
||||
pluginRoot,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
installedSpecs: ["tokenjuice@0.6.1"],
|
||||
retainSpecs: ["tokenjuice@0.6.1"],
|
||||
});
|
||||
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
|
||||
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
installRoot,
|
||||
missingSpecs: ["tokenjuice@0.6.1"],
|
||||
installSpecs: ["tokenjuice@0.6.1"],
|
||||
},
|
||||
]);
|
||||
expect(installRoot).toContain(stageDir);
|
||||
expect(installRoot).not.toBe(pluginRoot);
|
||||
});
|
||||
|
||||
it("keeps source-checkout bundled runtime deps in the plugin root by default", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
||||
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
dependencies: {
|
||||
tokenjuice: "0.6.1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const calls: BundledRuntimeDepsInstallParams[] = [];
|
||||
const result = ensureBundledPluginRuntimeDeps({
|
||||
env: {},
|
||||
installDeps: (params) => {
|
||||
calls.push(params);
|
||||
},
|
||||
pluginId: "tokenjuice",
|
||||
pluginRoot,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
installedSpecs: ["tokenjuice@0.6.1"],
|
||||
retainSpecs: ["tokenjuice@0.6.1"],
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
installRoot: pluginRoot,
|
||||
installExecutionRoot: expect.stringContaining(
|
||||
path.join(".local", "bundled-plugin-runtime-deps"),
|
||||
),
|
||||
missingSpecs: ["tokenjuice@0.6.1"],
|
||||
installSpecs: ["tokenjuice@0.6.1"],
|
||||
},
|
||||
]);
|
||||
expect(resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} })).toBe(pluginRoot);
|
||||
});
|
||||
|
||||
it("does not trust package-root runtime deps for source-checkout bundled plugins", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const stageDir = makeTempDir();
|
||||
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
||||
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
||||
fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
dependencies: {
|
||||
tokenjuice: "0.6.1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "node_modules", "tokenjuice", "package.json"),
|
||||
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
|
||||
);
|
||||
const calls: BundledRuntimeDepsInstallParams[] = [];
|
||||
|
||||
const result = ensureBundledPluginRuntimeDeps({
|
||||
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
||||
installDeps: (params) => {
|
||||
calls.push(params);
|
||||
},
|
||||
pluginId: "tokenjuice",
|
||||
pluginRoot,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
installedSpecs: ["tokenjuice@0.6.1"],
|
||||
retainSpecs: ["tokenjuice@0.6.1"],
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
installRoot: resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
|
||||
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
||||
}),
|
||||
missingSpecs: ["tokenjuice@0.6.1"],
|
||||
installSpecs: ["tokenjuice@0.6.1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reuse mismatched package-root runtime deps for source-checkout bundled plugins", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const stageDir = makeTempDir();
|
||||
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
||||
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
||||
fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
dependencies: {
|
||||
tokenjuice: "0.6.1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "node_modules", "tokenjuice", "package.json"),
|
||||
JSON.stringify({ name: "tokenjuice", version: "0.6.0" }),
|
||||
);
|
||||
|
||||
const calls: BundledRuntimeDepsInstallParams[] = [];
|
||||
const result = ensureBundledPluginRuntimeDeps({
|
||||
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
||||
installDeps: (params) => {
|
||||
calls.push(params);
|
||||
},
|
||||
pluginId: "tokenjuice",
|
||||
pluginRoot,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
installedSpecs: ["tokenjuice@0.6.1"],
|
||||
retainSpecs: ["tokenjuice@0.6.1"],
|
||||
});
|
||||
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
|
||||
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
installRoot,
|
||||
missingSpecs: ["tokenjuice@0.6.1"],
|
||||
installSpecs: ["tokenjuice@0.6.1"],
|
||||
},
|
||||
]);
|
||||
expect(installRoot).toContain(stageDir);
|
||||
expect(installRoot).not.toBe(pluginRoot);
|
||||
});
|
||||
|
||||
it("skips install when staged plugin-local runtime deps are present", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
|
||||
@@ -489,7 +734,7 @@ describe("ensureBundledPluginRuntimeDeps", () => {
|
||||
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
|
||||
});
|
||||
|
||||
it("skips install when runtime deps resolve from the package root", () => {
|
||||
it("does not trust runtime deps that only resolve from the package root", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const pluginRoot = path.join(packageRoot, "dist", "extensions", "openai");
|
||||
fs.mkdirSync(path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai"), {
|
||||
@@ -508,20 +753,31 @@ describe("ensureBundledPluginRuntimeDeps", () => {
|
||||
path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai", "package.json"),
|
||||
JSON.stringify({ name: "@mariozechner/pi-ai", version: "0.68.1" }),
|
||||
);
|
||||
const calls: BundledRuntimeDepsInstallParams[] = [];
|
||||
|
||||
const result = ensureBundledPluginRuntimeDeps({
|
||||
env: {},
|
||||
installDeps: () => {
|
||||
throw new Error("package-root runtime deps should not reinstall");
|
||||
installDeps: (params) => {
|
||||
calls.push(params);
|
||||
},
|
||||
pluginId: "openai",
|
||||
pluginRoot,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
|
||||
expect(result).toEqual({
|
||||
installedSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
||||
retainSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
installRoot: pluginRoot,
|
||||
missingSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
||||
installSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("installs only deps missing from plugin and package-root resolution", () => {
|
||||
it("installs deps that are only present in the package root", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex");
|
||||
fs.mkdirSync(path.join(packageRoot, "node_modules", "ws"), { recursive: true });
|
||||
@@ -551,13 +807,13 @@ describe("ensureBundledPluginRuntimeDeps", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
installedSpecs: ["zod@^4.3.6"],
|
||||
installedSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
||||
retainSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
installRoot: pluginRoot,
|
||||
missingSpecs: ["zod@^4.3.6"],
|
||||
missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
||||
installSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
||||
},
|
||||
]);
|
||||
@@ -607,6 +863,56 @@ describe("ensureBundledPluginRuntimeDeps", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported remote runtime dependency specs", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice");
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
dependencies: {
|
||||
tokenjuice: "https://evil.example/tokenjuice.tgz",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
ensureBundledPluginRuntimeDeps({
|
||||
env: {},
|
||||
installDeps: () => {
|
||||
throw new Error("should not attempt install");
|
||||
},
|
||||
pluginId: "tokenjuice",
|
||||
pluginRoot,
|
||||
}),
|
||||
).toThrow("Unsupported bundled runtime dependency spec for tokenjuice");
|
||||
});
|
||||
|
||||
it("rejects invalid runtime dependency names before resolving sentinels", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice");
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
dependencies: {
|
||||
"../escape": "0.6.1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
ensureBundledPluginRuntimeDeps({
|
||||
env: {},
|
||||
installDeps: () => {
|
||||
throw new Error("should not attempt install");
|
||||
},
|
||||
pluginId: "tokenjuice",
|
||||
pluginRoot,
|
||||
}),
|
||||
).toThrow("Invalid bundled runtime dependency name");
|
||||
});
|
||||
|
||||
it("rehydrates source-checkout dist deps from cache after rebuilds", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveHomeRelativePath } from "../infra/home-dir.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { normalizePluginsConfig } from "./config-state.js";
|
||||
import { satisfies, validRange, validSemver } from "./semver.runtime.js";
|
||||
|
||||
export type RuntimeDepEntry = {
|
||||
name: string;
|
||||
@@ -24,6 +25,7 @@ export type RuntimeDepConflict = {
|
||||
|
||||
export type BundledRuntimeDepsInstallParams = {
|
||||
installRoot: string;
|
||||
installExecutionRoot?: string;
|
||||
missingSpecs: string[];
|
||||
installSpecs?: string[];
|
||||
};
|
||||
@@ -45,11 +47,108 @@ export type BundledRuntimeDepsNpmRunner = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
shell?: boolean;
|
||||
};
|
||||
|
||||
const BUNDLED_RUNTIME_DEP_SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
|
||||
function normalizeInstallableRuntimeDepName(rawName: string): string | null {
|
||||
const depName = rawName.trim();
|
||||
if (depName === "") {
|
||||
return null;
|
||||
}
|
||||
const segments = depName.split("/");
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
return null;
|
||||
}
|
||||
if (segments.length === 1) {
|
||||
return BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(segments[0] ?? "") ? depName : null;
|
||||
}
|
||||
if (segments.length !== 2 || !segments[0]?.startsWith("@")) {
|
||||
return null;
|
||||
}
|
||||
const scope = segments[0].slice(1);
|
||||
const packageName = segments[1];
|
||||
return BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(scope) &&
|
||||
BUNDLED_RUNTIME_DEP_SEGMENT_RE.test(packageName ?? "")
|
||||
? depName
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeInstallableRuntimeDepVersion(rawVersion: unknown): string | null {
|
||||
if (typeof rawVersion !== "string") {
|
||||
return null;
|
||||
}
|
||||
const version = rawVersion.trim();
|
||||
if (version === "" || version.toLowerCase().startsWith("workspace:")) {
|
||||
return null;
|
||||
}
|
||||
if (validSemver(version)) {
|
||||
return version;
|
||||
}
|
||||
const rangePrefix = version[0];
|
||||
if ((rangePrefix === "^" || rangePrefix === "~") && validSemver(version.slice(1))) {
|
||||
return version;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseInstallableRuntimeDep(
|
||||
name: string,
|
||||
rawVersion: unknown,
|
||||
): { name: string; version: string } | null {
|
||||
if (typeof rawVersion !== "string") {
|
||||
return null;
|
||||
}
|
||||
const version = rawVersion.trim();
|
||||
if (version === "" || version.toLowerCase().startsWith("workspace:")) {
|
||||
return null;
|
||||
}
|
||||
const normalizedName = normalizeInstallableRuntimeDepName(name);
|
||||
if (!normalizedName) {
|
||||
throw new Error(`Invalid bundled runtime dependency name: ${name}`);
|
||||
}
|
||||
const normalizedVersion = normalizeInstallableRuntimeDepVersion(version);
|
||||
if (!normalizedVersion) {
|
||||
throw new Error(
|
||||
`Unsupported bundled runtime dependency spec for ${normalizedName}: ${version}`,
|
||||
);
|
||||
}
|
||||
return { name: normalizedName, version: normalizedVersion };
|
||||
}
|
||||
|
||||
function parseInstallableRuntimeDepSpec(spec: string): { name: string; version: string } {
|
||||
const atIndex = spec.lastIndexOf("@");
|
||||
if (atIndex <= 0 || atIndex === spec.length - 1) {
|
||||
throw new Error(`Invalid bundled runtime dependency install spec: ${spec}`);
|
||||
}
|
||||
const parsed = parseInstallableRuntimeDep(spec.slice(0, atIndex), spec.slice(atIndex + 1));
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid bundled runtime dependency install spec: ${spec}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function dependencySentinelPath(depName: string): string {
|
||||
return path.join("node_modules", ...depName.split("/"), "package.json");
|
||||
const normalizedDepName = normalizeInstallableRuntimeDepName(depName);
|
||||
if (!normalizedDepName) {
|
||||
throw new Error(`Invalid bundled runtime dependency name: ${depName}`);
|
||||
}
|
||||
return path.join("node_modules", ...normalizedDepName.split("/"), "package.json");
|
||||
}
|
||||
|
||||
function resolveDependencySentinelAbsolutePath(rootDir: string, depName: string): string {
|
||||
const nodeModulesDir = path.resolve(rootDir, "node_modules");
|
||||
const sentinelPath = path.resolve(rootDir, dependencySentinelPath(depName));
|
||||
if (sentinelPath !== nodeModulesDir && !sentinelPath.startsWith(`${nodeModulesDir}${path.sep}`)) {
|
||||
throw new Error(`Blocked runtime dependency path escape for ${depName}`);
|
||||
}
|
||||
return sentinelPath;
|
||||
}
|
||||
|
||||
function readInstalledDependencyVersion(rootDir: string, depName: string): string | null {
|
||||
const parsed = readJsonObject(resolveDependencySentinelAbsolutePath(rootDir, depName));
|
||||
const version = parsed && typeof parsed.version === "string" ? parsed.version.trim() : "";
|
||||
return version || null;
|
||||
}
|
||||
|
||||
function readJsonObject(filePath: string): JsonObject | null {
|
||||
@@ -71,17 +170,6 @@ function collectRuntimeDeps(packageJson: JsonObject): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInstallableRuntimeDepVersion(rawVersion: unknown): string | null {
|
||||
if (typeof rawVersion !== "string") {
|
||||
return null;
|
||||
}
|
||||
const version = rawVersion.trim();
|
||||
if (version === "" || version.toLowerCase().startsWith("workspace:")) {
|
||||
return null;
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function isSourceCheckoutRoot(packageRoot: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(packageRoot, ".git")) &&
|
||||
@@ -90,12 +178,13 @@ function isSourceCheckoutRoot(packageRoot: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isSourceCheckoutBundledPluginRoot(pluginRoot: string): boolean {
|
||||
const extensionsDir = path.dirname(pluginRoot);
|
||||
function resolveSourceCheckoutBundledPluginPackageRoot(pluginRoot: string): string | null {
|
||||
const extensionsDir = path.dirname(path.resolve(pluginRoot));
|
||||
if (path.basename(extensionsDir) !== "extensions") {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return isSourceCheckoutRoot(path.dirname(extensionsDir));
|
||||
const packageRoot = path.dirname(extensionsDir);
|
||||
return isSourceCheckoutRoot(packageRoot) ? packageRoot : null;
|
||||
}
|
||||
|
||||
function resolveSourceCheckoutDistPackageRoot(pluginRoot: string): string | null {
|
||||
@@ -111,24 +200,11 @@ function resolveSourceCheckoutDistPackageRoot(pluginRoot: string): string | null
|
||||
return isSourceCheckoutRoot(packageRoot) ? packageRoot : null;
|
||||
}
|
||||
|
||||
function resolveBundledRuntimeDependencySearchRoots(params: {
|
||||
installRoot: string;
|
||||
pluginRoot: string;
|
||||
}): string[] {
|
||||
const roots = new Set<string>([params.installRoot]);
|
||||
const pluginRoot = path.resolve(params.pluginRoot);
|
||||
const extensionsDir = path.dirname(pluginRoot);
|
||||
const buildDir = path.dirname(extensionsDir);
|
||||
if (
|
||||
path.basename(extensionsDir) !== "extensions" ||
|
||||
(path.basename(buildDir) !== "dist" && path.basename(buildDir) !== "dist-runtime")
|
||||
) {
|
||||
return [...roots];
|
||||
}
|
||||
roots.add(extensionsDir);
|
||||
roots.add(buildDir);
|
||||
roots.add(path.dirname(buildDir));
|
||||
return [...roots];
|
||||
function resolveSourceCheckoutPackageRoot(pluginRoot: string): string | null {
|
||||
return (
|
||||
resolveSourceCheckoutBundledPluginPackageRoot(pluginRoot) ??
|
||||
resolveSourceCheckoutDistPackageRoot(pluginRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBundledPluginPackageRoot(pluginRoot: string): string | null {
|
||||
@@ -178,6 +254,7 @@ function readRetainedRuntimeDepsManifest(installRoot: string): string[] {
|
||||
}
|
||||
|
||||
function writeRetainedRuntimeDepsManifest(installRoot: string, specs: readonly string[]): void {
|
||||
fs.mkdirSync(installRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(installRoot, RETAINED_RUNTIME_DEPS_MANIFEST),
|
||||
`${JSON.stringify({ specs: [...specs].toSorted((left, right) => left.localeCompare(right)) }, null, 2)}\n`,
|
||||
@@ -230,7 +307,7 @@ function resolveSourceCheckoutRuntimeDepsCacheDir(params: {
|
||||
pluginRoot: string;
|
||||
installSpecs: readonly string[];
|
||||
}): string | null {
|
||||
const packageRoot = resolveSourceCheckoutDistPackageRoot(params.pluginRoot);
|
||||
const packageRoot = resolveSourceCheckoutPackageRoot(params.pluginRoot);
|
||||
if (!packageRoot) {
|
||||
return null;
|
||||
}
|
||||
@@ -246,10 +323,28 @@ function hasAllDependencySentinels(rootDir: string, deps: readonly { name: strin
|
||||
return deps.every((dep) => fs.existsSync(path.join(rootDir, dependencySentinelPath(dep.name))));
|
||||
}
|
||||
|
||||
function hasDependencySentinel(searchRoots: readonly string[], dep: { name: string }): boolean {
|
||||
return searchRoots.some((rootDir) =>
|
||||
fs.existsSync(path.join(rootDir, dependencySentinelPath(dep.name))),
|
||||
);
|
||||
function isInstalledDependencyVersionSatisfied(installedVersion: string, spec: string): boolean {
|
||||
const normalizedInstalledVersion = validSemver(installedVersion);
|
||||
const normalizedRange = validRange(spec);
|
||||
if (normalizedInstalledVersion && normalizedRange) {
|
||||
return satisfies(normalizedInstalledVersion, normalizedRange, {
|
||||
includePrerelease: true,
|
||||
});
|
||||
}
|
||||
return installedVersion === spec;
|
||||
}
|
||||
|
||||
function hasDependencySentinel(
|
||||
searchRoots: readonly string[],
|
||||
dep: { name: string; version: string },
|
||||
): boolean {
|
||||
return searchRoots.some((rootDir) => {
|
||||
const installedVersion = readInstalledDependencyVersion(rootDir, dep.name);
|
||||
return (
|
||||
typeof installedVersion === "string" &&
|
||||
isInstalledDependencyVersionSatisfied(installedVersion, dep.version)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceNodeModulesDir(targetDir: string, sourceDir: string): void {
|
||||
@@ -328,6 +423,9 @@ export function createBundledRuntimeDepsInstallEnv(env: NodeJS.ProcessEnv): Node
|
||||
}
|
||||
|
||||
export function createBundledRuntimeDepsInstallArgs(missingSpecs: readonly string[]): string[] {
|
||||
missingSpecs.forEach((spec) => {
|
||||
parseInstallableRuntimeDepSpec(spec);
|
||||
});
|
||||
return ["install", "--ignore-scripts", ...missingSpecs];
|
||||
}
|
||||
|
||||
@@ -384,11 +482,7 @@ export function resolveBundledRuntimeDepsNpmRunner(params: {
|
||||
args: params.npmArgs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: "npm.cmd",
|
||||
args: params.npmArgs,
|
||||
shell: true,
|
||||
};
|
||||
throw new Error("Unable to resolve a safe npm executable on Windows");
|
||||
}
|
||||
|
||||
const pathKey = resolvePathEnvKey(env, platform);
|
||||
@@ -513,15 +607,15 @@ function collectBundledPluginRuntimeDeps(params: {
|
||||
continue;
|
||||
}
|
||||
for (const [name, rawVersion] of Object.entries(collectRuntimeDeps(packageJson))) {
|
||||
const version = normalizeInstallableRuntimeDepVersion(rawVersion);
|
||||
if (!version) {
|
||||
const dep = parseInstallableRuntimeDep(name, rawVersion);
|
||||
if (!dep) {
|
||||
continue;
|
||||
}
|
||||
const byVersion = versionMap.get(name) ?? new Map<string, Set<string>>();
|
||||
const pluginIds = byVersion.get(version) ?? new Set<string>();
|
||||
const byVersion = versionMap.get(dep.name) ?? new Map<string, Set<string>>();
|
||||
const pluginIds = byVersion.get(dep.version) ?? new Set<string>();
|
||||
pluginIds.add(pluginId);
|
||||
byVersion.set(version, pluginIds);
|
||||
versionMap.set(name, byVersion);
|
||||
byVersion.set(dep.version, pluginIds);
|
||||
versionMap.set(dep.name, byVersion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,7 +693,7 @@ export function scanBundledPluginRuntimeDeps(params: {
|
||||
const packageInstallRoot = resolveBundledRuntimeDependencyPackageInstallRoot(params.packageRoot, {
|
||||
env: params.env,
|
||||
});
|
||||
const packageSearchRoots = [packageInstallRoot, params.packageRoot, extensionsDir];
|
||||
const packageSearchRoots = [packageInstallRoot];
|
||||
const missing = deps.filter(
|
||||
(dep) =>
|
||||
!hasDependencySentinel(packageSearchRoots, dep) &&
|
||||
@@ -608,7 +702,7 @@ export function scanBundledPluginRuntimeDeps(params: {
|
||||
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
|
||||
env: params.env,
|
||||
});
|
||||
return !hasDependencySentinel([installRoot, pluginRoot], dep);
|
||||
return !hasDependencySentinel([installRoot], dep);
|
||||
}),
|
||||
);
|
||||
return { deps, missing, conflicts };
|
||||
@@ -680,9 +774,13 @@ export function createBundledRuntimeDependencyAliasMap(params: {
|
||||
for (const name of Object.keys(collectRuntimeDeps(packageJson)).toSorted((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
const target = path.join(params.installRoot, "node_modules", ...name.split("/"));
|
||||
const normalizedName = normalizeInstallableRuntimeDepName(name);
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
const target = path.join(params.installRoot, "node_modules", ...normalizedName.split("/"));
|
||||
if (fs.existsSync(path.join(target, "package.json"))) {
|
||||
aliases[name] = target;
|
||||
aliases[normalizedName] = target;
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
@@ -690,21 +788,30 @@ export function createBundledRuntimeDependencyAliasMap(params: {
|
||||
|
||||
export function installBundledRuntimeDeps(params: {
|
||||
installRoot: string;
|
||||
installExecutionRoot?: string;
|
||||
missingSpecs: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
const installExecutionRoot = params.installExecutionRoot ?? params.installRoot;
|
||||
fs.mkdirSync(params.installRoot, { recursive: true });
|
||||
fs.mkdirSync(installExecutionRoot, { recursive: true });
|
||||
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
|
||||
fs.writeFileSync(
|
||||
path.join(installExecutionRoot, "package.json"),
|
||||
`${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
const installEnv = createBundledRuntimeDepsInstallEnv(params.env);
|
||||
const npmRunner = resolveBundledRuntimeDepsNpmRunner({
|
||||
env: installEnv,
|
||||
npmArgs: createBundledRuntimeDepsInstallArgs(params.missingSpecs),
|
||||
});
|
||||
const result = spawnSync(npmRunner.command, npmRunner.args, {
|
||||
cwd: params.installRoot,
|
||||
cwd: installExecutionRoot,
|
||||
encoding: "utf8",
|
||||
env: npmRunner.env ?? installEnv,
|
||||
stdio: "pipe",
|
||||
shell: npmRunner.shell ?? false,
|
||||
});
|
||||
if (result.status !== 0 || result.error) {
|
||||
const output = [result.error?.message, result.stderr, result.stdout]
|
||||
@@ -713,6 +820,13 @@ export function installBundledRuntimeDeps(params: {
|
||||
.trim();
|
||||
throw new Error(output || "npm install failed");
|
||||
}
|
||||
if (path.resolve(installExecutionRoot) !== path.resolve(params.installRoot)) {
|
||||
const stagedNodeModulesDir = path.join(installExecutionRoot, "node_modules");
|
||||
if (!fs.existsSync(stagedNodeModulesDir)) {
|
||||
throw new Error("npm install did not produce node_modules");
|
||||
}
|
||||
replaceNodeModulesDir(path.join(params.installRoot, "node_modules"), stagedNodeModulesDir);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureBundledPluginRuntimeDeps(params: {
|
||||
@@ -723,9 +837,6 @@ export function ensureBundledPluginRuntimeDeps(params: {
|
||||
retainSpecs?: readonly string[];
|
||||
installDeps?: (params: BundledRuntimeDepsInstallParams) => void;
|
||||
}): BundledRuntimeDepsEnsureResult {
|
||||
if (isSourceCheckoutBundledPluginRoot(params.pluginRoot)) {
|
||||
return { installedSpecs: [], retainSpecs: [] };
|
||||
}
|
||||
if (
|
||||
params.config &&
|
||||
!isBundledPluginConfiguredForRuntimeDeps({
|
||||
@@ -741,10 +852,7 @@ export function ensureBundledPluginRuntimeDeps(params: {
|
||||
return { installedSpecs: [], retainSpecs: [] };
|
||||
}
|
||||
const deps = Object.entries(collectRuntimeDeps(packageJson))
|
||||
.map(([name, rawVersion]) => {
|
||||
const version = normalizeInstallableRuntimeDepVersion(rawVersion);
|
||||
return version ? { name, version } : null;
|
||||
})
|
||||
.map(([name, rawVersion]) => parseInstallableRuntimeDep(name, rawVersion))
|
||||
.filter((entry): entry is { name: string; version: string } => Boolean(entry));
|
||||
if (deps.length === 0) {
|
||||
return { installedSpecs: [], retainSpecs: [] };
|
||||
@@ -753,15 +861,11 @@ export function ensureBundledPluginRuntimeDeps(params: {
|
||||
const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot, {
|
||||
env: params.env,
|
||||
});
|
||||
const dependencySearchRoots = resolveBundledRuntimeDependencySearchRoots({
|
||||
installRoot,
|
||||
pluginRoot: params.pluginRoot,
|
||||
});
|
||||
const dependencySpecs = deps
|
||||
.map((dep) => `${dep.name}@${dep.version}`)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
const missingSpecs = deps
|
||||
.filter((dep) => !hasDependencySentinel(dependencySearchRoots, dep))
|
||||
.filter((dep) => !hasDependencySentinel([installRoot], dep))
|
||||
.map((dep) => `${dep.name}@${dep.version}`)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
if (missingSpecs.length === 0) {
|
||||
@@ -776,6 +880,12 @@ export function ensureBundledPluginRuntimeDeps(params: {
|
||||
pluginRoot: params.pluginRoot,
|
||||
installSpecs,
|
||||
});
|
||||
const installExecutionRoot =
|
||||
cacheDir &&
|
||||
path.resolve(installRoot) === path.resolve(params.pluginRoot) &&
|
||||
resolveSourceCheckoutBundledPluginPackageRoot(params.pluginRoot)
|
||||
? cacheDir
|
||||
: undefined;
|
||||
if (
|
||||
restoreSourceCheckoutRuntimeDepsFromCache({
|
||||
cacheDir,
|
||||
@@ -791,10 +901,11 @@ export function ensureBundledPluginRuntimeDeps(params: {
|
||||
((installParams) =>
|
||||
installBundledRuntimeDeps({
|
||||
installRoot: installParams.installRoot,
|
||||
installExecutionRoot: installParams.installExecutionRoot,
|
||||
missingSpecs: installParams.installSpecs ?? installParams.missingSpecs,
|
||||
env: params.env,
|
||||
}));
|
||||
install({ installRoot, missingSpecs, installSpecs });
|
||||
install({ installRoot, installExecutionRoot, missingSpecs, installSpecs });
|
||||
writeRetainedRuntimeDepsManifest(installRoot, installSpecs);
|
||||
storeSourceCheckoutRuntimeDepsCache({ cacheDir, installRoot });
|
||||
return { installedSpecs: missingSpecs, retainSpecs: installSpecs };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { buildPluginApi } from "./api-builder.js";
|
||||
import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js";
|
||||
@@ -6,10 +7,10 @@ import type {
|
||||
AnyAgentTool,
|
||||
AgentHarness,
|
||||
CliBackendPlugin,
|
||||
OpenClawPluginApi,
|
||||
ImageGenerationProviderPlugin,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
MusicGenerationProviderPlugin,
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginCliCommandDescriptor,
|
||||
OpenClawPluginCliRegistrar,
|
||||
PluginTextTransformRegistration,
|
||||
@@ -35,6 +36,7 @@ export type CapturedPluginRegistration = {
|
||||
cliRegistrars: CapturedPluginCliRegistration[];
|
||||
cliBackends: CliBackendPlugin[];
|
||||
textTransforms: PluginTextTransformRegistration[];
|
||||
embeddedExtensionFactories: ExtensionFactory[];
|
||||
speechProviders: SpeechProviderPlugin[];
|
||||
realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[];
|
||||
realtimeVoiceProviders: RealtimeVoiceProviderPlugin[];
|
||||
@@ -57,6 +59,7 @@ export function createCapturedPluginRegistration(params?: {
|
||||
const cliRegistrars: CapturedPluginCliRegistration[] = [];
|
||||
const cliBackends: CliBackendPlugin[] = [];
|
||||
const textTransforms: PluginTextTransformRegistration[] = [];
|
||||
const embeddedExtensionFactories: ExtensionFactory[] = [];
|
||||
const speechProviders: SpeechProviderPlugin[] = [];
|
||||
const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = [];
|
||||
const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = [];
|
||||
@@ -81,6 +84,7 @@ export function createCapturedPluginRegistration(params?: {
|
||||
cliRegistrars,
|
||||
cliBackends,
|
||||
textTransforms,
|
||||
embeddedExtensionFactories,
|
||||
speechProviders,
|
||||
realtimeTranscriptionProviders,
|
||||
realtimeVoiceProviders,
|
||||
@@ -131,6 +135,9 @@ export function createCapturedPluginRegistration(params?: {
|
||||
registerAgentHarness(harness: AgentHarness) {
|
||||
agentHarnesses.push(harness);
|
||||
},
|
||||
registerEmbeddedExtensionFactory(factory: ExtensionFactory) {
|
||||
embeddedExtensionFactories.push(factory);
|
||||
},
|
||||
registerCliBackend(backend: CliBackendPlugin) {
|
||||
cliBackends.push(backend);
|
||||
},
|
||||
|
||||
@@ -108,14 +108,16 @@ export async function loadPluginCliCommandRegistryWithContext(params: {
|
||||
primaryCommand?: string;
|
||||
loaderOptions?: PluginCliLoaderOptions;
|
||||
}): Promise<PluginCliRegistryLoadResult> {
|
||||
const onlyPluginIds = resolvePrimaryCommandPluginIds(params.context, params.primaryCommand);
|
||||
return {
|
||||
...params.context,
|
||||
registry: loadOpenClawPlugins(
|
||||
buildPluginCliLoaderParams(
|
||||
params.context,
|
||||
{ primaryCommand: params.primaryCommand },
|
||||
params.loaderOptions,
|
||||
),
|
||||
buildPluginRuntimeLoadOptions(params.context, {
|
||||
...params.loaderOptions,
|
||||
...(onlyPluginIds.length > 0 ? { onlyPluginIds } : {}),
|
||||
activate: false,
|
||||
cache: false,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,6 +298,8 @@ describe("registerPluginCliCommands", () => {
|
||||
autoEnabledReasons: {
|
||||
demo: ["demo configured"],
|
||||
},
|
||||
activate: false,
|
||||
cache: false,
|
||||
}),
|
||||
);
|
||||
expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled();
|
||||
|
||||
8
src/plugins/embedded-extension-factory.ts
Normal file
8
src/plugins/embedded-extension-factory.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
||||
import { getActivePluginRegistry } from "./runtime.js";
|
||||
|
||||
export const PI_EMBEDDED_EXTENSION_RUNTIME_ID = "pi";
|
||||
|
||||
export function listEmbeddedExtensionFactories(): ExtensionFactory[] {
|
||||
return getActivePluginRegistry()?.embeddedExtensionFactories?.map((entry) => entry.factory) ?? [];
|
||||
}
|
||||
@@ -933,6 +933,78 @@ module.exports = {
|
||||
expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded");
|
||||
});
|
||||
|
||||
it("keeps bundled runtime dep install logs off non-activating loads", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
const plugin = writePlugin({
|
||||
id: "discord",
|
||||
dir: path.join(bundledDir, "discord"),
|
||||
filename: "index.cjs",
|
||||
body: `module.exports = { id: "discord", register() {} };`,
|
||||
});
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
fs.writeFileSync(
|
||||
path.join(plugin.dir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/discord",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"discord-runtime": "1.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(plugin.dir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "discord",
|
||||
enabledByDefault: true,
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
logger,
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
bundledRuntimeDepsInstaller: ({ installRoot }) => {
|
||||
fs.mkdirSync(path.join(installRoot, "node_modules", "discord-runtime"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(installRoot, "node_modules", "discord-runtime", "package.json"),
|
||||
JSON.stringify({ name: "discord-runtime", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded");
|
||||
expect(logger.info).not.toHaveBeenCalledWith(
|
||||
"[plugins] discord installed bundled runtime deps: discord-runtime@1.0.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not repair disabled bundled plugin runtime deps", () => {
|
||||
const bundledDir = makeTempDir();
|
||||
const plugin = writePlugin({
|
||||
@@ -1190,6 +1262,88 @@ module.exports = {
|
||||
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
|
||||
});
|
||||
|
||||
it("loads source-checkout bundled runtime deps without mirroring the repo tree", () => {
|
||||
const packageRoot = makeTempDir();
|
||||
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
||||
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
||||
const bundledDir = path.join(packageRoot, "extensions");
|
||||
const plugin = writePlugin({
|
||||
id: "tokenjuice",
|
||||
dir: path.join(bundledDir, "tokenjuice"),
|
||||
filename: "index.cjs",
|
||||
body: `
|
||||
const runtimeDep = require("external-runtime");
|
||||
module.exports = {
|
||||
id: "tokenjuice",
|
||||
register(api) {
|
||||
api.registerCommand({ name: "external-runtime", handler: () => runtimeDep.marker });
|
||||
}
|
||||
};
|
||||
`,
|
||||
});
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
||||
fs.writeFileSync(
|
||||
path.join(plugin.dir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/tokenjuice",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"external-runtime": "1.0.0",
|
||||
},
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(plugin.dir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "tokenjuice",
|
||||
enabledByDefault: true,
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const installRoots: string[] = [];
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
bundledRuntimeDepsInstaller: ({ installRoot }) => {
|
||||
installRoots.push(fs.realpathSync(installRoot));
|
||||
const depRoot = path.join(installRoot, "node_modules", "external-runtime");
|
||||
fs.mkdirSync(depRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(depRoot, "package.json"),
|
||||
JSON.stringify({ name: "external-runtime", version: "1.0.0", main: "index.cjs" }),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(depRoot, "index.cjs"),
|
||||
"module.exports = { marker: 'source-checkout-ok' };\n",
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(installRoots).toEqual([fs.realpathSync(plugin.dir)]);
|
||||
expect(registry.plugins.find((entry) => entry.id === "tokenjuice")?.status).toBe("loaded");
|
||||
expect(resolveLoadedPluginSource(registry, "tokenjuice")).toBe(
|
||||
fs.realpathSync(path.join(plugin.dir, "index.cjs")),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers standalone text transforms", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
|
||||
@@ -276,6 +276,7 @@ type PluginRegistrySnapshot = {
|
||||
musicGenerationProviders: PluginRegistry["musicGenerationProviders"];
|
||||
webFetchProviders: PluginRegistry["webFetchProviders"];
|
||||
webSearchProviders: PluginRegistry["webSearchProviders"];
|
||||
embeddedExtensionFactories: PluginRegistry["embeddedExtensionFactories"];
|
||||
memoryEmbeddingProviders: PluginRegistry["memoryEmbeddingProviders"];
|
||||
agentHarnesses: PluginRegistry["agentHarnesses"];
|
||||
httpRoutes: PluginRegistry["httpRoutes"];
|
||||
@@ -312,6 +313,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho
|
||||
musicGenerationProviders: [...registry.musicGenerationProviders],
|
||||
webFetchProviders: [...registry.webFetchProviders],
|
||||
webSearchProviders: [...registry.webSearchProviders],
|
||||
embeddedExtensionFactories: [...registry.embeddedExtensionFactories],
|
||||
memoryEmbeddingProviders: [...registry.memoryEmbeddingProviders],
|
||||
agentHarnesses: [...registry.agentHarnesses],
|
||||
httpRoutes: [...registry.httpRoutes],
|
||||
@@ -347,6 +349,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr
|
||||
registry.musicGenerationProviders = snapshot.arrays.musicGenerationProviders;
|
||||
registry.webFetchProviders = snapshot.arrays.webFetchProviders;
|
||||
registry.webSearchProviders = snapshot.arrays.webSearchProviders;
|
||||
registry.embeddedExtensionFactories = snapshot.arrays.embeddedExtensionFactories;
|
||||
registry.memoryEmbeddingProviders = snapshot.arrays.memoryEmbeddingProviders;
|
||||
registry.agentHarnesses = snapshot.arrays.agentHarnesses;
|
||||
registry.httpRoutes = snapshot.arrays.httpRoutes;
|
||||
@@ -1912,9 +1915,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
(left, right) => left.localeCompare(right),
|
||||
),
|
||||
);
|
||||
logger.info(
|
||||
`[plugins] ${record.id} installed bundled runtime deps: ${depsInstallResult.installedSpecs.join(", ")}`,
|
||||
);
|
||||
if (shouldActivate) {
|
||||
logger.info(
|
||||
`[plugins] ${record.id} installed bundled runtime deps: ${depsInstallResult.installedSpecs.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (path.resolve(installRoot) !== path.resolve(pluginRoot)) {
|
||||
registerBundledRuntimeDependencyNodePath(installRoot);
|
||||
|
||||
@@ -230,6 +230,7 @@ export type PluginManifest = {
|
||||
};
|
||||
|
||||
export type PluginManifestContracts = {
|
||||
embeddedExtensionFactories?: string[];
|
||||
memoryEmbeddingProviders?: string[];
|
||||
speechProviders?: string[];
|
||||
realtimeTranscriptionProviders?: string[];
|
||||
@@ -416,6 +417,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const embeddedExtensionFactories = normalizeTrimmedStringList(value.embeddedExtensionFactories);
|
||||
const memoryEmbeddingProviders = normalizeTrimmedStringList(value.memoryEmbeddingProviders);
|
||||
const speechProviders = normalizeTrimmedStringList(value.speechProviders);
|
||||
const realtimeTranscriptionProviders = normalizeTrimmedStringList(
|
||||
@@ -430,6 +432,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
|
||||
const webSearchProviders = normalizeTrimmedStringList(value.webSearchProviders);
|
||||
const tools = normalizeTrimmedStringList(value.tools);
|
||||
const contracts = {
|
||||
...(embeddedExtensionFactories.length > 0 ? { embeddedExtensionFactories } : {}),
|
||||
...(memoryEmbeddingProviders.length > 0 ? { memoryEmbeddingProviders } : {}),
|
||||
...(speechProviders.length > 0 ? { speechProviders } : {}),
|
||||
...(realtimeTranscriptionProviders.length > 0 ? { realtimeTranscriptionProviders } : {}),
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
musicGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentHarness } from "../agents/harness/types.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { OperatorScope } from "../gateway/operator-scopes.js";
|
||||
@@ -144,6 +145,14 @@ export type PluginWebSearchProviderRegistration =
|
||||
PluginOwnedProviderRegistration<WebSearchProviderPlugin>;
|
||||
export type PluginMemoryEmbeddingProviderRegistration =
|
||||
PluginOwnedProviderRegistration<MemoryEmbeddingProviderAdapter>;
|
||||
export type PluginEmbeddedExtensionFactoryRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
rawFactory: ExtensionFactory;
|
||||
factory: ExtensionFactory;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
export type PluginAgentHarnessRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
@@ -281,6 +290,7 @@ export type PluginRegistry = {
|
||||
musicGenerationProviders: PluginMusicGenerationProviderRegistration[];
|
||||
webFetchProviders: PluginWebFetchProviderRegistration[];
|
||||
webSearchProviders: PluginWebSearchProviderRegistration[];
|
||||
embeddedExtensionFactories: PluginEmbeddedExtensionFactoryRegistration[];
|
||||
memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[];
|
||||
agentHarnesses: PluginAgentHarnessRegistration[];
|
||||
gatewayHandlers: GatewayRequestHandlers;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
getRegisteredAgentHarness,
|
||||
registerAgentHarness as registerGlobalAgentHarness,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
getRegisteredCompactionProvider,
|
||||
registerCompactionProvider,
|
||||
} from "./compaction-provider.js";
|
||||
import { PI_EMBEDDED_EXTENSION_RUNTIME_ID } from "./embedded-extension-factory.js";
|
||||
import { normalizePluginHttpPath } from "./http-path.js";
|
||||
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
||||
import {
|
||||
@@ -196,6 +198,69 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registry.diagnostics.push(diag);
|
||||
};
|
||||
|
||||
const registerPiEmbeddedExtensionFactory = (
|
||||
record: PluginRecord,
|
||||
factory: Parameters<OpenClawPluginApi["registerEmbeddedExtensionFactory"]>[0],
|
||||
) => {
|
||||
if (record.origin !== "bundled") {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "only bundled plugins can register Pi embedded extension factories",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(record.contracts?.embeddedExtensionFactories ?? []).includes(
|
||||
PI_EMBEDDED_EXTENSION_RUNTIME_ID,
|
||||
)
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message:
|
||||
'plugin must declare contracts.embeddedExtensionFactories: ["pi"] to register Pi embedded extension factories',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typeof (factory as unknown) !== "function") {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "embedded extension factory must be a function",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
registry.embeddedExtensionFactories.some(
|
||||
(entry) => entry.pluginId === record.id && entry.rawFactory === factory,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const safeFactory: ExtensionFactory = async (pi) => {
|
||||
try {
|
||||
await factory(pi);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
registryParams.logger.warn(
|
||||
`[plugins] embedded extension factory failed for ${record.id}: ${detail}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
registry.embeddedExtensionFactories.push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
rawFactory: factory,
|
||||
factory: safeFactory,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
};
|
||||
|
||||
const registerTool = (
|
||||
record: PluginRecord,
|
||||
tool: AnyAgentTool | OpenClawPluginToolFactory,
|
||||
@@ -1271,6 +1336,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
}
|
||||
registerCompactionProvider(provider, { ownerPluginId: record.id });
|
||||
},
|
||||
registerEmbeddedExtensionFactory: (factory) => {
|
||||
registerPiEmbeddedExtensionFactory(record, factory);
|
||||
},
|
||||
registerMemoryCapability: (capability) => {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
pushDiagnostic({
|
||||
|
||||
18
src/plugins/semver.runtime.ts
Normal file
18
src/plugins/semver.runtime.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const semver = require("semver") as {
|
||||
satisfies(version: string, range: string, options?: { includePrerelease?: boolean }): boolean;
|
||||
valid(version: string): string | null;
|
||||
validRange(range: string): string | null;
|
||||
};
|
||||
|
||||
export const satisfies = (
|
||||
version: string,
|
||||
range: string,
|
||||
options?: { includePrerelease?: boolean },
|
||||
): boolean => semver.satisfies(version, range, options);
|
||||
|
||||
export const validSemver = (version: string): string | null => semver.valid(version);
|
||||
|
||||
export const validRange = (range: string): string | null => semver.validRange(range);
|
||||
@@ -128,6 +128,7 @@ export function createPluginLoadResult(
|
||||
musicGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
embeddedExtensionFactories: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
import type { ExtensionFactory, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
import type { Command } from "commander";
|
||||
import type {
|
||||
ApiKeyCredential,
|
||||
@@ -2007,6 +2007,8 @@ export type OpenClawPluginApi = {
|
||||
) => void;
|
||||
/** Register an agent harness implementation. */
|
||||
registerAgentHarness: (harness: AgentHarness) => void;
|
||||
/** Register a Pi embedded extension factory for OpenClaw embedded runs. Only bundled plugins may use this seam, and `contracts.embeddedExtensionFactories` must include `"pi"`. */
|
||||
registerEmbeddedExtensionFactory: (factory: ExtensionFactory) => void;
|
||||
/** Register the active detached task runtime for this plugin (exclusive slot). */
|
||||
registerDetachedTaskRuntime: (
|
||||
runtime: import("./runtime/runtime-tasks.types.js").DetachedTaskLifecycleRuntime,
|
||||
|
||||
Reference in New Issue
Block a user