mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
0fdc825b29
commit
89db948bb6
@@ -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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", "/*");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) },
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
workerProviders: new Map(),
|
||||
migrationProviders: [],
|
||||
codexAppServerExtensionFactories: [],
|
||||
agentToolResultMiddlewareOwners: [],
|
||||
agentToolResultMiddlewares: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user