mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-01 23:41:36 +00:00
feat(plugins): allow forced chat installs
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user