feat(plugins): allow forced chat installs

This commit is contained in:
Jesse Merhi
2026-07-14 15:11:36 +10:00
parent a9699683d9
commit 96e4ab2e9a
4 changed files with 437 additions and 30 deletions

View File

@@ -437,16 +437,21 @@ chat.
/plugins disable context7
/plugins install clawhub:<package>
/plugins install npm:@openclaw/<official-package>
/plugins install npm:<package> --force
/plugins install git:<repository>@<ref> --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

View File

@@ -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 <path|archive|npm-spec|npm-pack:path|git:repo|clawhub:pkg> [--force]",
);
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(persistPluginInstallMock).not.toHaveBeenCalled();
});
});
it("treats /plugin add as an install alias", async () => {
installPluginFromClawHubMock.mockResolvedValue({
ok: true,

View File

@@ -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,
});

View File

@@ -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 <clawhub-package|official-npm-spec>. Arbitrary sources must be installed from a trusted local shell with openclaw plugins install ... --force.",
"Usage: /plugins install <path|archive|npm-spec|npm-pack:path|git:repo|clawhub:pkg> [--force]",
};
}
return { action: "install", spec: name };
return { action: "install", force, spec };
}
if (action === "enable" || action === "disable") {