From 96e4ab2e9a4e8c25b3f54ea8efbf7dd40d523ba8 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:11:36 +1000 Subject: [PATCH] feat(plugins): allow forced chat installs --- docs/tools/slash-commands.md | 17 +- .../reply/commands-plugins.install.test.ts | 295 +++++++++++++++++- src/auto-reply/reply/commands-plugins.ts | 143 ++++++++- src/auto-reply/reply/plugins-commands.ts | 12 +- 4 files changed, 437 insertions(+), 30 deletions(-) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 6fd9fc8cbb40..6b260c441132 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -437,16 +437,21 @@ chat. /plugins disable context7 /plugins install clawhub: /plugins install npm:@openclaw/ +/plugins install npm: --force +/plugins install git:@ --force ``` `/plugins enable|disable` updates plugin config and hot-reloads the Gateway plugin runtime for new agent turns. `/plugins install` restarts managed -Gateways automatically because plugin source modules changed. Chat installs are -limited to ClawHub and packages matched by OpenClaw's official catalog. Install -arbitrary npm, git, archive, marketplace, or local path sources from a trusted -local shell after reviewing the source and confirming the interactive warning. -For noninteractive installs, also pass -`--force`. +Gateways automatically because plugin source modules changed. Trusted ClawHub +and official-catalog installs do not need extra acknowledgement. Arbitrary npm, +git, archive, `npm-pack:`, and local path sources show a provenance warning and +require a trailing `--force` after you review the source. This flag acknowledges +the source and permits replacement of an existing install; it does not bypass +`security.installPolicy` or installer security checks. ClawHub releases with +risk warnings still require the separate shell-only +`--acknowledge-clawhub-risk` flag. Marketplace, linked, and pinned installs also +remain shell-only. ## `/trace`: plugin trace output diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index 4769ea1488e2..5422aa5cd9f6 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -10,12 +10,14 @@ import { handlePluginsCommand } from "./commands-plugins.js"; import { buildPluginsCommandParams } from "./commands.test-harness.js"; const { + installPluginFromNpmPackArchiveMock, installPluginFromNpmSpecMock, installPluginFromPathMock, installPluginFromClawHubMock, installPluginFromGitSpecMock, persistPluginInstallMock, } = vi.hoisted(() => ({ + installPluginFromNpmPackArchiveMock: vi.fn(), installPluginFromNpmSpecMock: vi.fn(), installPluginFromPathMock: vi.fn(), installPluginFromClawHubMock: vi.fn(), @@ -29,6 +31,7 @@ vi.mock("../../plugins/install.js", async () => { ); return { ...actual, + installPluginFromNpmPackArchive: installPluginFromNpmPackArchiveMock, installPluginFromNpmSpec: installPluginFromNpmSpecMock, installPluginFromPath: installPluginFromPathMock, }; @@ -113,8 +116,9 @@ function expectNonClawHubChatInstallRejected( expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain(expectedSource); expect(result.reply?.text).toContain("outside ClawHub review"); - expect(result.reply?.text).toContain("cannot acknowledge non-ClawHub install provenance"); + expect(result.reply?.text).toContain("rerun this chat command with --force"); expect(result.reply?.text).toContain("--force"); + expect(installPluginFromNpmPackArchiveMock).not.toHaveBeenCalled(); expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); expect(installPluginFromPathMock).not.toHaveBeenCalled(); expect(installPluginFromClawHubMock).not.toHaveBeenCalled(); @@ -124,6 +128,7 @@ function expectNonClawHubChatInstallRejected( describe("handleCommands /plugins install", () => { afterEach(async () => { + installPluginFromNpmPackArchiveMock.mockReset(); installPluginFromNpmSpecMock.mockReset(); installPluginFromPathMock.mockReset(); installPluginFromClawHubMock.mockReset(); @@ -149,6 +154,69 @@ describe("handleCommands /plugins install", () => { }); }); + it("installs an arbitrary npm package after a trailing --force acknowledgement", async () => { + const policyConfig: OpenClawConfig = { + commands: { text: true, plugins: true }, + plugins: { enabled: true }, + security: { + installPolicy: { + enabled: true, + exec: { + source: "exec", + command: process.execPath, + args: ["-e", "process.exit(1)"], + allowInsecurePath: true, + }, + }, + }, + }; + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "policy-plugin", + targetDir: "/tmp/policy-plugin", + version: "1.0.0", + extensions: ["index.js"], + npmResolution: { + name: "@acme/policy-plugin", + version: "1.0.0", + resolvedSpec: "@acme/policy-plugin@1.0.0", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async (home) => { + await fs.writeFile( + path.join(home, ".openclaw", "openclaw.json"), + `${JSON.stringify(policyConfig, null, 2)}\n`, + ); + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install @acme/policy-plugin@1.0.0 --force", + workspaceDir, + { cfg: policyConfig }, + ); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "policy-plugin"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + const installParams = mockFirstObjectArg(installPluginFromNpmSpecMock); + expectObjectFields(installParams, { + spec: "@acme/policy-plugin@1.0.0", + config: policyConfig, + mode: "update", + }); + expect(installParams).not.toHaveProperty("expectedPluginId"); + expect(installParams).not.toHaveProperty("trustedSourceLinkedOfficialInstall"); + expectPersistedInstall("policy-plugin", { + source: "npm", + spec: "@acme/policy-plugin@1.0.0", + installPath: "/tmp/policy-plugin", + version: "1.0.0", + }); + }); + }); + it("allows npm packages matched by the official catalog", async () => { const policyConfig: OpenClawConfig = { commands: { text: true, plugins: true }, @@ -311,6 +379,56 @@ describe("handleCommands /plugins install", () => { }); }); + it("installs an npm-pack archive after a trailing --force acknowledgement", async () => { + installPluginFromNpmPackArchiveMock.mockResolvedValue({ + ok: true, + pluginId: "packed-demo", + targetDir: "/tmp/packed-demo", + manifestName: "@acme/packed-demo", + version: "1.2.3", + extensions: ["index.js"], + npmTarballName: "acme-packed-demo-1.2.3.tgz", + npmResolution: { + name: "@acme/packed-demo", + version: "1.2.3", + resolvedSpec: "@acme/packed-demo@1.2.3", + integrity: "sha512-packed", + shasum: "a".repeat(40), + resolvedAt: "2026-07-14T00:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const archivePath = "/tmp/packed-demo.tgz"; + const params = buildPluginsParams( + `/plugins install npm-pack:${archivePath} --force`, + workspaceDir, + ); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "packed-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromNpmPackArchiveMock), { + archivePath, + mode: "update", + }); + expectPersistedInstall("packed-demo", { + source: "npm", + spec: "@acme/packed-demo@1.2.3", + sourcePath: archivePath, + installPath: "/tmp/packed-demo", + artifactKind: "npm-pack", + artifactFormat: "tgz", + npmIntegrity: "sha512-packed", + npmShasum: "a".repeat(40), + npmTarballName: "acme-packed-demo-1.2.3.tgz", + }); + }); + }); + it("rejects local path chat installs before package installer side effects", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); @@ -329,6 +447,39 @@ describe("handleCommands /plugins install", () => { }); }); + it("installs a local path after a trailing --force acknowledgement", async () => { + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "path-demo", + targetDir: "/tmp/path-demo", + version: "1.0.0", + extensions: ["index.js"], + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const pluginDir = path.join(workspaceDir, "fixtures", "path-install-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + const params = buildPluginsParams(`/plugins install ${pluginDir} --force`, workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "path-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromPathMock), { + path: pluginDir, + mode: "update", + }); + expectPersistedInstall("path-demo", { + source: "path", + sourcePath: pluginDir, + installPath: "/tmp/path-demo", + version: "1.0.0", + }); + }); + }); + it("rejects local archive chat installs before package installer side effects", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); @@ -348,13 +499,47 @@ describe("handleCommands /plugins install", () => { }); }); + it("installs a local archive after a trailing --force acknowledgement", async () => { + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "archive-demo", + targetDir: "/tmp/archive-demo", + version: "2.0.0", + extensions: ["index.js"], + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const pluginArchive = path.join(workspaceDir, "fixtures", "archive-install-plugin.tgz"); + await fs.mkdir(path.dirname(pluginArchive), { recursive: true }); + await fs.writeFile(pluginArchive, "not-a-real-archive"); + const params = buildPluginsParams(`/plugins install ${pluginArchive} --force`, workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "archive-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromPathMock), { + path: pluginArchive, + mode: "update", + }); + expectPersistedInstall("archive-demo", { + source: "archive", + sourcePath: pluginArchive, + installPath: "/tmp/archive-demo", + version: "2.0.0", + }); + }); + }); + it("blocks channel-authorized non-owner plugin installs before installer side effects", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); const pluginDir = path.join(workspaceDir, "fixtures", "channel-installed-plugin"); await fs.mkdir(pluginDir, { recursive: true }); - const params = buildPluginsParams(`/plugins install ${pluginDir}`, workspaceDir, { + const params = buildPluginsParams(`/plugins install ${pluginDir} --force`, workspaceDir, { omitGatewayClientScopes: true, senderIsOwner: false, }); @@ -374,7 +559,7 @@ describe("handleCommands /plugins install", () => { }); }); - it("rejects non-ClawHub gateway client installs even with operator.admin", async () => { + it("requires --force for non-ClawHub gateway client installs with operator.admin", async () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); const pluginDir = path.join(workspaceDir, "fixtures", "gateway-admin-plugin"); @@ -398,6 +583,39 @@ describe("handleCommands /plugins install", () => { }); }); + it("allows a gateway client with operator.admin to force a non-ClawHub install", async () => { + installPluginFromPathMock.mockResolvedValue({ + ok: true, + pluginId: "gateway-admin-plugin", + targetDir: "/tmp/gateway-admin-plugin", + version: "1.0.0", + extensions: ["index.js"], + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const pluginDir = path.join(workspaceDir, "fixtures", "gateway-admin-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + const params = buildPluginsParams(`/plugins install ${pluginDir} --force`, workspaceDir, { + gatewayClientScopes: ["operator.admin", "operator.write"], + senderIsOwner: false, + }); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "gateway-admin-plugin"'); + expectObjectFields(mockFirstObjectArg(installPluginFromPathMock), { + path: pluginDir, + mode: "update", + }); + expectPersistedInstall("gateway-admin-plugin", { + source: "path", + sourcePath: pluginDir, + }); + }); + }); + it("installs from an explicit clawhub: spec", async () => { installPluginFromClawHubMock.mockResolvedValue({ ok: true, @@ -542,7 +760,7 @@ describe("handleCommands /plugins install", () => { await withTempHome("openclaw-command-plugins-home-", async () => { const workspaceDir = await workspaceHarness.createWorkspace(); const params = buildPluginsParams( - "/plugins install clawhub:@openclaw/risky-demo@1.2.3", + "/plugins install clawhub:@openclaw/risky-demo@1.2.3 --force", workspaceDir, ); const result = await handlePluginsCommand(params, true); @@ -556,9 +774,12 @@ describe("handleCommands /plugins install", () => { expect(result.reply?.text).toContain("--acknowledge-clawhub-risk"); expect(result.reply?.text).toContain("local openclaw plugins install command"); expect(result.reply?.text).toContain("trusted shell"); - expect(mockFirstObjectArg(installPluginFromClawHubMock).spec).toBe( - "clawhub:@openclaw/risky-demo@1.2.3", - ); + const installParams = mockFirstObjectArg(installPluginFromClawHubMock); + expectObjectFields(installParams, { + spec: "clawhub:@openclaw/risky-demo@1.2.3", + mode: "update", + }); + expect(installParams).not.toHaveProperty("acknowledgeClawHubRisk"); expect(persistPluginInstallMock).not.toHaveBeenCalled(); }); }); @@ -661,6 +882,66 @@ describe("handleCommands /plugins install", () => { }); }); + it("installs an explicit git: source after a trailing --force acknowledgement", async () => { + installPluginFromGitSpecMock.mockResolvedValue({ + ok: true, + pluginId: "git-demo", + targetDir: "/tmp/git-demo", + version: "1.2.3", + extensions: ["index.js"], + git: { + url: "https://github.com/acme/git-demo.git", + ref: "v1.2.3", + commit: "0123456789abcdef0123456789abcdef01234567", + resolvedAt: "2026-07-14T00:00:00.000Z", + }, + }); + persistPluginInstallMock.mockResolvedValue({}); + + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const spec = "git:github.com/acme/git-demo@v1.2.3"; + const params = buildPluginsParams(`/plugins install ${spec} --force`, workspaceDir); + + const result = await handlePluginsCommand(params, true); + + expect(result?.reply?.text).toContain('Installed plugin "git-demo"'); + expect(result?.reply?.text).toContain("outside ClawHub review"); + expectObjectFields(mockFirstObjectArg(installPluginFromGitSpecMock), { + spec, + mode: "update", + }); + expectPersistedInstall("git-demo", { + source: "git", + spec, + installPath: "/tmp/git-demo", + version: "1.2.3", + gitUrl: "https://github.com/acme/git-demo.git", + gitRef: "v1.2.3", + gitCommit: "0123456789abcdef0123456789abcdef01234567", + }); + }); + }); + + it("rejects --force unless it is the final install argument", async () => { + await withTempHome("openclaw-command-plugins-home-", async () => { + const workspaceDir = await workspaceHarness.createWorkspace(); + const params = buildPluginsParams( + "/plugins install --force @acme/policy-plugin@1.0.0", + workspaceDir, + ); + + const result = await handlePluginsCommand(params, true); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain( + "Usage: /plugins install [--force]", + ); + expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled(); + expect(persistPluginInstallMock).not.toHaveBeenCalled(); + }); + }); + it("treats /plugin add as an install alias", async () => { installPluginFromClawHubMock.mockResolvedValue({ ok: true, diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 72b971a75371..17f55fc3b1c6 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -17,7 +17,7 @@ import { parseClawHubPluginSpec } from "../../infra/clawhub.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { buildClawHubPluginInstallRecordFields } from "../../plugins/clawhub-install-records.js"; import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "../../plugins/clawhub.js"; -import { parseGitPluginSpec } from "../../plugins/git-install.js"; +import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js"; import { persistPluginInstall, resolveInstallConfigMutationPreflights, @@ -30,7 +30,11 @@ import { resolveOpenClawTrustedNpmPackageInstall, type NonClawHubInstallSourceClass, } from "../../plugins/install-provenance.js"; -import { installPluginFromNpmSpec } from "../../plugins/install.js"; +import { + installPluginFromNpmPackArchive, + installPluginFromNpmSpec, + installPluginFromPath, +} from "../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { resolveCatalogOfficialExternalInstallPlan } from "../../plugins/official-external-install-trust.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js"; @@ -194,19 +198,24 @@ function looksLikeLocalPluginInstallSpec(raw: string): boolean { ); } -function rejectNonClawHubChatInstall(params: { +function resolveNonClawHubChatInstallAcknowledgement(params: { + force: boolean; sourceClass: NonClawHubInstallSourceClass; spec: string; -}): { ok: false; error: string } { +}): { ok: true; warning: string } | { ok: false; error: string } { const warning = formatNonClawHubInstallWarning(params); + if (params.force) { + return { ok: true, warning }; + } return { ok: false, - error: `${warning}\nThe /plugins chat command cannot acknowledge non-ClawHub install provenance; run the local openclaw plugins install command with ${NON_CLAWHUB_INSTALL_FORCE_FLAG} from a trusted shell after reviewing the source.`, + error: `${warning}\nReview the source, then rerun this chat command with ${NON_CLAWHUB_INSTALL_FORCE_FLAG} to continue.`, }; } async function installPluginFromPluginsCommand(params: { raw: string; + force: boolean; config: OpenClawConfig; snapshot: ConfigSnapshotForInstallPersist; }): Promise< @@ -218,17 +227,82 @@ async function installPluginFromPluginsCommand(params: { } const normalized = fileSpec && fileSpec.ok ? fileSpec.path : params.raw; const resolved = resolveUserPath(normalized); + const installMode = params.force ? "update" : "install"; if (fs.existsSync(resolved)) { - return rejectNonClawHubChatInstall({ + const acknowledgement = resolveNonClawHubChatInstallAcknowledgement({ + force: params.force, sourceClass: resolveArchiveKind(resolved) ? "local-archive" : "local-path", spec: params.raw, }); + if (!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 }; + } + const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; + await persistPluginInstall({ + snapshot: params.snapshot, + pluginId: result.pluginId, + install: { + source, + sourcePath: resolved, + installPath: result.targetDir, + version: result.version, + }, + }); + return { ok: true, pluginId: result.pluginId, warnings: [acknowledgement.warning] }; } const npmPackPath = parseNpmPackPrefixPath(params.raw); if (npmPackPath !== null) { - return rejectNonClawHubChatInstall({ sourceClass: "npm-pack", spec: params.raw }); + 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] }; } if (looksLikeLocalPluginInstallSpec(params.raw)) { @@ -241,10 +315,38 @@ async function installPluginFromPluginsCommand(params: { return { ok: false, error: `unsupported git: plugin spec: ${params.raw}` }; } if (gitSpec) { - return rejectNonClawHubChatInstall({ + 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); @@ -254,6 +356,7 @@ async function installPluginFromPluginsCommand(params: { const result = await installPluginFromClawHub({ spec: params.raw, config: params.config, + mode: installMode, logger: { info: logger.info, warn: (message) => { @@ -292,8 +395,16 @@ async function installPluginFromPluginsCommand(params: { : params.raw; const trustedNpmInstall = resolveOpenClawTrustedNpmPackageInstall(npmSpec); const officialIdPlan = resolveCatalogOfficialExternalInstallPlan(params.raw); - if (!trustedNpmInstall && !officialIdPlan) { - return rejectNonClawHubChatInstall({ sourceClass: "npm", spec: 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; @@ -302,9 +413,10 @@ async function installPluginFromPluginsCommand(params: { const result = await installPluginFromNpmSpec({ spec: trustedNpmSpec, config: params.config, - expectedPluginId: trustedPluginId, + mode: installMode, + ...(trustedPluginId ? { expectedPluginId: trustedPluginId } : {}), ...(expectedIntegrity ? { expectedIntegrity } : {}), - trustedSourceLinkedOfficialInstall: true, + ...(trustedNpmInstall || officialIdPlan ? { trustedSourceLinkedOfficialInstall: true } : {}), logger: createPluginInstallLogger(), }); if (!result.ok) { @@ -321,7 +433,11 @@ async function installPluginFromPluginsCommand(params: { pluginId: result.pluginId, install: installRecord, }); - return { ok: true, pluginId: result.pluginId }; + return { + ok: true, + pluginId: result.pluginId, + ...(arbitraryNpmAcknowledgement?.ok ? { warnings: [arbitraryNpmAcknowledgement.warning] } : {}), + }; } async function loadPluginCommandState( @@ -451,6 +567,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm } const installed = await installPluginFromPluginsCommand({ raw: pluginsCommand.spec, + force: pluginsCommand.force, config: loadedConfig.snapshot.config, snapshot: loadedConfig.snapshot, }); diff --git a/src/auto-reply/reply/plugins-commands.ts b/src/auto-reply/reply/plugins-commands.ts index e51551cce1b8..cf6b729f9830 100644 --- a/src/auto-reply/reply/plugins-commands.ts +++ b/src/auto-reply/reply/plugins-commands.ts @@ -8,7 +8,7 @@ import { type PluginsCommand = | { action: "list" } | { action: "inspect"; name?: string } - | { action: "install"; spec: string } + | { action: "install"; force: boolean; spec: string } | { action: "enable"; name: string } | { action: "disable"; name: string } | { action: "error"; message: string }; @@ -43,14 +43,18 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null { } if (action === "install" || action === "add") { - if (!name) { + const force = rest.at(-1) === "--force"; + const specParts = force ? rest.slice(0, -1) : rest; + const hasMisplacedForce = specParts.includes("--force"); + const spec = specParts.join(" ").trim(); + if (!spec || hasMisplacedForce) { return { action: "error", message: - "Usage: /plugins install . Arbitrary sources must be installed from a trusted local shell with openclaw plugins install ... --force.", + "Usage: /plugins install [--force]", }; } - return { action: "install", spec: name }; + return { action: "install", force, spec }; } if (action === "enable" || action === "disable") {