From 89db948bb646d3d374c979d481bb3c6faf38b5dc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 19:34:01 -0400 Subject: [PATCH] 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 --- .../codex-app-server.extensions.test.ts | 43 +- src/agents/worktrees/registry.test.ts | 8 +- src/agents/worktrees/registry.ts | 50 +- .../worktrees/service.provisioned.test.ts | 31 +- src/agents/worktrees/service.ts | 20 +- .../reply/commands-plugins-install.ts | 332 ++---------- src/auto-reply/reply/commands-plugins.ts | 1 - src/cli/npm-resolution.test.ts | 32 +- src/cli/npm-resolution.ts | 2 +- src/cli/plugin-install-plan.test.ts | 105 +--- src/cli/plugin-install-plan.ts | 167 +++++- src/cli/plugins-install-command.ts | 512 ++++++------------ src/commands/doctor.e2e-harness.ts | 1 + src/infra/state-migrations.doctor.ts | 31 ++ src/infra/state-migrations.test.ts | 46 ++ src/infra/state-migrations.types.ts | 1 + .../agent-tool-result-middleware-loader.ts | 143 +---- src/plugins/loader-runtime-load.ts | 36 ++ ...oader.discovery-and-security.test-utils.ts | 13 +- src/plugins/management-service.ts | 443 +++++++++++---- src/plugins/registry-empty.ts | 1 + src/plugins/registry-types.ts | 7 + .../runtime-web-channel-plugin.test.ts | 48 +- .../runtime/runtime-web-channel-plugin.ts | 52 +- 24 files changed, 1003 insertions(+), 1122 deletions(-) diff --git a/src/agents/codex-app-server.extensions.test.ts b/src/agents/codex-app-server.extensions.test.ts index 2d9358627749..41763666be71 100644 --- a/src/agents/codex-app-server.extensions.test.ts +++ b/src/agents/codex-app-server.extensions.test.ts @@ -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); diff --git a/src/agents/worktrees/registry.test.ts b/src/agents/worktrees/registry.test.ts index 6761db9ce0a0..b6546f0682bf 100644 --- a/src/agents/worktrees/registry.test.ts +++ b/src/agents/worktrees/registry.test.ts @@ -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", () => { diff --git a/src/agents/worktrees/registry.ts b/src/agents/worktrees/registry.ts index 27325c550bd3..2aa0b9296998 100644 --- a/src/agents/worktrees/registry.ts +++ b/src/agents/worktrees/registry.ts @@ -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( diff --git a/src/agents/worktrees/service.provisioned.test.ts b/src/agents/worktrees/service.provisioned.test.ts index 56f1e8e450f9..34837b6bc80c 100644 --- a/src/agents/worktrees/service.provisioned.test.ts +++ b/src/agents/worktrees/service.provisioned.test.ts @@ -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", "/*"); diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index 97c424e9af28..16171b80f342 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -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 diff --git a/src/auto-reply/reply/commands-plugins-install.ts b/src/auto-reply/reply/commands-plugins-install.ts index 9d908ba9b533..46456776f7f9 100644 --- a/src/auto-reply/reply/commands-plugins-install.ts +++ b/src/auto-reply/reply/commands-plugins-install.ts @@ -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 } : {}), }; } diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 12dfc9bf9be4..d58c72c681b0 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -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) { diff --git a/src/cli/npm-resolution.test.ts b/src/cli/npm-resolution.test.ts index 5695d5664d1b..3f30750e8809 100644 --- a/src/cli/npm-resolution.test.ts +++ b/src/cli/npm-resolution.test.ts @@ -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( diff --git a/src/cli/npm-resolution.ts b/src/cli/npm-resolution.ts index 6f3dca516013..1c918e0a98b8 100644 --- a/src/cli/npm-resolution.ts +++ b/src/cli/npm-resolution.ts @@ -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; diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts index ad06073a4217..47c6f2f15660 100644 --- a/src/cli/plugin-install-plan.test.ts +++ b/src/cli/plugin-install-plan.test.ts @@ -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", () => { diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts index e2fceb653c39..3573c5687987 100644 --- a/src/cli/plugin-install-plan.ts +++ b/src/cli/plugin-install-plan.ts @@ -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 { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index a4a1d4cc2a37..6820e560d8a5 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -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:@ ${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:@ ${NON_CLAWHUB_INSTALL_FORCE_FLAG}`)} for Git installs or ${formatCliCommand(`openclaw plugins install --link ${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:@ ${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 ${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 ${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: ${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: ${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: ${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) { diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index f73529fb5946..21d97a904f3f 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -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", diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 4ef6998dab03..beba76aaf9e3 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -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; }; 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 }) }, { diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 35b08b1bce4a..7c51abc24da7 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -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"); diff --git a/src/infra/state-migrations.types.ts b/src/infra/state-migrations.types.ts index 17755a6f7007..97c82138601f 100644 --- a/src/infra/state-migrations.types.ts +++ b/src/infra/state-migrations.types.ts @@ -69,6 +69,7 @@ export type LegacyStateDetection = { hasLegacy: boolean; preview: string[]; }; + worktrees: { hasLegacy: boolean }; taskStateSidecars: { taskRunsPath: string; flowRunsPath: string; diff --git a/src/plugins/agent-tool-result-middleware-loader.ts b/src/plugins/agent-tool-result-middleware-loader.ts index 5cff1921936f..8db952244f5e 100644 --- a/src/plugins/agent-tool-result-middleware-loader.ts +++ b/src/plugins/agent-tool-result-middleware-loader.ts @@ -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 { - 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 { 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, }); diff --git a/src/plugins/loader-runtime-load.ts b/src/plugins/loader-runtime-load.ts index 31cdc86001aa..1d33ca2551f5 100644 --- a/src/plugins/loader-runtime-load.ts +++ b/src/plugins/loader-runtime-load.ts @@ -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(), diff --git a/src/plugins/loader.discovery-and-security.test-utils.ts b/src/plugins/loader.discovery-and-security.test-utils.ts index 4bd67407370a..7c40fbbb9358 100644 --- a/src/plugins/loader.discovery-and-security.test-utils.ts +++ b/src/plugins/loader.discovery-and-security.test-utils.ts @@ -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[1], + ) => { + expectPluginSourcePrecedence(registry, scenario); + expect(registry.agentToolResultMiddlewareOwners).toEqual([]); + }, }, { label: "bundled beats auto-discovered global duplicate", diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 3924435b0633..e31273df6579 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -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[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 { - 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; +/** 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 { + 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 ( + installResult: Promise, + completed: { + install: (result: Extract) => PluginInstallRecord; + expectedPluginId?: string; + targetDir?: string; + snapshot?: ConfigSnapshotForInstallPersist; + successMessage?: string; + }, + ): Promise => { + const result = await installResult; + if (!result.ok) { + return result; + } + const installed = result as Extract & { + 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; officialEntries: readonly OfficialExternalPluginCatalogEntry[]; - env: NodeJS.ProcessEnv; - warnings: string[]; expectedIntegrity?: string; -}): Promise<{ pluginId: string; config: OpenClawConfig }> { +}): Extract { 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; - 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, diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index ddc5c3af2d87..6fc30d3028ae 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -28,6 +28,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { workerProviders: new Map(), migrationProviders: [], codexAppServerExtensionFactories: [], + agentToolResultMiddlewareOwners: [], agentToolResultMiddlewares: [], memoryEmbeddingProviders: [], agentHarnesses: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 798e911915d4..be4af4f49762 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -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; migrationProviders: PluginMigrationProviderRegistration[]; codexAppServerExtensionFactories: PluginCodexAppServerExtensionFactoryRegistration[]; + agentToolResultMiddlewareOwners: PluginAgentToolResultMiddlewareOwner[]; agentToolResultMiddlewares: PluginAgentToolResultMiddlewareRegistration[]; memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; agentHarnesses: PluginAgentHarnessRegistration[]; diff --git a/src/plugins/runtime/runtime-web-channel-plugin.test.ts b/src/plugins/runtime/runtime-web-channel-plugin.test.ts index c5f94f272e9a..ad2cf3c23e2a 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.test.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.test.ts @@ -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 () => { diff --git a/src/plugins/runtime/runtime-web-channel-plugin.ts b/src/plugins/runtime/runtime-web-channel-plugin.ts index 491882277c6f..c707ad367f84 100644 --- a/src/plugins/runtime/runtime-web-channel-plugin.ts +++ b/src/plugins/runtime/runtime-web-channel-plugin.ts @@ -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(modulePath, moduleLoaders, { - origin: record.origin, - }); -} - function getCachedWebChannelRuntimeModule( 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(modulePath, moduleLoaders, { + return getCachedWebChannelRuntimeModule("light", () => { + const record = resolveWebChannelPluginRecord(); + const modulePath = resolveWebChannelRuntimeModulePath(record, "light-runtime-api"); + return loadPluginBoundaryModule(modulePath, moduleLoaders, { origin: record.origin, - }), - ); + }); + }); +} + +function loadWebChannelHeavyModuleSync(): WebChannelHeavyRuntimeModule { + return getCachedWebChannelRuntimeModule("heavy", () => { + const record = resolveWebChannelPluginRecord(); + const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api"); + return loadPluginBoundaryModule(modulePath, moduleLoaders, { + origin: record.origin, + }); + }); } async function loadWebChannelHeavyModule(): Promise { - const record = resolveWebChannelPluginRecord(); - const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api"); - return getCachedWebChannelRuntimeModule("heavy", modulePath, () => - loadPluginBoundaryModule(modulePath, moduleLoaders, { - origin: record.origin, - }), - ); + return loadWebChannelHeavyModuleSync(); } function getLightExport( @@ -287,7 +287,7 @@ export async function waitForWebLogin( /** Extracts text through the heavy runtime API. */ export const extractText = (...args: Parameters) => - loadCurrentHeavyModuleSync().extractText(...args); + loadWebChannelHeavyModuleSync().extractText(...args); /** Returns default local media roots through the core media helper. */ export function getDefaultLocalRoots(