refactor(core): consolidate plugin install pipeline, migrate worktree ledger, carry prepared runtime metadata (#114749)

* refactor(plugins): consolidate install execution

* fix(worktrees): retire pre-ledger registry rows

Release note: Very old managed worktrees without provisioned-file ledgers are retired by openclaw doctor --fix and provision fresh on next use; existing checkout and branch data remain untouched.

* perf(plugins): reuse prepared runtime metadata

* fix(plugins): restore shared install result typing

* refactor(plugins): keep install helpers private
This commit is contained in:
Peter Steinberger
2026-07-27 19:34:01 -04:00
committed by GitHub
parent 0fdc825b29
commit 89db948bb6
24 changed files with 1003 additions and 1122 deletions
+23 -20
View File
@@ -1,7 +1,7 @@
// Verifies plugin extension points that are exposed to the Codex app server.
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import {
createAgentToolResultMiddlewareRunner,
@@ -251,7 +251,7 @@ export default { id: "tool-result-middleware", register(api) {
} };`,
});
setRuntimeConfigSnapshot({
const config = {
plugins: {
entries: {
"tool-result-middleware": {
@@ -259,10 +259,14 @@ export default { id: "tool-result-middleware", register(api) {
},
},
},
});
};
setRuntimeConfigSnapshot(config);
resetActivePluginRegistryForTest();
loadOpenClawPlugins({ config, loadModules: false, onlyPluginIds: ["tool-result-middleware"] });
expect(listAgentToolResultMiddlewares("codex")).toHaveLength(0);
const manifestRegistry = await import("../plugins/manifest-registry.js");
const manifestSpy = vi.spyOn(manifestRegistry, "loadPluginManifestRegistry");
// Startup activation stays false here; the runner must load the owner only
// when Codex asks for the middleware runtime.
@@ -276,6 +280,8 @@ export default { id: "tool-result-middleware", register(api) {
result: { content: [{ type: "text", text: "raw" }], details: {} },
});
expect(manifestSpy).not.toHaveBeenCalled();
manifestSpy.mockRestore();
expect(result.content).toEqual([{ type: "text", text: "exec lazily compacted" }]);
expect(listAgentToolResultMiddlewares("codex")).toHaveLength(0);
});
@@ -302,13 +308,15 @@ export default { id: "tool-result-middleware", register(api) {
} };`,
});
setRuntimeConfigSnapshot({
const config = {
plugins: {
load: { paths: [pluginFile] },
allow: ["tool-result-middleware"],
},
});
};
setRuntimeConfigSnapshot(config);
resetActivePluginRegistryForTest();
loadOpenClawPlugins({ config, loadModules: false, onlyPluginIds: ["tool-result-middleware"] });
expect(listAgentToolResultMiddlewares("codex")).toHaveLength(0);
@@ -542,24 +550,19 @@ export default { id: "tool-result-middleware", register(api) {
} };`,
});
loadOpenClawPlugins({
onlyPluginIds: ["bundled-tool-result-middleware"],
config: {
plugins: {
entries: {
"bundled-tool-result-middleware": {
enabled: true,
},
const config = {
plugins: {
load: { paths: [installedPluginFile] },
allow: ["bundled-tool-result-middleware", "installed-tool-result-middleware"],
entries: {
"bundled-tool-result-middleware": {
enabled: true,
},
},
},
});
setRuntimeConfigSnapshot({
plugins: {
load: { paths: [installedPluginFile] },
allow: ["installed-tool-result-middleware"],
},
});
};
loadOpenClawPlugins({ config, onlyPluginIds: ["bundled-tool-result-middleware"] });
setRuntimeConfigSnapshot(config);
expect(listAgentToolResultMiddlewares("codex")).toHaveLength(1);
+1 -7
View File
@@ -13,7 +13,6 @@ import {
findRegistryWorktreeByPath,
findLiveRegistryWorktreeByPath,
getRegistryWorktree,
getRegistryWorktreeProvisionedLedger,
getRegistryWorktreeProvisionedPaths,
getRegistryWorktreeProvisionedState,
insertRegistryWorktreeProvisionedChunk,
@@ -70,7 +69,6 @@ describe("managed worktree registry", () => {
});
expect(getRegistryWorktreeProvisionedPaths(env, "first")).toEqual([".env.local"]);
expect(getRegistryWorktreeProvisionedPaths(env, "second")).toBeUndefined();
expect(getRegistryWorktreeProvisionedLedger(env, "second")).toEqual({ status: "legacy" });
updateRegistryWorktree(env, "first", {
lastActiveAt: 30,
@@ -89,10 +87,6 @@ describe("managed worktree registry", () => {
expect(getRegistryWorktreeProvisionedState(env, "first")).toEqual([
{ path: ".env.local", mode: 0o600, chunks: 1 },
]);
expect(getRegistryWorktreeProvisionedLedger(env, "first")).toEqual({
status: "valid",
paths: [".env.local"],
});
insertRegistryWorktreeProvisionedChunk(env, {
worktreeId: "first",
path: ".env.local",
@@ -122,7 +116,7 @@ describe("managed worktree registry", () => {
openOpenClawStateDatabase({ env })
.db.prepare("UPDATE worktrees SET provisioned_paths_json = ? WHERE id = ?")
.run("not-json", "second");
expect(getRegistryWorktreeProvisionedLedger(env, "second")).toEqual({ status: "invalid" });
expect(getRegistryWorktreeProvisionedPaths(env, "second")).toBeUndefined();
});
it("adds the provisioned-path ledger to an existing worktree registry", () => {
+29 -21
View File
@@ -133,33 +133,41 @@ export function getRegistryWorktreeProvisionedPaths(
env: NodeJS.ProcessEnv,
id: string,
): string[] | undefined {
const ledger = getRegistryWorktreeProvisionedLedger(env, id);
return ledger.status === "valid" ? ledger.paths : undefined;
}
export function getRegistryWorktreeProvisionedLedger(
env: NodeJS.ProcessEnv,
id: string,
): { status: "legacy" } | { status: "invalid" } | { status: "valid"; paths: string[] } {
const db = dbFor(env);
const query = kyselyFor(db)
.selectFrom("worktrees")
.select("provisioned_paths_json")
.where("id", "=", id);
const row = executeSqliteQuerySync(db, query).rows[0];
if (!row) {
return { status: "invalid" };
}
if (row.provisioned_paths_json === null) {
return { status: "legacy" };
}
const data = parseProvisionedData(row.provisioned_paths_json);
return data
? {
status: "valid",
paths: data.map((entry) => (typeof entry === "string" ? entry : entry.path)),
}
: { status: "invalid" };
return parseProvisionedData(row?.provisioned_paths_json ?? null)?.map((entry) =>
typeof entry === "string" ? entry : entry.path,
);
}
export function hasLegacyRegistryWorktrees(env: NodeJS.ProcessEnv): boolean {
const db = dbFor(env);
const query = kyselyFor(db)
.selectFrom("worktrees")
.select("id")
.where("provisioned_paths_json", "is", null)
.limit(1);
return executeSqliteQuerySync(db, query).rows.length > 0;
}
export function discardLegacyRegistryWorktrees(env: NodeJS.ProcessEnv): number {
const db = dbFor(env);
return runOpenClawStateWriteTransaction(
() =>
Number(
executeSqliteQuerySync(
db,
// Retire every pre-ledger owner row so next use provisions canonically.
// The checkout and branch stay untouched; doctor never deletes their user data.
kyselyFor(db).deleteFrom("worktrees").where("provisioned_paths_json", "is", null),
).numAffectedRows ?? 0n,
),
{ env },
);
}
export function getRegistryWorktreeProvisionedState(
@@ -6,12 +6,7 @@ import path from "node:path";
import { promisify } from "node:util";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
deleteRegistryWorktree,
getRegistryWorktree,
getRegistryWorktreeProvisionedPaths,
insertRegistryWorktree,
} from "./registry.js";
import { insertRegistryWorktree } from "./registry.js";
import { ManagedWorktreeService } from "./service.js";
const execFileAsync = promisify(execFile);
@@ -232,30 +227,6 @@ describe("ManagedWorktreeService provisioned state", () => {
},
);
it("upgrades the provisioned ledger when restoring a pre-ledger snapshot", async () => {
await fs.writeFile(path.join(repo, ".gitignore"), ".env.local\n");
await fs.writeFile(path.join(repo, ".worktreeinclude"), ".env.local\n");
await git(repo, "add", ".gitignore", ".worktreeinclude");
await git(repo, "commit", "-m", "configure worktree provisioning");
await fs.writeFile(path.join(repo, ".env.local"), "value=source\n");
await addRemote(root, repo);
const created = await service.create({ repoRoot: repo, name: "legacy-restore" });
await service.remove({ id: created.id, reason: "test" });
const removed = getRegistryWorktree(env, created.id)!;
deleteRegistryWorktree(env, created.id);
insertRegistryWorktree(env, removed);
const restored = await service.restore({ id: created.id });
expect(getRegistryWorktreeProvisionedPaths(env, created.id)).toEqual([".env.local"]);
await fs.writeFile(path.join(restored.path, ".env.local"), "value=restored-local\n");
expect(await service.removeIfLossless(created.id)).toBe(true);
const roundTripped = await service.restore({ id: created.id });
expect(await fs.readFile(path.join(roundTripped.path, ".env.local"), "utf8")).toBe(
"value=restored-local\n",
);
});
it("snapshots deleted skip-worktree files still included by sparse rules", async () => {
const created = await service.create({ repoRoot: repo, name: "stale-sparse-bit" });
await git(created.path, "sparse-checkout", "set", "--no-cone", "/*");
+5 -15
View File
@@ -36,7 +36,6 @@ import {
findLiveRegistryWorktreeByOwner,
findLiveRegistryWorktreeByPath,
getRegistryWorktree,
getRegistryWorktreeProvisionedLedger,
getRegistryWorktreeProvisionedPaths,
getRegistryWorktreeProvisionedState,
insertRegistryWorktree,
@@ -963,21 +962,12 @@ export class ManagedWorktreeService {
branchCreated = true;
await requireGit(record.path, ["symbolic-ref", "HEAD", `refs/heads/${record.branch}`]);
await requireGit(record.path, ["reset"]);
const provisionedLedger = getRegistryWorktreeProvisionedLedger(this.env, record.id);
if (provisionedLedger.status === "legacy") {
// Explicitly removed pre-ledger worktrees retain their historical restore behavior.
restoredProvisionedPaths = await provisionIncludedFiles(record.repoRoot, record.path);
} else {
if (provisionedLedger.status === "invalid") {
throw new Error(`worktree ${record.id} has invalid provisioned file metadata`);
}
const provisionedState = getRegistryWorktreeProvisionedState(this.env, record.id);
if (provisionedState === undefined) {
throw new Error(`worktree ${record.id} snapshot lacks provisioned file metadata`);
}
await restoreProvisionedFiles(this.env, record.id, record.path, provisionedState);
restoredProvisionedPaths = provisionedState.map((state) => state.path);
const provisionedState = getRegistryWorktreeProvisionedState(this.env, record.id);
if (provisionedState === undefined) {
throw new Error(`worktree ${record.id} snapshot lacks provisioned file metadata`);
}
await restoreProvisionedFiles(this.env, record.id, record.path, provisionedState);
restoredProvisionedPaths = provisionedState.map((state) => state.path);
} catch (error) {
const removed = await runGit(record.repoRoot, ["worktree", "remove", "--force", record.path]);
const branchDeleted = branchCreated
+49 -283
View File
@@ -1,54 +1,14 @@
import fs from "node:fs";
import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js";
import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js";
import { resolveBundledInstallPlanBeforeNpm } from "../../cli/plugin-install-plan.js";
import {
createPluginInstallLogger,
parseNpmPackPrefixPath,
resolveFileNpmSpecToLocalPath,
} from "../../cli/plugins-command-helpers.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { resolveArchiveKind } from "../../infra/archive.js";
import { parseClawHubPluginSpec } from "../../infra/clawhub.js";
import { installBundledPluginSource } from "../../plugins/bundled-install.js";
import { findBundledPluginSource } from "../../plugins/bundled-sources.js";
import { buildClawHubPluginInstallRecordFields } from "../../plugins/clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../plugins/clawhub.js";
import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js";
import {
persistPluginInstall,
type ConfigSnapshotForInstallPersist,
} from "../../plugins/install-persistence.js";
import { resolvePluginInstallSourcePlan } from "../../cli/plugin-install-plan.js";
import { createPluginInstallLogger } from "../../cli/plugins-command-helpers.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../../plugins/clawhub.js";
import type { ConfigSnapshotForInstallPersist } from "../../plugins/install-persistence.js";
import {
formatNonClawHubInstallWarning,
NON_CLAWHUB_INSTALL_FORCE_FLAG,
resolveOpenClawTrustedNpmPackageInstall,
type NonClawHubInstallSourceClass,
} from "../../plugins/install-provenance.js";
import {
installPluginFromNpmPackArchive,
installPluginFromNpmSpec,
installPluginFromPath,
} from "../../plugins/install.js";
import { resolveCatalogOfficialExternalInstallPlan } from "../../plugins/official-external-install-trust.js";
import { resolveUserPath } from "../../utils.js";
function looksLikeLocalPluginInstallSpec(raw: string): boolean {
return (
raw.startsWith(".") ||
raw.startsWith("~") ||
raw.startsWith("/") ||
raw.endsWith(".ts") ||
raw.endsWith(".js") ||
raw.endsWith(".mjs") ||
raw.endsWith(".cjs") ||
raw.endsWith(".tgz") ||
raw.endsWith(".tar.gz") ||
raw.endsWith(".tar") ||
raw.endsWith(".zip")
);
}
import { installManagedPluginSource } from "../../plugins/management-service.js";
function resolveNonClawHubChatInstallAcknowledgement(params: {
force: boolean;
@@ -68,256 +28,62 @@ function resolveNonClawHubChatInstallAcknowledgement(params: {
export async function installPluginFromPluginsCommand(params: {
raw: string;
force: boolean;
config: OpenClawConfig;
snapshot: ConfigSnapshotForInstallPersist;
}): Promise<
{ ok: true; pluginId: string; warnings?: readonly string[] } | { ok: false; error: string }
> {
const fileSpec = resolveFileNpmSpecToLocalPath(params.raw);
if (fileSpec && !fileSpec.ok) {
return { ok: false, error: fileSpec.error };
}
const normalized = fileSpec && fileSpec.ok ? fileSpec.path : params.raw;
const resolved = resolveUserPath(normalized);
const installMode = params.force ? "update" : "install";
if (fs.existsSync(resolved)) {
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
const bundledLocalSource =
source === "path"
? findBundledPluginSource({ lookup: { kind: "localPath", value: resolved } })
: undefined;
const acknowledgement = bundledLocalSource
? null
: resolveNonClawHubChatInstallAcknowledgement({
force: params.force,
sourceClass: source === "archive" ? "local-archive" : "local-path",
spec: params.raw,
});
if (acknowledgement && !acknowledgement.ok) {
return acknowledgement;
}
const result = await installPluginFromPath({
path: resolved,
config: params.config,
mode: installMode,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
return { ok: false, error: result.error };
}
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: {
source,
sourcePath: resolved,
installPath: result.targetDir,
version: result.version,
},
});
return {
ok: true,
pluginId: result.pluginId,
...(acknowledgement?.ok ? { warnings: [acknowledgement.warning] } : {}),
};
const plan = resolvePluginInstallSourcePlan({ raw: params.raw, mode: installMode });
if (!plan.ok) {
return { ok: false, error: plan.error.replace(/^Plugin path not found:/, "Path not found:") };
}
const npmPackPath = parseNpmPackPrefixPath(params.raw);
if (npmPackPath !== null) {
if (!npmPackPath) {
return { ok: false, error: "Unsupported npm-pack plugin spec: missing archive path." };
}
const acknowledgement = resolveNonClawHubChatInstallAcknowledgement({
force: params.force,
sourceClass: "npm-pack",
spec: params.raw,
});
if (!acknowledgement.ok) {
return acknowledgement;
}
const result = await installPluginFromNpmPackArchive({
archivePath: npmPackPath,
config: params.config,
mode: installMode,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
return { ok: false, error: result.error };
}
const installRecord = {
...buildNpmInstallRecordFields({
spec: result.npmResolution?.resolvedSpec ?? result.manifestName ?? result.pluginId,
installPath: result.targetDir,
version: result.version,
resolution: result.npmResolution,
}),
sourcePath: npmPackPath,
artifactKind: "npm-pack",
artifactFormat: "tgz",
...(result.npmResolution?.integrity ? { npmIntegrity: result.npmResolution.integrity } : {}),
...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}),
...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}),
} satisfies PluginInstallRecord;
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: installRecord,
});
return { ok: true, pluginId: result.pluginId, warnings: [acknowledgement.warning] };
const acknowledgement = plan.acknowledgement
? resolveNonClawHubChatInstallAcknowledgement({
force: params.force,
...plan.acknowledgement,
})
: null;
if (acknowledgement && !acknowledgement.ok) {
return acknowledgement;
}
if (looksLikeLocalPluginInstallSpec(params.raw)) {
return { ok: false, error: `Path not found: ${resolved}` };
}
const gitPrefix = params.raw.trim().toLowerCase().startsWith("git:");
const gitSpec = parseGitPluginSpec(params.raw);
if (gitPrefix && !gitSpec) {
return { ok: false, error: `unsupported git: plugin spec: ${params.raw}` };
}
if (gitSpec) {
const acknowledgement = resolveNonClawHubChatInstallAcknowledgement({
force: params.force,
sourceClass: "git",
spec: params.raw,
});
if (!acknowledgement.ok) {
return acknowledgement;
}
const result = await installPluginFromGitSpec({
spec: params.raw,
config: params.config,
mode: installMode,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
return { ok: false, error: result.error };
}
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: {
source: "git",
spec: params.raw,
installPath: result.targetDir,
version: result.version,
resolvedAt: result.git.resolvedAt,
gitUrl: result.git.url,
gitRef: result.git.ref,
gitCommit: result.git.commit,
},
});
return { ok: true, pluginId: result.pluginId, warnings: [acknowledgement.warning] };
}
const clawhubSpec = parseClawHubPluginSpec(params.raw);
if (clawhubSpec) {
const warnings: string[] = [];
const logger = createPluginInstallLogger();
const result = await installPluginFromClawHub({
spec: params.raw,
config: params.config,
mode: installMode,
logger: {
info: logger.info,
warn: (message) => {
warnings.push(stripAnsi(message));
logger.warn(message);
},
terminalLinks: false,
},
});
if (!result.ok) {
const warning = "warning" in result ? result.warning : warnings.join("\n");
const warningPrefix = warning ? `${warning} ` : "";
if (result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED) {
return {
ok: false,
error: `${warningPrefix}${result.error} The /plugins chat command cannot acknowledge ClawHub risk; run the local openclaw plugins install command with --acknowledge-clawhub-risk from a trusted shell after reviewing the warning.`,
};
}
return { ok: false, error: `${warningPrefix}${result.error}` };
}
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: {
...buildClawHubPluginInstallRecordFields(result.clawhub),
spec: params.raw,
installPath: result.targetDir,
version: result.version,
},
});
return { ok: true, pluginId: result.pluginId, warnings };
}
const npmSpec = params.raw.trim().toLowerCase().startsWith("npm:")
? params.raw.trim().slice("npm:".length)
: params.raw;
const explicitNpm = params.raw.trim().toLowerCase().startsWith("npm:");
const bundledPlan = explicitNpm
? null
: resolveBundledInstallPlanBeforeNpm({
rawSpec: params.raw,
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
if (bundledPlan) {
const bundledInstall = await installBundledPluginSource({
snapshot: params.snapshot,
rawSpec: params.raw,
bundledSource: bundledPlan.bundledSource,
warning: bundledPlan.warning,
});
return {
ok: true,
pluginId: bundledInstall.pluginId,
warnings: bundledInstall.warnings,
};
}
const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(npmSpec);
const officialIdPlan = resolveCatalogOfficialExternalInstallPlan(params.raw);
const arbitraryNpmAcknowledgement =
!trustedNpmInstall && !officialIdPlan
? resolveNonClawHubChatInstallAcknowledgement({
force: params.force,
sourceClass: "npm",
spec: params.raw,
})
: null;
if (arbitraryNpmAcknowledgement && !arbitraryNpmAcknowledgement.ok) {
return arbitraryNpmAcknowledgement;
}
const trustedPluginId = trustedNpmInstall?.pluginId ?? officialIdPlan?.pluginId;
const trustedNpmSpec = officialIdPlan?.npmSpec ?? npmSpec;
const expectedIntegrity =
trustedNpmInstall?.expectedIntegrity ?? officialIdPlan?.expectedIntegrity;
const result = await installPluginFromNpmSpec({
spec: trustedNpmSpec,
config: params.config,
mode: installMode,
...(trustedPluginId ? { expectedPluginId: trustedPluginId } : {}),
...(expectedIntegrity ? { expectedIntegrity } : {}),
...(trustedNpmInstall || officialIdPlan ? { trustedSourceLinkedOfficialInstall: true } : {}),
logger: createPluginInstallLogger(),
const warnings: string[] = [];
const logger = createPluginInstallLogger();
const clawhub = plan.request.source === "clawhub";
const result = await installManagedPluginSource({
request: plan.request,
snapshot: params.snapshot,
logger: clawhub
? {
info: logger.info,
warn: (message) => {
warnings.push(stripAnsi(message));
logger.warn(message);
},
terminalLinks: false,
}
: logger,
});
if (!result.ok) {
return { ok: false, error: result.error };
const warning = "warning" in result ? result.warning : warnings.join("\n");
const warningPrefix = warning ? `${warning} ` : "";
if (
clawhub &&
result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED
) {
return {
ok: false,
error: `${warningPrefix}${result.error} The /plugins chat command cannot acknowledge ClawHub risk; run the local openclaw plugins install command with --acknowledge-clawhub-risk from a trusted shell after reviewing the warning.`,
};
}
return { ok: false, error: `${warningPrefix}${result.error}` };
}
warnings.push(...(result.warnings ?? []));
if (acknowledgement?.ok) {
warnings.push(acknowledgement.warning);
}
const installRecord = buildNpmInstallRecordFields({
spec: trustedNpmSpec,
installPath: result.targetDir,
version: result.version,
resolution: result.npmResolution,
});
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: installRecord,
});
return {
ok: true,
pluginId: result.pluginId,
...(arbitraryNpmAcknowledgement?.ok ? { warnings: [arbitraryNpmAcknowledgement.warning] } : {}),
...(warnings.length > 0 ? { warnings } : {}),
};
}
-1
View File
@@ -286,7 +286,6 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
const installed = await installPluginFromPluginsCommand({
raw: pluginsCommand.spec,
force: pluginsCommand.force,
config: loadedConfig.snapshot.config,
snapshot: loadedConfig.snapshot,
});
if (!installed.ok) {
+1 -31
View File
@@ -1,42 +1,12 @@
// npm resolution tests cover CLI plugin package resolution from installed roots.
import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { describe, expect, it } from "vitest";
import {
buildNpmInstallRecordFields,
resolvePinnedNpmInstallRecordForCli,
} from "./npm-resolution.js";
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
const CLI_STATE_ROOT = "/tmp/openclaw";
const ALPHA_INSTALL_PATH = installedPluginRoot(CLI_STATE_ROOT, "alpha");
describe("npm-resolution helpers", () => {
it("builds common npm install record fields", () => {
expect(
buildNpmInstallRecordFields({
spec: "@openclaw/plugin-alpha@latest",
installPath: ALPHA_INSTALL_PATH,
version: "1.2.3",
resolution: {
name: "@openclaw/plugin-alpha",
version: "1.2.3",
resolvedSpec: "@openclaw/plugin-alpha@1.2.3",
integrity: "sha512-abc",
},
}),
).toEqual({
source: "npm",
spec: "@openclaw/plugin-alpha@latest",
installPath: ALPHA_INSTALL_PATH,
version: "1.2.3",
resolvedName: "@openclaw/plugin-alpha",
resolvedVersion: "1.2.3",
resolvedSpec: "@openclaw/plugin-alpha@1.2.3",
integrity: "sha512-abc",
shasum: undefined,
resolvedAt: undefined,
});
});
it("pins the install record to the resolved spec and logs a notice", () => {
const logs: string[] = [];
const record = resolvePinnedNpmInstallRecordForCli(
+1 -1
View File
@@ -5,7 +5,7 @@ import {
} from "../infra/install-source-utils.js";
/** Build the npm section of a plugin install record. */
export function buildNpmInstallRecordFields(params: {
function buildNpmInstallRecordFields(params: {
spec: string;
installPath: string;
version?: string;
+8 -97
View File
@@ -11,8 +11,8 @@ import {
} from "../plugins/official-external-install-trust.js";
import {
resolveBundledInstallPlanForCatalogEntry,
resolveBundledInstallPlanBeforeNpm,
resolveBundledInstallPlanForNpmFailure,
resolvePluginInstallSourcePlan,
} from "./plugin-install-plan.js";
function createSourceCheckoutPlugin(pluginId: string): {
@@ -31,102 +31,13 @@ function createSourceCheckoutPlugin(pluginId: string): {
}
describe("plugin install plan helpers", () => {
it("prefers bundled plugin for bare plugin-id specs", () => {
const findBundledSource = vi.fn().mockReturnValue({
pluginId: "voice-call",
localPath: installedPluginRoot("/tmp", "voice-call"),
npmSpec: "@openclaw/voice-call",
});
const result = resolveBundledInstallPlanBeforeNpm({
rawSpec: "voice-call",
findBundledSource,
});
expect(findBundledSource).toHaveBeenCalledWith({ kind: "pluginId", value: "voice-call" });
expect(result?.bundledSource.pluginId).toBe("voice-call");
expect(result?.warning).toContain('bare install spec "voice-call"');
});
it("prefers bundled plugin for scoped npm package specs", () => {
const findBundledSource = vi
.fn()
.mockImplementation(({ kind, value }: { kind: "pluginId" | "npmSpec"; value: string }) => {
if (kind === "npmSpec" && value === "@openclaw/voice-call") {
return {
pluginId: "voice-call",
localPath: installedPluginRoot("/tmp", "voice-call"),
npmSpec: "@openclaw/voice-call",
};
}
return undefined;
});
const result = resolveBundledInstallPlanBeforeNpm({
rawSpec: "@openclaw/voice-call@2026.5.20",
findBundledSource,
});
expect(findBundledSource).toHaveBeenCalledWith({
kind: "npmSpec",
value: "@openclaw/voice-call@2026.5.20",
});
expect(findBundledSource).toHaveBeenCalledWith({
kind: "npmSpec",
value: "@openclaw/voice-call",
});
expect(result?.bundledSource.pluginId).toBe("voice-call");
expect(result?.warning).toContain('npm install spec "@openclaw/voice-call@2026.5.20"');
expect(result?.warning).toContain("npm:@openclaw/voice-call@2026.5.20");
});
it("keeps scoped npm specs on the registry path for source checkout bundles", () => {
const { packageRoot, pluginRoot } = createSourceCheckoutPlugin("codex");
try {
const findBundledSource = vi.fn().mockReturnValue({
pluginId: "codex",
localPath: pluginRoot,
npmSpec: "@openclaw/codex",
});
const result = resolveBundledInstallPlanBeforeNpm({
rawSpec: "@openclaw/codex@2026.7.2-beta.3",
findBundledSource,
});
expect(result).toBeNull();
} finally {
fs.rmSync(packageRoot, { recursive: true, force: true });
}
});
it("keeps bare plugin ids on source checkout bundles", () => {
const { packageRoot, pluginRoot } = createSourceCheckoutPlugin("codex");
try {
const findBundledSource = vi.fn().mockReturnValue({
pluginId: "codex",
localPath: pluginRoot,
npmSpec: "@openclaw/codex",
});
const result = resolveBundledInstallPlanBeforeNpm({
rawSpec: "codex",
findBundledSource,
});
expect(result?.bundledSource.pluginId).toBe("codex");
} finally {
fs.rmSync(packageRoot, { recursive: true, force: true });
}
});
it("skips bundled pre-plan for npm specs that do not match bundled packages", () => {
const findBundledSource = vi.fn();
const result = resolveBundledInstallPlanBeforeNpm({
rawSpec: "@openclaw/not-bundled",
findBundledSource,
});
expect(result).toBeNull();
it("keeps explicit npm specs with local-looking suffixes on the registry path", () => {
expect(resolvePluginInstallSourcePlan({ raw: "npm:plugin.js", mode: "install" })).toMatchObject(
{
ok: true,
request: { source: "npm", spec: "plugin.js" },
},
);
});
it("resolves exact official external plugin ids before npm fallback", () => {
+164 -3
View File
@@ -1,16 +1,177 @@
// Plugin install planning helpers for bundled, official external, and npm fallback paths.
import fs from "node:fs";
import path from "node:path";
import { resolveArchiveKind } from "../infra/archive.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import type { BundledPluginSource } from "../plugins/bundled-sources.js";
import { findBundledPluginSource, type BundledPluginSource } from "../plugins/bundled-sources.js";
import { parseGitPluginSpec } from "../plugins/git-install.js";
import {
resolveOpenClawTrustedNpmPackageInstall,
type NonClawHubInstallSourceClass,
} from "../plugins/install-provenance.js";
import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js";
import { shortenHomePath } from "../utils.js";
import type { ManagedPluginSourceInstallRequest } from "../plugins/management-service.js";
import { resolveCatalogOfficialExternalInstallPlan } from "../plugins/official-external-install-trust.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import {
parseNpmPackPrefixPath,
parseNpmPrefixSpec,
resolveFileNpmSpecToLocalPath,
} from "./plugins-command-helpers.js";
type BundledLookup = (params: {
kind: "pluginId" | "npmSpec";
value: string;
}) => BundledPluginSource | undefined;
type PluginInstallSourcePlan =
| { ok: false; error: string }
| {
ok: true;
request: ManagedPluginSourceInstallRequest;
acknowledgement?: { sourceClass: NonClawHubInstallSourceClass; spec: string };
};
function sourcePlan(
request: ManagedPluginSourceInstallRequest,
raw: string,
sourceClass?: NonClawHubInstallSourceClass,
): PluginInstallSourcePlan {
return {
ok: true,
request,
...(sourceClass ? { acknowledgement: { sourceClass, spec: raw } } : {}),
};
}
export function resolvePluginInstallSourcePlan(params: {
raw: string;
mode: "install" | "update";
link?: boolean;
pin?: boolean;
}): PluginInstallSourcePlan {
const fileSpec = resolveFileNpmSpecToLocalPath(params.raw);
if (fileSpec && !fileSpec.ok) {
return fileSpec;
}
const normalized = fileSpec?.ok ? fileSpec.path : params.raw;
const resolved = resolveUserPath(normalized);
if (fs.existsSync(resolved)) {
const recordSource = resolveArchiveKind(resolved) ? "archive" : "path";
const bundled =
recordSource === "path"
? findBundledPluginSource({ lookup: { kind: "localPath", value: resolved } })
: undefined;
return sourcePlan(
{
source: "local",
path: resolved,
recordSource,
mode: params.mode,
...(params.link ? { link: true } : {}),
},
params.raw,
bundled ? undefined : recordSource === "archive" ? "local-archive" : "local-path",
);
}
const npmPackPath = parseNpmPackPrefixPath(params.raw);
if (npmPackPath !== null) {
return npmPackPath
? sourcePlan(
{ source: "npm-pack", archivePath: npmPackPath, mode: params.mode },
params.raw,
"npm-pack",
)
: { ok: false, error: "Unsupported npm-pack plugin spec: missing archive path." };
}
const gitPrefix = params.raw.trim().toLowerCase().startsWith("git:");
const git = parseGitPluginSpec(params.raw);
if (gitPrefix) {
return git
? sourcePlan({ source: "git", spec: params.raw, mode: params.mode }, params.raw, "git")
: { ok: false, error: `unsupported git: plugin spec: ${params.raw}` };
}
if (parseClawHubPluginSpec(params.raw)) {
return sourcePlan({ source: "clawhub", spec: params.raw, mode: params.mode }, params.raw);
}
const explicitNpm = parseNpmPrefixSpec(params.raw);
if (explicitNpm !== null && !explicitNpm) {
return { ok: false, error: "Unsupported npm plugin spec: missing package." };
}
if (
explicitNpm === null &&
looksLikeLocalInstallSpec(params.raw, [
".ts",
".js",
".mjs",
".cjs",
".tgz",
".tar.gz",
".tar",
".zip",
])
) {
return { ok: false, error: `Plugin path not found: ${resolved}` };
}
const npmSpec = explicitNpm ?? params.raw;
const bundledPlan =
explicitNpm === null
? resolveBundledInstallPlanBeforeNpm({
rawSpec: params.raw,
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
})
: null;
if (bundledPlan) {
return sourcePlan(
{
source: "bundled",
rawSpec: params.raw,
bundledSource: bundledPlan.bundledSource,
warning: bundledPlan.warning,
},
params.raw,
);
}
const official =
explicitNpm === null ? resolveCatalogOfficialExternalInstallPlan(params.raw) : null;
if (official) {
return sourcePlan(
{
source: "official",
spec: official.npmSpec,
pluginId: official.pluginId,
mode: params.mode,
...(official.expectedIntegrity ? { expectedIntegrity: official.expectedIntegrity } : {}),
...(params.pin ? { pin: true } : {}),
},
params.raw,
);
}
const trusted = resolveOpenClawTrustedNpmPackageInstall(npmSpec);
return sourcePlan(
{
source: "npm",
spec: npmSpec,
mode: params.mode,
...(params.pin ? { pin: true } : {}),
...(explicitNpm === null ? { allowBundledFallback: true } : {}),
...(trusted
? {
expectedPluginId: trusted.pluginId,
...(trusted.expectedIntegrity ? { expectedIntegrity: trusted.expectedIntegrity } : {}),
trustedSourceLinkedOfficialInstall: true,
}
: {}),
},
params.raw,
trusted ? undefined : "npm",
);
}
function isBareNpmPackageName(spec: string): boolean {
const trimmed = spec.trim();
return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed);
@@ -74,7 +235,7 @@ export function resolveBundledInstallPlanForCatalogEntry(params: {
return { bundledSource: bundledById };
}
export function resolveBundledInstallPlanBeforeNpm(params: {
function resolveBundledInstallPlanBeforeNpm(params: {
rawSpec: string;
findBundledSource: BundledLookup;
}): { bundledSource: BundledPluginSource; warning: string } | null {
+151 -361
View File
@@ -15,11 +15,8 @@ import {
import { resolveArchiveKind } from "../infra/archive.js";
import { parseClawHubPluginSpec, reportClawHubPluginInstallTelemetry } from "../infra/clawhub.js";
import { formatErrorMessage } from "../infra/errors.js";
import { installBundledPluginSource } from "../plugins/bundled-install.js";
import { findBundledPluginSource } from "../plugins/bundled-sources.js";
import { buildClawHubPluginInstallRecordFields } from "../plugins/clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../plugins/clawhub.js";
import { installPluginFromGitSpec, parseGitPluginSpec } from "../plugins/git-install.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js";
import {
persistPluginInstall,
@@ -29,20 +26,14 @@ import {
type ConfigMutationPreflight,
type ConfigSnapshotForInstallPersist,
} from "../plugins/install-persistence.js";
import { resolveOpenClawTrustedNpmPackageInstall } from "../plugins/install-provenance.js";
import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js";
import {
PLUGIN_INSTALL_ERROR_CODE,
installPluginFromNpmPackArchive,
installPluginFromNpmSpec,
installPluginFromPath,
} from "../plugins/install.js";
import { PLUGIN_INSTALL_ERROR_CODE } from "../plugins/install.js";
import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js";
import { installManagedPluginSource } from "../plugins/management-service.js";
import {
installPluginFromMarketplace,
resolveMarketplaceInstallShortcut,
} from "../plugins/marketplace.js";
import { resolveCatalogOfficialExternalInstallPlan } from "../plugins/official-external-install-trust.js";
import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
@@ -52,7 +43,6 @@ import { resolveUserPath, shortenHomePath } from "../utils.js";
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
import { formatCliCommand } from "./command-format.js";
import { persistHookPackInstall } from "./hook-install-persistence.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import {
confirmNonClawHubInstall,
NON_CLAWHUB_INSTALL_FORCE_FLAG,
@@ -65,15 +55,13 @@ import {
type PluginInstallRequestContext,
} from "./plugin-install-config-policy.js";
import {
resolveBundledInstallPlanBeforeNpm,
resolveBundledInstallPlanForNpmFailure,
resolvePluginInstallSourcePlan,
} from "./plugin-install-plan.js";
import {
createHookPackInstallLogger,
createPluginInstallLogger,
formatPluginInstallWithHookFallbackError,
parseNpmPackPrefixPath,
parseNpmPrefixSpec,
} from "./plugins-command-helpers.js";
import { listPersistedBundledPluginRecoveryLocations } from "./plugins-location-bridges.js";
@@ -296,6 +284,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
expectedPluginId?: string;
expectedIntegrity?: string;
trustedSourceLinkedOfficialInstall?: boolean;
official?: boolean;
invalidateRuntimeCache?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
@@ -342,17 +331,32 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
}
}
const result = await installPluginFromNpmSpec({
...params.safetyOverrides,
mode: params.installMode,
spec: params.spec,
...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}),
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
...(params.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
extensionsDir: params.extensionsDir,
const result = await installManagedPluginSource({
request: params.official
? {
source: "official",
spec: params.spec,
pluginId: params.expectedPluginId ?? params.spec,
mode: params.installMode,
pin: params.pin,
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
}
: {
source: "npm",
spec: params.spec,
mode: params.installMode,
pin: params.pin,
...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}),
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
...(params.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
},
snapshot: params.snapshot,
safetyOverrides: params.safetyOverrides,
logger: createPluginInstallLogger(params.runtime),
invalidateRuntimeCache: params.invalidateRuntimeCache,
runtime: params.runtime,
});
if (!result.ok) {
if (isTerminalPluginInstallFailure(result.code)) {
@@ -366,11 +370,14 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
if (bundledFallbackPlan) {
await installBundledPluginSource({
await installManagedPluginSource({
request: {
source: "bundled",
rawSpec: params.spec,
bundledSource: bundledFallbackPlan.bundledSource,
warning: bundledFallbackPlan.warning,
},
snapshot: params.snapshot,
rawSpec: params.spec,
bundledSource: bundledFallbackPlan.bundledSource,
warning: bundledFallbackPlan.warning,
invalidateRuntimeCache: params.invalidateRuntimeCache,
runtime: params.runtime,
});
@@ -394,112 +401,14 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
return { ok: false };
}
const installRecord = resolvePinnedNpmInstallRecordForCli(
params.spec,
Boolean(params.pin),
result.targetDir,
result.version,
result.npmResolution,
params.runtime?.log ?? defaultRuntime.log,
theme.warn,
);
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: installRecord,
invalidateRuntimeCache: params.invalidateRuntimeCache,
runtime: params.runtime,
});
return { ok: true };
}
async function tryInstallPluginFromNpmPackArchive(params: {
snapshot: ConfigSnapshotForInstallExecution;
installMode: "install" | "update";
archivePath: string;
safetyOverrides: InstallSafetyOverrides;
extensionsDir: string;
invalidateRuntimeCache?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
const result = await installPluginFromNpmPackArchive({
...params.safetyOverrides,
mode: params.installMode,
archivePath: params.archivePath,
extensionsDir: params.extensionsDir,
logger: createPluginInstallLogger(params.runtime),
});
if (!result.ok) {
(params.runtime ?? defaultRuntime).error(result.error);
return { ok: false };
if (params.pin) {
const resolvedSpec = result.npmResolution?.resolvedSpec;
(params.runtime ?? defaultRuntime).log(
resolvedSpec
? `Pinned npm install record to ${resolvedSpec}.`
: theme.warn("Could not resolve exact npm version for --pin; storing original npm spec."),
);
}
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: {
source: "npm",
spec: result.npmResolution?.resolvedSpec ?? result.manifestName ?? result.pluginId,
sourcePath: params.archivePath,
installPath: result.targetDir,
...(result.version ? { version: result.version } : {}),
...(result.npmResolution?.name ? { resolvedName: result.npmResolution.name } : {}),
...(result.npmResolution?.version ? { resolvedVersion: result.npmResolution.version } : {}),
...(result.npmResolution?.resolvedSpec
? { resolvedSpec: result.npmResolution.resolvedSpec }
: {}),
...(result.npmResolution?.integrity ? { integrity: result.npmResolution.integrity } : {}),
...(result.npmResolution?.shasum ? { shasum: result.npmResolution.shasum } : {}),
...(result.npmResolution?.resolvedAt ? { resolvedAt: result.npmResolution.resolvedAt } : {}),
artifactKind: "npm-pack",
artifactFormat: "tgz",
...(result.npmResolution?.integrity ? { npmIntegrity: result.npmResolution.integrity } : {}),
...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}),
...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}),
},
invalidateRuntimeCache: params.invalidateRuntimeCache,
runtime: params.runtime,
});
return { ok: true };
}
async function tryInstallPluginFromGitSpec(params: {
snapshot: ConfigSnapshotForInstallExecution;
installMode: "install" | "update";
spec: string;
safetyOverrides: InstallSafetyOverrides;
extensionsDir: string;
invalidateRuntimeCache?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
const result = await installPluginFromGitSpec({
...params.safetyOverrides,
mode: params.installMode,
spec: params.spec,
extensionsDir: params.extensionsDir,
logger: createPluginInstallLogger(params.runtime),
});
if (!result.ok) {
(params.runtime ?? defaultRuntime).error(result.error);
return { ok: false };
}
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: {
source: "git",
spec: params.spec,
installPath: result.targetDir,
version: result.version,
resolvedAt: result.git.resolvedAt,
gitUrl: result.git.url,
gitRef: result.git.ref,
gitCommit: result.git.commit,
},
invalidateRuntimeCache: params.invalidateRuntimeCache,
runtime: params.runtime,
});
return { ok: true };
}
@@ -822,28 +731,34 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return runtime.exit(1);
}
}
const gitPrefix = raw.trim().toLowerCase().startsWith("git:");
const gitSpec = parseGitPluginSpec(raw);
if (gitPrefix && !gitSpec) {
runtime.error(
`Unsupported git plugin spec: ${raw}. Use ${formatCliCommand(`openclaw plugins install git:<repo>@<ref> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`,
);
// For linked paths, --force confirms source provenance without changing copy/update mode.
const installMode = resolveInstallMode(opts.force && !opts.link);
const sourcePlan = opts.marketplace
? null
: resolvePluginInstallSourcePlan({ raw, mode: installMode, link: opts.link, pin: opts.pin });
if (sourcePlan && !sourcePlan.ok) {
runtime.error(sourcePlan.error);
return runtime.exit(1);
}
if (gitSpec && opts.link) {
const sourceRequest = sourcePlan?.request;
if (sourceRequest?.source === "git" && opts.link) {
runtime.error(
`--link is not supported with git: installs. Use ${formatCliCommand(`openclaw plugins install git:<repo>@<ref> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)} for Git installs or ${formatCliCommand(`openclaw plugins install --link <path> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)} for local paths.`,
);
return runtime.exit(1);
}
if (gitSpec && opts.pin) {
if (sourceRequest?.source === "git" && opts.pin) {
runtime.error(
`--pin is not supported with git: installs. Pin the ref in the spec instead, for example ${formatCliCommand(`openclaw plugins install git:<repo>@<ref> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`,
);
return runtime.exit(1);
}
const npmPackPath = parseNpmPackPrefixPath(raw);
const clawhubSpec = parseClawHubPluginSpec(raw);
if (opts.link && sourceRequest?.source !== "local") {
runtime.error(
`--link requires a local path. Run ${formatCliCommand(`openclaw plugins install --link <path> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`,
);
return runtime.exit(1);
}
const requestResolution = resolvePluginInstallRequestContext({
rawSpec: raw,
marketplace: opts.marketplace,
@@ -853,21 +768,10 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return runtime.exit(1);
}
let request = requestResolution.request;
const resolved = request.resolvedPath ?? request.normalizedSpec;
const resolvesToLocalPath = fs.existsSync(resolved);
if (!resolvesToLocalPath && (gitSpec || npmPackPath !== null || clawhubSpec)) {
request = { ...request, installKind: "plugin" };
}
const bundledPreNpmPlan = resolvesToLocalPath
? null
: resolveBundledInstallPlanBeforeNpm({
rawSpec: raw,
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
const officialExternalPlan = resolvesToLocalPath
? null
: resolveCatalogOfficialExternalInstallPlan(raw);
if (bundledPreNpmPlan || officialExternalPlan) {
if (
sourceRequest &&
["npm-pack", "git", "clawhub", "bundled", "official"].includes(sourceRequest.source)
) {
request = { ...request, installKind: "plugin" };
}
const snapshot = await loadConfigForInstall(request).catch((error: unknown) => {
@@ -878,8 +782,6 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return runtime.exit(1);
}
const cfg = snapshot.config;
// For linked paths, --force confirms source provenance without changing copy/update mode.
const installMode = resolveInstallMode(opts.force && !opts.link);
const safetyOverrides = resolveInstallSafetyOverrides({ ...opts, config: cfg });
const extensionsDir = resolveDefaultPluginExtensionsDir();
const acknowledgeNonClawHubSource = async (
@@ -929,18 +831,24 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return;
}
if (fs.existsSync(resolved)) {
const bundledLocalSource = resolveArchiveKind(resolved)
? undefined
: findBundledPluginSource({ lookup: { kind: "localPath", value: resolved } });
if (
!bundledLocalSource &&
!(await acknowledgeNonClawHubSource(
resolveArchiveKind(resolved) ? "local-archive" : "local-path",
resolved,
))
) {
return runtime.exit(1);
if (!sourcePlan || !sourceRequest) {
runtime.error("Plugin install source could not be resolved.");
return runtime.exit(1);
}
if (
sourcePlan.acknowledgement &&
!(await acknowledgeNonClawHubSource(
sourcePlan.acknowledgement.sourceClass,
sourcePlan.acknowledgement.spec,
))
) {
return runtime.exit(1);
}
if (sourceRequest.source === "local") {
const resolved = sourceRequest.path;
if (sourceRequest.link) {
sourceRequest.successMessage = `Linked plugin path: ${shortenHomePath(resolved)}`;
}
const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(snapshot);
if (fullyBlockedReason) {
@@ -979,17 +887,14 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return runtime.exit(1);
}
}
if (opts.link) {
const existing = cfg.plugins?.load?.paths ?? [];
const merged = uniqueStrings([...existing, resolved]);
const probe = await installPluginFromPath({
...safetyOverrides,
mode: installMode,
path: resolved,
dryRun: true,
allowSourceTypeScriptEntries: true,
extensionsDir,
if (sourceRequest.link) {
const probe = await installManagedPluginSource({
request: sourceRequest,
snapshot,
safetyOverrides,
logger: createPluginInstallLogger(runtime),
invalidateRuntimeCache,
runtime,
});
if (!probe.ok) {
if (isTerminalPluginInstallFailure(probe.code)) {
@@ -1011,40 +916,16 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return runtime.exit(1);
}
await persistPluginInstall({
snapshot: {
...snapshot,
config: {
...cfg,
plugins: {
...cfg.plugins,
load: {
...cfg.plugins?.load,
paths: merged,
},
},
},
},
pluginId: probe.pluginId,
install: {
source: "path",
sourcePath: resolved,
installPath: resolved,
version: probe.version,
},
invalidateRuntimeCache,
successMessage: `Linked plugin path: ${shortenHomePath(resolved)}`,
runtime,
});
return;
}
const result = await installPluginFromPath({
...safetyOverrides,
mode: installMode,
path: resolved,
extensionsDir,
const result = await installManagedPluginSource({
request: sourceRequest,
snapshot,
safetyOverrides,
logger: createPluginInstallLogger(runtime),
invalidateRuntimeCache,
runtime,
});
if (!result.ok) {
if (isTerminalPluginInstallFailure(result.code)) {
@@ -1065,162 +946,73 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return runtime.exit(1);
}
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
await persistPluginInstall({
snapshot,
pluginId: result.pluginId,
install: {
source,
sourcePath: resolved,
installPath: result.targetDir,
version: result.version,
},
invalidateRuntimeCache,
runtime,
});
return;
}
if (opts.link) {
runtime.error(
`--link requires a local path. Run ${formatCliCommand(`openclaw plugins install --link <path> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`,
);
return runtime.exit(1);
}
const npmPrefixSpec = parseNpmPrefixSpec(raw);
if (npmPrefixSpec !== null) {
if (!npmPrefixSpec) {
runtime.error(
`Unsupported npm plugin spec: missing package. Use ${formatCliCommand(`openclaw plugins install npm:<package> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`,
);
return runtime.exit(1);
}
const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(npmPrefixSpec);
if (!trustedNpmInstall && !(await acknowledgeNonClawHubSource("npm", npmPrefixSpec))) {
return runtime.exit(1);
}
const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({
if (sourceRequest.source === "npm-pack") {
const npmPackResult = await installManagedPluginSource({
request: sourceRequest,
snapshot,
installMode,
spec: npmPrefixSpec,
pin: opts.pin,
safetyOverrides,
allowBundledFallback: false,
extensionsDir,
invalidateRuntimeCache,
...(trustedNpmInstall
? {
expectedPluginId: trustedNpmInstall.pluginId,
...(trustedNpmInstall.expectedIntegrity
? { expectedIntegrity: trustedNpmInstall.expectedIntegrity }
: {}),
trustedSourceLinkedOfficialInstall: true,
}
: {}),
runtime,
});
if (!npmPrefixResult.ok) {
return runtime.exit(1);
}
return;
}
if (npmPackPath !== null) {
if (!npmPackPath) {
runtime.error(
`Unsupported npm-pack plugin spec: missing archive path. Use ${formatCliCommand(`openclaw plugins install npm-pack:<path-to.tgz> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`,
);
return runtime.exit(1);
}
if (!(await acknowledgeNonClawHubSource("npm-pack", raw))) {
return runtime.exit(1);
}
const npmPackResult = await tryInstallPluginFromNpmPackArchive({
snapshot,
installMode,
archivePath: npmPackPath,
safetyOverrides,
extensionsDir,
logger: createPluginInstallLogger(runtime),
invalidateRuntimeCache,
runtime,
});
if (!npmPackResult.ok) {
runtime.error(npmPackResult.error);
return runtime.exit(1);
}
return;
}
if (gitSpec) {
if (!(await acknowledgeNonClawHubSource("git", raw))) {
return runtime.exit(1);
}
const gitResult = await tryInstallPluginFromGitSpec({
if (sourceRequest.source === "git") {
const gitResult = await installManagedPluginSource({
request: sourceRequest,
snapshot,
installMode,
spec: raw,
safetyOverrides,
extensionsDir,
logger: createPluginInstallLogger(runtime),
invalidateRuntimeCache,
runtime,
});
if (!gitResult.ok) {
runtime.error(gitResult.error);
return runtime.exit(1);
}
return;
}
if (
looksLikeLocalInstallSpec(raw, [
".ts",
".js",
".mjs",
".cjs",
".tgz",
".tar.gz",
".tar",
".zip",
])
) {
runtime.error(
`Plugin path not found: ${resolved}. Check the path, or install from npm with ${formatCliCommand(`openclaw plugins install npm:<package> ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)}.`,
);
return runtime.exit(1);
}
if (bundledPreNpmPlan) {
if (sourceRequest.source === "bundled") {
await tracePluginLifecyclePhaseAsync(
"install execution",
() =>
installBundledPluginSource({
installManagedPluginSource({
request: sourceRequest,
snapshot,
rawSpec: raw,
bundledSource: bundledPreNpmPlan.bundledSource,
warning: bundledPreNpmPlan.warning,
invalidateRuntimeCache,
runtime,
}),
{
command: "install",
source: "bundled",
pluginId: bundledPreNpmPlan.bundledSource.pluginId,
pluginId: sourceRequest.bundledSource.pluginId,
},
);
return;
}
if (officialExternalPlan) {
if (sourceRequest.source === "official") {
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
installMode,
spec: officialExternalPlan.npmSpec,
pin: opts.pin,
spec: sourceRequest.spec,
pin: sourceRequest.pin,
safetyOverrides,
allowBundledFallback: false,
extensionsDir,
expectedPluginId: officialExternalPlan.pluginId,
expectedIntegrity: officialExternalPlan.expectedIntegrity,
expectedPluginId: sourceRequest.pluginId,
expectedIntegrity: sourceRequest.expectedIntegrity,
trustedSourceLinkedOfficialInstall: true,
official: true,
invalidateRuntimeCache,
runtime,
});
@@ -1230,23 +1022,30 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return;
}
if (clawhubSpec) {
if (sourceRequest.source === "clawhub") {
const installFromClawHub = async (
installSnapshot = snapshot,
installSafetyOverrides = safetyOverrides,
) => {
const result = await installPluginFromClawHub({
...installSafetyOverrides,
...resolveClawHubRiskAcknowledgementCliOptions({
acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk,
action: "installing",
}),
mode: installMode,
spec: raw,
...(opts.expectedIntegrity ? { expectedIntegrity: opts.expectedIntegrity } : {}),
...(opts.expectedPluginId ? { expectedPluginId: opts.expectedPluginId } : {}),
extensionsDir,
const acknowledgement = resolveClawHubRiskAcknowledgementCliOptions({
acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk,
action: "installing",
});
const result = await installManagedPluginSource({
request: {
...sourceRequest,
...(opts.expectedIntegrity ? { expectedIntegrity: opts.expectedIntegrity } : {}),
...(opts.expectedPluginId ? { expectedPluginId: opts.expectedPluginId } : {}),
...(acknowledgement.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
...(acknowledgement.onClawHubRisk
? { onClawHubRisk: acknowledgement.onClawHubRisk }
: {}),
},
snapshot: installSnapshot,
safetyOverrides: installSafetyOverrides,
logger: createPluginInstallLogger(runtime),
invalidateRuntimeCache,
runtime,
});
if (!result.ok) {
if (!isClawHubBlockedCliFailure(result)) {
@@ -1254,18 +1053,11 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
}
return runtime.exit(1);
}
if (!result.clawhub) {
runtime.error("ClawHub plugin install completed without source metadata.");
return runtime.exit(1);
}
await persistPluginInstall({
snapshot: installSnapshot,
pluginId: result.pluginId,
install: {
...buildClawHubPluginInstallRecordFields(result.clawhub),
spec: raw,
installPath: result.targetDir,
},
invalidateRuntimeCache,
runtime,
});
if (!params.clawManaged && result.clawhub.version) {
markClawPackageIndependentlyOwned({
kind: "plugin",
@@ -1284,7 +1076,11 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
return await installFromClawHub();
}
return await withClawPackageLifecycleLease(
{ kind: "plugin", source: "clawhub", ref: clawhubSpec.name },
{
kind: "plugin",
source: "clawhub",
ref: parseClawHubPluginSpec(sourceRequest.spec)?.name ?? sourceRequest.spec,
},
async () => {
const leasedSnapshot = await loadConfigForInstall(request).catch((error: unknown) => {
runtime.error(formatErrorMessage(error));
@@ -1301,28 +1097,22 @@ async function runPluginInstallCommandUnlocked(params: RunPluginInstallCommandPa
);
}
const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(raw);
if (!trustedNpmInstall && !(await acknowledgeNonClawHubSource("npm", raw))) {
if (sourceRequest.source !== "npm") {
runtime.error("Unsupported plugin install source.");
return runtime.exit(1);
}
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
installMode,
spec: raw,
pin: opts.pin,
spec: sourceRequest.spec,
pin: sourceRequest.pin,
safetyOverrides,
allowBundledFallback: true,
allowBundledFallback: sourceRequest.allowBundledFallback ?? false,
extensionsDir,
invalidateRuntimeCache,
...(trustedNpmInstall
? {
expectedPluginId: trustedNpmInstall.pluginId,
...(trustedNpmInstall.expectedIntegrity
? { expectedIntegrity: trustedNpmInstall.expectedIntegrity }
: {}),
trustedSourceLinkedOfficialInstall: true,
}
: {}),
expectedPluginId: sourceRequest.expectedPluginId,
expectedIntegrity: sourceRequest.expectedIntegrity,
trustedSourceLinkedOfficialInstall: sourceRequest.trustedSourceLinkedOfficialInstall,
runtime,
});
if (!npmResult.ok) {
+1
View File
@@ -266,6 +266,7 @@ function createLegacyStateMigrationDetectionResult(params?: {
hasLegacy: false,
preview: [],
},
worktrees: { hasLegacy: false },
taskStateSidecars: {
taskRunsPath: "/tmp/state/tasks/runs.sqlite",
flowRunsPath: "/tmp/state/flows/registry.sqlite",
+31
View File
@@ -1,6 +1,10 @@
import os from "node:os";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
discardLegacyRegistryWorktrees,
hasLegacyRegistryWorktrees,
} from "../agents/worktrees/registry.js";
import { listBundledChannelLegacyStateMigrationDetectors } from "../channels/plugins/bundled.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin } from "../channels/plugins/registry.js";
@@ -34,6 +38,7 @@ import {
repairOpenClawStateDatabaseSchema,
type OpenClawStateDatabaseSchemaMigration,
} from "../state/openclaw-state-db.js";
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
import { acquireGatewayLock } from "./gateway-lock.js";
import {
detectLegacyAcpReplayLedger,
@@ -400,6 +405,12 @@ export async function detectLegacyStateMigrations(params: {
const stateSchemaMigrations = detectOpenClawStateDatabaseSchemaMigrations({
env: { ...env, OPENCLAW_STATE_DIR: stateDir },
});
const stateEnv = { ...env, OPENCLAW_STATE_DIR: stateDir };
const hasLegacyWorktrees =
params.doctorOnlyStateMigrations === true &&
stateSchemaMigrations.length === 0 &&
fileExists(resolveOpenClawStateSqlitePath(stateEnv)) &&
hasLegacyRegistryWorktrees(stateEnv);
const taskRunsSidecarPath = resolveLegacyTaskRunsSidecarPath(stateDir);
const flowRunsSidecarPath = resolveLegacyFlowRunsSidecarPath(stateDir);
const hasPendingTaskRunsSidecarArchive = hasPendingSqliteSidecarArchive(
@@ -631,6 +642,9 @@ export async function detectLegacyStateMigrations(params: {
"- Rerun doctor after shared SQLite schema repair to detect plugin state migrations",
);
}
if (hasLegacyWorktrees) {
preview.push("- Managed worktrees: discard rows without provisioned-file ledgers");
}
if (fileExists(taskRunsSidecarPath)) {
preview.push(`- Task registry sidecar: ${taskRunsSidecarPath} → shared SQLite state`);
} else if (hasPendingTaskRunsSidecarArchive) {
@@ -767,6 +781,7 @@ export async function detectLegacyStateMigrations(params: {
hasLegacy: stateSchemaMigrations.length > 0,
preview: stateSchemaMigrations.map((migration) => migration.path),
},
worktrees: { hasLegacy: hasLegacyWorktrees },
taskStateSidecars: {
taskRunsPath: taskRunsSidecarPath,
flowRunsPath: flowRunsSidecarPath,
@@ -1015,6 +1030,22 @@ export async function runLegacyStateMigrations(params: {
run: () => MigrationMessages | Promise<MigrationMessages>;
};
const steps: LegacyMigrationStep[] = [
{
run: () => {
const discardedWorktrees = detected.worktrees.hasLegacy
? discardLegacyRegistryWorktrees({ ...env, OPENCLAW_STATE_DIR: stateDir })
: 0;
return {
changes:
discardedWorktrees > 0
? [
`Discarded ${discardedWorktrees} legacy managed worktree ${discardedWorktrees === 1 ? "row" : "rows"}; affected worktrees will provision fresh on next use`,
]
: [],
warnings: [],
};
},
},
{ run: () => migrateLegacyPluginStateSidecar({ stateDir }) },
{ collectNotices: true, run: () => migrateLegacyInstalledPluginIndex({ stateDir }) },
{
+46
View File
@@ -1997,6 +1997,52 @@ describe("state migrations", () => {
);
});
it("doctor discards worktree rows that predate the provisioned-file ledger", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
const env = createEnv(stateDir);
const cfg = createConfig();
const db = openOpenClawStateDatabase({ env }).db;
db.prepare(
`INSERT INTO worktrees (
id, repo_fingerprint, repo_root, path, branch, base_ref, owner_kind,
created_at, last_active_at, provisioned_paths_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
).run(
"legacy-worktree",
"legacy-fingerprint",
path.join(root, "repo"),
path.join(stateDir, "worktrees", "legacy"),
"openclaw/legacy",
"HEAD",
"session",
1,
1,
);
const runtime = await detectLegacyStateMigrations({ cfg, env, homedir: () => root });
expect(runtime.preview).not.toContain(
"- Managed worktrees: discard rows without provisioned-file ledgers",
);
const detected = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
doctorOnlyStateMigrations: true,
});
expect(detected.preview).toContain(
"- Managed worktrees: discard rows without provisioned-file ledgers",
);
const result = await runLegacyStateMigrations({ detected, config: cfg, env });
expect(result.changes).toContain(
"Discarded 1 legacy managed worktree row; affected worktrees will provision fresh on next use",
);
expect(
db.prepare("SELECT id FROM worktrees WHERE id = ?").get("legacy-worktree"),
).toBeUndefined();
});
it("does not run plugin doctor migrations after shared state schema repair fails", async () => {
const root = await createTempDir();
const stateDir = path.join(root, ".openclaw");
+1
View File
@@ -69,6 +69,7 @@ export type LegacyStateDetection = {
hasLegacy: boolean;
preview: string[];
};
worktrees: { hasLegacy: boolean };
taskStateSidecars: {
taskRunsPath: string;
flowRunsPath: string;
@@ -1,93 +1,31 @@
// Loads agent tool result middleware from plugin runtime surfaces.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js";
import type {
AgentToolResultMiddleware,
AgentToolResultMiddlewareRuntime,
} from "./agent-tool-result-middleware-types.js";
import {
listAgentToolResultMiddlewares,
normalizeAgentToolResultMiddlewareRuntimeIds,
} from "./agent-tool-result-middleware.js";
import {
createPluginActivationSource,
normalizePluginsConfig,
resolveEffectivePluginActivationState,
type NormalizedPluginsConfig,
type PluginActivationConfigSource,
} from "./config-state.js";
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
import { listAgentToolResultMiddlewares } from "./agent-tool-result-middleware.js";
import { loadOpenClawPlugins } from "./loader.js";
import {
loadPluginManifestRegistry,
type PluginManifestRecord,
type PluginManifestRegistry,
} from "./manifest-registry.js";
import type { PluginRegistry } from "./registry-types.js";
import type { PluginAgentToolResultMiddlewareOwner, PluginRegistry } from "./registry-types.js";
import { getActivePluginRegistry } from "./runtime.js";
const log = createSubsystemLogger("plugins/agent-tool-result-middleware");
async function resolveRuntimeConfigContext(): Promise<{
config: OpenClawConfig;
activationSourceConfig: OpenClawConfig;
}> {
const { getRuntimeConfig, getRuntimeConfigSourceSnapshot } = await import("../config/config.js");
const config = getRuntimeConfig();
return {
config,
activationSourceConfig: getRuntimeConfigSourceSnapshot() ?? config,
};
}
function listMiddlewareOwnerPluginIds(params: {
manifestRegistry: PluginManifestRegistry;
function listMiddlewareOwners(params: {
registry: PluginRegistry | null;
runtime: AgentToolResultMiddlewareRuntime;
config: OpenClawConfig;
pluginsConfig: NormalizedPluginsConfig;
activationSource: PluginActivationConfigSource;
}): string[] {
const pluginIds: string[] = [];
for (const record of params.manifestRegistry.plugins) {
}): PluginAgentToolResultMiddlewareOwner[] {
const owners: PluginAgentToolResultMiddlewareOwner[] = [];
for (const owner of params.registry?.agentToolResultMiddlewareOwners ?? []) {
if (
!canLazyLoadMiddlewareOwner({
record,
config: params.config,
pluginsConfig: params.pluginsConfig,
activationSource: params.activationSource,
})
owner.runtimes.includes(params.runtime) &&
!owners.some((entry) => entry.pluginId === owner.pluginId)
) {
continue;
}
const runtimes = normalizeAgentToolResultMiddlewareRuntimeIds(
record.contracts?.agentToolResultMiddleware,
);
if (runtimes.includes(params.runtime) && !pluginIds.includes(record.id)) {
pluginIds.push(record.id);
owners.push(owner);
}
}
return pluginIds;
}
function canLazyLoadMiddlewareOwner(params: {
record: PluginManifestRecord;
config: OpenClawConfig;
pluginsConfig: NormalizedPluginsConfig;
activationSource: PluginActivationConfigSource;
}): boolean {
if (params.record.origin === "bundled") {
return true;
}
const activationState = resolveEffectivePluginActivationState({
id: params.record.id,
origin: params.record.origin,
config: params.pluginsConfig,
rootConfig: params.config,
enabledByDefault: isPluginEnabledByDefaultForPlatform(params.record),
activationSource: params.activationSource,
});
return activationState.enabled && activationState.explicitlyEnabled;
return owners;
}
function listRuntimeMiddlewareOwnerPluginIds(
@@ -103,12 +41,6 @@ function listRuntimeMiddlewareOwnerPluginIds(
return pluginIds;
}
function listActiveMiddlewareOwnerPluginIds(
runtime: AgentToolResultMiddlewareRuntime,
): Set<string> {
return listRuntimeMiddlewareOwnerPluginIds(getActivePluginRegistry(), runtime);
}
function registryHasMiddlewareOwners(params: {
registry: PluginRegistry | undefined;
pluginIds: readonly string[];
@@ -123,55 +55,27 @@ function registryHasMiddlewareOwners(params: {
export async function loadAgentToolResultMiddlewaresForRuntime(params: {
runtime: AgentToolResultMiddlewareRuntime;
config?: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
}): Promise<AgentToolResultMiddleware[]> {
const activeHandlers = listAgentToolResultMiddlewares(params.runtime);
try {
const runtimeContext = params.config
? { config: params.config, activationSourceConfig: params.config }
: await resolveRuntimeConfigContext();
const config = runtimeContext.config;
const activationSourceConfig =
params.activationSourceConfig ?? runtimeContext.activationSourceConfig;
const env = params.env ?? process.env;
const manifestRegistry =
params.manifestRegistry ??
loadPluginManifestRegistry({
config,
workspaceDir: params.workspaceDir,
env,
});
const pluginsConfig = normalizePluginsConfig(config.plugins);
const activationSourcePlugins = normalizePluginsConfig(activationSourceConfig.plugins);
const activationSource = createPluginActivationSource({
config: activationSourceConfig,
plugins: activationSourcePlugins,
});
const pluginIds = listMiddlewareOwnerPluginIds({
manifestRegistry,
const activeRegistry = getActivePluginRegistry();
const owners = listMiddlewareOwners({
registry: activeRegistry,
runtime: params.runtime,
config,
pluginsConfig,
activationSource,
});
if (pluginIds.length === 0) {
if (owners.length === 0) {
return activeHandlers;
}
const activePluginIds = listActiveMiddlewareOwnerPluginIds(params.runtime);
const missingPluginIds = pluginIds.filter((pluginId) => !activePluginIds.has(pluginId));
if (missingPluginIds.length === 0) {
const activePluginIds = listRuntimeMiddlewareOwnerPluginIds(activeRegistry, params.runtime);
const missingOwners = owners.filter((owner) => !activePluginIds.has(owner.pluginId));
if (missingOwners.length === 0) {
return activeHandlers;
}
const missingPluginIds = missingOwners.map((owner) => owner.pluginId);
const missingPluginIdSet = new Set(missingPluginIds);
const loadedRegistry = getLoadedRuntimePluginRegistry({
workspaceDir: params.workspaceDir,
env,
requiredPluginIds: missingPluginIds,
});
const runtimeRegistry =
@@ -183,11 +87,12 @@ export async function loadAgentToolResultMiddlewaresForRuntime(params: {
})
? loadedRegistry
: loadOpenClawPlugins({
config,
workspaceDir: params.workspaceDir,
env,
config: (await import("../config/config.js")).getRuntimeConfig(),
onlyPluginIds: missingPluginIds,
manifestRegistry,
manifestRegistry: {
plugins: missingOwners.map((owner) => owner.manifest),
diagnostics: [],
},
activate: false,
forceFullRuntimeForChannelPlugins: true,
});
+36
View File
@@ -1,4 +1,7 @@
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { normalizeAgentToolResultMiddlewareRuntimeIds } from "./agent-tool-result-middleware.js";
import { resolveEffectivePluginActivationState } from "./config-state.js";
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
import {
getReusableCachedPluginRegistry,
pluginLoaderCacheState,
@@ -129,6 +132,39 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
warningCacheKey: context.cacheKey,
suppliedManifestRegistry: options.manifestRegistry,
});
const selectedMiddlewareOwnerManifests = new Map<
string,
(typeof manifestRegistry.plugins)[number]
>();
for (const candidate of orderedCandidates) {
const record = manifestBySource.get(candidate.source);
if (record && !selectedMiddlewareOwnerManifests.has(record.id)) {
selectedMiddlewareOwnerManifests.set(record.id, record);
}
}
for (const record of selectedMiddlewareOwnerManifests.values()) {
const activation = resolveEffectivePluginActivationState({
id: record.id,
origin: record.origin,
config: context.normalized,
rootConfig: context.cfg,
enabledByDefault: isPluginEnabledByDefaultForPlatform(record),
activationSource: context.activationSource,
});
const runtimes = normalizeAgentToolResultMiddlewareRuntimeIds(
record.contracts?.agentToolResultMiddleware,
);
if (
runtimes.length > 0 &&
(record.origin === "bundled" || (activation.enabled && activation.explicitlyEnabled))
) {
registry.agentToolResultMiddlewareOwners.push({
pluginId: record.id,
runtimes,
manifest: record,
});
}
}
const memorySlot = context.normalized.slots.memory;
const state: PluginLoadLoopState = {
seenIds: new Map(),
@@ -574,11 +574,14 @@ describe("loadOpenClawPlugins", () => {
pluginId: "shadow",
bundledFilename: "shadow.cjs",
loadRegistry: () => {
writeBundledPlugin({
const bundled = writeBundledPlugin({
id: "shadow",
body: simplePluginBody("shadow"),
filename: "shadow.cjs",
});
updatePluginManifest(bundled.plugin, {
contracts: { agentToolResultMiddleware: ["codex"] },
});
const override = writePlugin({
id: "shadow",
@@ -599,7 +602,13 @@ describe("loadOpenClawPlugins", () => {
},
expectedLoadedOrigin: "config",
expectedDisabledOrigin: "bundled",
assert: expectPluginSourcePrecedence,
assert: (
registry: PluginRegistry,
scenario: Parameters<typeof expectPluginSourcePrecedence>[1],
) => {
expectPluginSourcePrecedence(registry, scenario);
expect(registry.agentToolResultMiddlewareOwners).toEqual([]);
},
},
{
label: "bundled beats auto-discovered global duplicate",
+342 -101
View File
@@ -1,6 +1,7 @@
// Structured plugin catalog and lifecycle operations shared by Gateway-facing surfaces.
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { collectChangedPaths } from "../config/config-change-paths.js";
import {
@@ -14,12 +15,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { formatErrorMessage } from "../infra/errors.js";
import { buildNpmResolutionFields, type NpmSpecResolution } from "../infra/install-source-utils.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import type { RuntimeEnv } from "../runtime.js";
import { installBundledPluginSource } from "./bundled-install.js";
import type { BundledPluginSource } from "./bundled-sources.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "./clawhub-error-codes.js";
import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
import {
buildClawHubPluginInstallRecordFields,
type ClawHubPluginInstallRecordFields,
} from "./clawhub-install-records.js";
import { installPluginFromClawHub } from "./clawhub.js";
import { enableExplicitlySelectedPluginInConfig } from "./enable.js";
import { installPluginFromGitSpec } from "./git-install.js";
import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
import {
resolveInstallConfigMutationPreflights,
@@ -28,14 +36,19 @@ import {
type ConfigSnapshotForInstallPersist,
} from "./install-persistence.js";
import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js";
import { installPluginFromNpmSpec } from "./install.js";
import type { InstallSafetyOverrides } from "./install-security-scan.js";
import type { PluginInstallLogger } from "./install-types.js";
import {
installPluginFromNpmPackArchive,
installPluginFromNpmSpec,
installPluginFromPath,
} from "./install.js";
import {
loadInstalledPluginIndexInstallRecords,
removePluginInstallRecordFromRecords,
withPluginInstallRecords,
withoutPluginInstallRecords,
} from "./installed-plugin-index-records.js";
import { buildNpmResolutionInstallFields } from "./installs.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type { PluginDiagnostic } from "./manifest-types.js";
import {
@@ -106,6 +119,78 @@ type ManagedPluginInstallRequest =
}
| { source: "official"; pluginId: string };
export type ManagedPluginSourceInstallRequest =
| {
source: "local";
path: string;
recordSource: "archive" | "path";
mode: "install" | "update";
link?: boolean;
successMessage?: string;
}
| {
source: "npm-pack";
archivePath: string;
mode: "install" | "update";
}
| { source: "git"; spec: string; mode: "install" | "update" }
| {
source: "clawhub";
spec: string;
mode?: "install" | "update";
expectedPluginId?: string;
expectedIntegrity?: string;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: NonNullable<Parameters<typeof installPluginFromClawHub>[0]["onClawHubRisk"]>;
}
| {
source: "bundled";
rawSpec: string;
bundledSource: BundledPluginSource;
warning?: string;
}
| {
source: "official";
spec: string;
pluginId: string;
expectedIntegrity?: string;
mode: "install" | "update";
pin?: boolean;
}
| {
source: "npm";
spec: string;
mode: "install" | "update";
pin?: boolean;
expectedPluginId?: string;
expectedIntegrity?: string;
trustedSourceLinkedOfficialInstall?: boolean;
allowBundledFallback?: boolean;
};
type ManagedPluginSourceInstallResult =
| {
ok: true;
pluginId: string;
config: OpenClawConfig;
warnings?: string[];
targetDir?: string;
version?: string;
npmResolution?: NpmSpecResolution;
clawhub?: ClawHubPluginInstallRecordFields;
}
| { ok: false; error: string; code?: string; version?: string; warning?: string };
type SourceInstallerResult =
| { ok: false; error: string; code?: string; version?: string; warning?: string }
| {
ok: true;
pluginId: string;
targetDir: string;
version?: string;
npmResolution?: NpmSpecResolution;
};
export class ManagedPluginLifecycleError extends Error {
readonly kind: "invalid-request" | "unavailable";
readonly code?: string;
@@ -966,45 +1051,254 @@ function throwPersistenceFailureWithCleanupWarnings(error: unknown, warnings: st
});
}
async function persistManagedPluginInstall(params: {
async function persistManagedSourceInstall(params: {
snapshot: ConfigSnapshotForInstallPersist;
pluginId: string;
install: PluginInstallRecord;
targetDir: string;
extensionsDir: string;
invalidateRuntimeCache?: boolean;
runtime?: RuntimeEnv;
successMessage?: string;
cleanupOnPersistenceFailure?: boolean;
}): Promise<OpenClawConfig> {
try {
return await persistPluginInstall({
const persist = () =>
persistPluginInstall({
snapshot: params.snapshot,
pluginId: params.pluginId,
install: params.install,
invalidateRuntimeCache: false,
runtime: createSilentRuntime(),
invalidateRuntimeCache: params.cleanupOnPersistenceFailure
? false
: params.invalidateRuntimeCache,
runtime: params.cleanupOnPersistenceFailure ? createSilentRuntime() : params.runtime,
...(params.successMessage ? { successMessage: params.successMessage } : {}),
});
if (!params.cleanupOnPersistenceFailure) {
return await persist();
}
try {
return await persist();
} catch (error) {
const cleanupWarnings = await cleanupFailedManagedPluginInstall({
pluginId: params.pluginId,
install: params.install,
targetDir: params.targetDir,
extensionsDir: params.extensionsDir,
});
return throwPersistenceFailureWithCleanupWarnings(error, cleanupWarnings);
const warnings = await cleanupFailedManagedPluginInstall(params);
return throwPersistenceFailureWithCleanupWarnings(error, warnings);
}
}
async function installFromClawHub(params: {
request: Extract<ManagedPluginInstallRequest, { source: "clawhub" }>;
/** Execute one resolved plugin source through the shared install-and-persist pipeline. */
export async function installManagedPluginSource(params: {
request: ManagedPluginSourceInstallRequest;
snapshot: ConfigSnapshotForInstallPersist;
env?: NodeJS.ProcessEnv;
logger?: PluginInstallLogger & { terminalLinks?: boolean };
safetyOverrides?: InstallSafetyOverrides;
runtime?: RuntimeEnv;
invalidateRuntimeCache?: boolean;
cleanupOnPersistenceFailure?: boolean;
}): Promise<ManagedPluginSourceInstallResult> {
const { request } = params;
const extensionsDir = resolveDefaultPluginExtensionsDir(params.env ?? process.env);
if (request.source === "bundled") {
const result = await installBundledPluginSource({
snapshot: params.snapshot,
rawSpec: request.rawSpec,
bundledSource: request.bundledSource,
warning: request.warning,
invalidateRuntimeCache: params.invalidateRuntimeCache,
runtime: params.runtime,
});
return {
ok: true,
...result,
config: params.snapshot.config,
};
}
const common = {
...params.safetyOverrides,
config: params.snapshot.config,
extensionsDir,
logger: params.logger,
};
const complete = async <T extends SourceInstallerResult>(
installResult: Promise<T>,
completed: {
install: (result: Extract<T, { ok: true }>) => PluginInstallRecord;
expectedPluginId?: string;
targetDir?: string;
snapshot?: ConfigSnapshotForInstallPersist;
successMessage?: string;
},
): Promise<ManagedPluginSourceInstallResult> => {
const result = await installResult;
if (!result.ok) {
return result;
}
const installed = result as Extract<T, { ok: true }> & {
pluginId: string;
targetDir: string;
};
if (completed.expectedPluginId && installed.pluginId !== completed.expectedPluginId) {
return {
ok: false as const,
error: `official catalog plugin id mismatch: expected ${completed.expectedPluginId}, got ${installed.pluginId}`,
};
}
const targetDir = completed.targetDir ?? installed.targetDir;
const config = await persistManagedSourceInstall({
...params,
snapshot: completed.snapshot ?? params.snapshot,
pluginId: installed.pluginId,
install: completed.install(installed),
targetDir,
extensionsDir,
successMessage: completed.successMessage,
});
return { ...installed, config };
};
if (request.source === "local") {
const installPath = request.link ? request.path : undefined;
const linkedSnapshot = request.link
? {
...params.snapshot,
config: {
...params.snapshot.config,
plugins: {
...params.snapshot.config.plugins,
load: {
...params.snapshot.config.plugins?.load,
paths: uniqueStrings([
...(params.snapshot.config.plugins?.load?.paths ?? []),
request.path,
]),
},
},
},
}
: params.snapshot;
return await complete(
installPluginFromPath({
...common,
path: request.path,
mode: request.mode,
...(request.link ? { dryRun: true, allowSourceTypeScriptEntries: true } : {}),
}),
{
snapshot: linkedSnapshot,
targetDir: installPath,
successMessage: request.successMessage,
install: (result) => ({
source: request.recordSource,
sourcePath: request.path,
installPath: installPath ?? result.targetDir,
version: result.version,
}),
},
);
}
if (request.source === "npm-pack") {
return await complete(
installPluginFromNpmPackArchive({
...common,
archivePath: request.archivePath,
mode: request.mode,
}),
{
install: (result) => ({
source: "npm",
spec: result.npmResolution?.resolvedSpec ?? result.manifestName ?? result.pluginId,
sourcePath: request.archivePath,
installPath: result.targetDir,
...(result.version ? { version: result.version } : {}),
...buildNpmResolutionFields(result.npmResolution),
artifactKind: "npm-pack",
artifactFormat: "tgz",
...(result.npmResolution?.integrity
? { npmIntegrity: result.npmResolution.integrity }
: {}),
...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}),
...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}),
}),
},
);
}
if (request.source === "git") {
return await complete(
installPluginFromGitSpec({ ...common, spec: request.spec, mode: request.mode }),
{
install: (result) => ({
source: "git",
spec: request.spec,
installPath: result.targetDir,
version: result.version,
resolvedAt: result.git.resolvedAt,
gitUrl: result.git.url,
gitRef: result.git.ref,
gitCommit: result.git.commit,
}),
},
);
}
if (request.source === "clawhub") {
return await complete(
installPluginFromClawHub({
...common,
spec: request.spec,
mode: request.mode,
...(request.expectedPluginId ? { expectedPluginId: request.expectedPluginId } : {}),
...(request.expectedIntegrity ? { expectedIntegrity: request.expectedIntegrity } : {}),
...(request.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
...(request.onClawHubRisk ? { onClawHubRisk: request.onClawHubRisk } : {}),
}),
{
expectedPluginId: request.expectedPluginId,
install: (result) => ({
...buildClawHubPluginInstallRecordFields(result.clawhub),
spec: request.spec,
installPath: result.targetDir,
}),
},
);
}
const expectedPluginId =
request.source === "official" ? request.pluginId : request.expectedPluginId;
return await complete(
installPluginFromNpmSpec({
...common,
spec: request.spec,
mode: request.mode,
...(request.source === "official" || request.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
...(expectedPluginId ? { expectedPluginId } : {}),
...(request.expectedIntegrity ? { expectedIntegrity: request.expectedIntegrity } : {}),
}),
{
expectedPluginId,
install: (result) => ({
source: "npm",
spec: request.pin ? (result.npmResolution?.resolvedSpec ?? request.spec) : request.spec,
installPath: result.targetDir,
...(result.version ? { version: result.version } : {}),
...buildNpmResolutionFields(result.npmResolution),
}),
},
);
}
function resolveManagedClawHubInstallRequest(params: {
request: Extract<ManagedPluginInstallRequest, { source: "clawhub" }>;
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
env: NodeJS.ProcessEnv;
warnings: string[];
expectedIntegrity?: string;
}): Promise<{ pluginId: string; config: OpenClawConfig }> {
}): Extract<ManagedPluginSourceInstallRequest, { source: "clawhub" }> {
const packageName = params.request.packageName.trim();
const official = resolveOfficialEntryByClawHubPackage(params.officialEntries, packageName);
// Pin the runtime id only when the catalog entry declares one; the entry-id
// fallback is just the package name and would reject legitimate installs,
// while a declared id must stay enforced even if it equals the package name.
// fallback is just the package name and would reject legitimate installs.
const expectedPluginId = official ? resolveDeclaredOfficialPluginId(official) : undefined;
const hostedOfficial = resolveHostedOfficialEntryByClawHubPackage(
params.officialEntries,
@@ -1016,52 +1310,24 @@ async function installFromClawHub(params: {
const hostedClawHub = parseClawHubPluginSpec(hostedInstall?.clawhubSpec ?? "");
const requestMatchesHostedCandidate =
!params.request.version || params.request.version === hostedClawHub?.version;
const version =
params.request.version ?? (requestMatchesHostedCandidate ? hostedClawHub?.version : undefined);
const expectedIntegrity =
params.expectedIntegrity ??
(requestMatchesHostedCandidate ? hostedInstall?.expectedIntegrity : undefined);
const version =
params.request.version ?? (requestMatchesHostedCandidate ? hostedClawHub?.version : undefined);
const spec = buildClawHubSpec(packageName, version);
const extensionsDir = resolveDefaultPluginExtensionsDir(params.env);
const result = await installPluginFromClawHub({
spec,
config: params.snapshot.config,
extensionsDir,
logger: createInstallLogger(params.warnings),
return {
source: "clawhub",
spec: buildClawHubSpec(packageName, version),
...(expectedPluginId ? { expectedPluginId } : {}),
...(expectedIntegrity ? { expectedIntegrity } : {}),
...(params.request.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
});
if (!result.ok) {
return throwInstallFailure(result);
}
if (expectedPluginId && result.pluginId !== expectedPluginId) {
throw new ManagedPluginLifecycleError(
`official catalog plugin id mismatch: expected ${expectedPluginId}, got ${result.pluginId}`,
);
}
const install: PluginInstallRecord = {
...buildClawHubPluginInstallRecordFields(result.clawhub),
spec,
installPath: result.targetDir,
};
const config = await persistManagedPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install,
targetDir: result.targetDir,
extensionsDir,
});
return { pluginId: result.pluginId, config };
}
async function installFromOfficialCatalog(params: {
function resolveManagedOfficialInstallRequest(params: {
request: Extract<ManagedPluginInstallRequest, { source: "official" }>;
snapshot: ConfigSnapshotForInstallPersist;
officialEntries: readonly OfficialExternalPluginCatalogEntry[];
env: NodeJS.ProcessEnv;
warnings: string[];
}): Promise<{ pluginId: string; config: OpenClawConfig }> {
}): ManagedPluginSourceInstallRequest {
const entry = resolveOfficialEntryById(params.officialEntries, params.request.pluginId);
if (!entry) {
throw new ManagedPluginLifecycleError(
@@ -1077,16 +1343,13 @@ async function installFromOfficialCatalog(params: {
}
const clawhub = install.clawhubSpec ? parseClawHubPluginSpec(install.clawhubSpec) : undefined;
if (clawhub) {
return await installFromClawHub({
return resolveManagedClawHubInstallRequest({
request: {
source: "clawhub",
packageName: clawhub.name,
...(clawhub.version ? { version: clawhub.version } : {}),
},
snapshot: params.snapshot,
officialEntries: params.officialEntries,
env: params.env,
warnings: params.warnings,
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
});
}
@@ -1095,39 +1358,13 @@ async function installFromOfficialCatalog(params: {
`official plugin catalog entry has no supported install source: ${params.request.pluginId}`,
);
}
const extensionsDir = resolveDefaultPluginExtensionsDir(params.env);
const result = await installPluginFromNpmSpec({
return {
source: "official",
spec: install.npmSpec,
config: params.snapshot.config,
extensionsDir,
expectedPluginId: pluginId,
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
trustedSourceLinkedOfficialInstall: true,
logger: createInstallLogger(params.warnings),
});
if (!result.ok) {
return throwInstallFailure(result);
}
if (result.pluginId !== pluginId) {
throw new ManagedPluginLifecycleError(
`official catalog plugin id mismatch: expected ${pluginId}, got ${result.pluginId}`,
);
}
const installRecord: PluginInstallRecord = {
source: "npm",
spec: install.npmSpec,
installPath: result.targetDir,
...(result.version ? { version: result.version } : {}),
...buildNpmResolutionInstallFields(result.npmResolution),
};
const config = await persistManagedPluginInstall({
snapshot: params.snapshot,
pluginId,
install: installRecord,
targetDir: result.targetDir,
extensionsDir,
});
return { pluginId, config };
mode: "install",
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
};
}
/** Install a ClawHub or curated official plugin through the canonical install pipeline. */
@@ -1140,22 +1377,26 @@ export async function installManagedPlugin(params: {
const snapshot = await readPluginMutationSnapshot(env);
const officialCatalog = await loadOfficialCatalog();
const warnings: string[] = [];
const installed =
const request =
params.request.source === "clawhub"
? await installFromClawHub({
? resolveManagedClawHubInstallRequest({
request: params.request,
snapshot,
officialEntries: officialCatalog.entries,
env,
warnings,
})
: await installFromOfficialCatalog({
: resolveManagedOfficialInstallRequest({
request: params.request,
snapshot,
officialEntries: officialCatalog.entries,
env,
warnings,
});
const installed = await installManagedPluginSource({
request,
snapshot,
env,
logger: createInstallLogger(warnings),
cleanupOnPersistenceFailure: true,
});
if (!installed.ok) {
return throwInstallFailure(installed);
}
const catalog = await listManagedPlugins({
config: installed.config,
env,
+1
View File
@@ -28,6 +28,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
workerProviders: new Map(),
migrationProviders: [],
codexAppServerExtensionFactories: [],
agentToolResultMiddlewareOwners: [],
agentToolResultMiddlewares: [],
memoryEmbeddingProviders: [],
agentHarnesses: [],
+7
View File
@@ -22,6 +22,7 @@ import type {
PluginToolMetadataRegistration,
PluginTrustedToolPolicyRegistration,
} from "./host-hooks.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type {
PluginBundleFormat,
PluginConfigUiHint,
@@ -256,6 +257,11 @@ export type PluginAgentToolResultMiddlewareRegistration = {
source: string;
rootDir?: string;
};
export type PluginAgentToolResultMiddlewareOwner = {
pluginId: string;
runtimes: AgentToolResultMiddlewareRuntime[];
manifest: PluginManifestRecord;
};
type PluginAgentHarnessRegistration = {
pluginId: string;
pluginName?: string;
@@ -502,6 +508,7 @@ export type PluginRegistry = {
workerProviders: Map<string, PluginWorkerProviderRegistration>;
migrationProviders: PluginMigrationProviderRegistration[];
codexAppServerExtensionFactories: PluginCodexAppServerExtensionFactoryRegistration[];
agentToolResultMiddlewareOwners: PluginAgentToolResultMiddlewareOwner[];
agentToolResultMiddlewares: PluginAgentToolResultMiddlewareRegistration[];
memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[];
agentHarnesses: PluginAgentHarnessRegistration[];
@@ -10,14 +10,15 @@ describe("runtime web channel plugin", () => {
it("resolves the default auth dir through the light runtime on each call", async () => {
let authDir = "/tmp/openclaw-default-auth";
const resolveDefaultWebAuthDir = vi.fn(() => authDir);
const resolvePluginRuntimeRecordByEntryBaseNames = vi.fn(() => ({
origin: "bundled",
source: "test",
}));
vi.doMock("./runtime-plugin-boundary.js", () => ({
loadPluginBoundaryModule: () => ({ resolveDefaultWebAuthDir }),
resolvePluginRuntimeModulePath: () => "/tmp/light-runtime-api.js",
resolvePluginRuntimeRecordByEntryBaseNames: () => ({
origin: "bundled",
source: "test",
}),
resolvePluginRuntimeRecordByEntryBaseNames,
}));
const { resolveWebChannelAuthDir } = await import("./runtime-web-channel-plugin.js");
@@ -26,6 +27,45 @@ describe("runtime web channel plugin", () => {
authDir = "/tmp/openclaw-profile-auth";
expect(resolveWebChannelAuthDir()).toBe("/tmp/openclaw-profile-auth");
expect(resolveDefaultWebAuthDir).toHaveBeenCalledTimes(2);
expect(resolvePluginRuntimeRecordByEntryBaseNames).toHaveBeenCalledOnce();
});
it("reuses the prepared heavy runtime before resolving plugin metadata again", async () => {
const extractText = vi.fn((value: string) => value);
const startWebLoginWithQr = vi.fn(async () => "started");
const resolvePluginRuntimeRecordByEntryBaseNames = vi.fn(() => ({
origin: "bundled",
source: "test",
}));
vi.doMock("./runtime-plugin-boundary.js", () => ({
loadPluginBoundaryModule: () => ({ extractText, startWebLoginWithQr }),
resolvePluginRuntimeModulePath: () => "/tmp/runtime-api.js",
resolvePluginRuntimeRecordByEntryBaseNames,
}));
const runtime = await import("./runtime-web-channel-plugin.js");
expect(runtime.extractText("first")).toBe("first");
expect(runtime.extractText("second")).toBe("second");
await expect(runtime.startWebLoginWithQr()).resolves.toBe("started");
expect(resolvePluginRuntimeRecordByEntryBaseNames).toHaveBeenCalledOnce();
});
it("reports heavy runtime load failures as promise rejections", async () => {
vi.doMock("./runtime-plugin-boundary.js", () => ({
loadPluginBoundaryModule: () => {
throw new Error("runtime unavailable");
},
resolvePluginRuntimeModulePath: () => "/tmp/runtime-api.js",
resolvePluginRuntimeRecordByEntryBaseNames: () => ({
origin: "bundled",
source: "test",
}),
}));
const runtime = await import("./runtime-web-channel-plugin.js");
await expect(runtime.loginWeb(false)).rejects.toThrow("runtime unavailable");
await expect(runtime.monitorWebChannel()).rejects.toThrow("runtime unavailable");
});
it("falls back to the older WhatsApp light runtime auth dir export", async () => {
@@ -5,6 +5,7 @@ import {
loadWebMediaRaw as loadWebMediaRawImpl,
optimizeImageToJpeg as optimizeImageToJpegImpl,
} from "../../media/web-media.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugin-metadata-lifecycle.js";
import {
createPluginModuleLoaderCache,
type PluginModuleLoaderCache,
@@ -59,7 +60,6 @@ type WebChannelHeavyRuntimeModule = {
type WebChannelRuntimeModuleKind = "heavy" | "light";
type CachedWebChannelRuntimeModule = {
modulePath: string;
module: WebChannelHeavyRuntimeModule | WebChannelLightRuntimeModule;
};
@@ -70,6 +70,11 @@ const webChannelRuntimeModuleCache = new Map<
const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache();
registerPluginMetadataProcessMemoLifecycleClear(() => {
webChannelRuntimeModuleCache.clear();
moduleLoaders.clear();
});
/** Resolves the active web-channel plugin record that provides runtime APIs. */
function resolveWebChannelPluginRecord(): WebChannelPluginRecord {
return resolvePluginRuntimeRecordByEntryBaseNames(["light-runtime-api", "runtime-api"], () => {
@@ -92,46 +97,41 @@ function resolveWebChannelRuntimeModulePath(
return modulePath;
}
function loadCurrentHeavyModuleSync(): WebChannelHeavyRuntimeModule {
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api");
return loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, moduleLoaders, {
origin: record.origin,
});
}
function getCachedWebChannelRuntimeModule<T extends CachedWebChannelRuntimeModule["module"]>(
kind: WebChannelRuntimeModuleKind,
modulePath: string,
load: () => T,
): T {
const cached = webChannelRuntimeModuleCache.get(kind);
if (cached?.modulePath === modulePath) {
if (cached) {
return cached.module as T;
}
const loaded = load();
webChannelRuntimeModuleCache.set(kind, { modulePath, module: loaded });
webChannelRuntimeModuleCache.set(kind, { module: loaded });
return loaded;
}
function loadWebChannelLightModule(): WebChannelLightRuntimeModule {
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "light-runtime-api");
return getCachedWebChannelRuntimeModule("light", modulePath, () =>
loadPluginBoundaryModule<WebChannelLightRuntimeModule>(modulePath, moduleLoaders, {
return getCachedWebChannelRuntimeModule("light", () => {
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "light-runtime-api");
return loadPluginBoundaryModule<WebChannelLightRuntimeModule>(modulePath, moduleLoaders, {
origin: record.origin,
}),
);
});
});
}
function loadWebChannelHeavyModuleSync(): WebChannelHeavyRuntimeModule {
return getCachedWebChannelRuntimeModule("heavy", () => {
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api");
return loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, moduleLoaders, {
origin: record.origin,
});
});
}
async function loadWebChannelHeavyModule(): Promise<WebChannelHeavyRuntimeModule> {
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api");
return getCachedWebChannelRuntimeModule("heavy", modulePath, () =>
loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, moduleLoaders, {
origin: record.origin,
}),
);
return loadWebChannelHeavyModuleSync();
}
function getLightExport<K extends keyof WebChannelLightRuntimeModule>(
@@ -287,7 +287,7 @@ export async function waitForWebLogin(
/** Extracts text through the heavy runtime API. */
export const extractText = (...args: Parameters<WebChannelHeavyRuntimeModule["extractText"]>) =>
loadCurrentHeavyModuleSync().extractText(...args);
loadWebChannelHeavyModuleSync().extractText(...args);
/** Returns default local media roots through the core media helper. */
export function getDefaultLocalRoots(