mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 05:50:44 +00:00
Keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of persisting them into openclaw.json.
Refuse config writers, mutating update/plugin lifecycle commands, and doctor repair/token generation in Nix mode with agent-first nix-openclaw guidance.
Verification:
- pnpm check
- pnpm build
- pnpm test -- src/config/io.write-config.test.ts src/config/mutate.test.ts src/config/io.owner-display-secret.test.ts src/gateway/server-startup-config.recovery.test.ts src/gateway/startup-auth.test.ts src/gateway/startup-control-ui-origins.test.ts src/cli/plugins-cli.install.test.ts src/cli/plugins-cli.policy.test.ts src/cli/plugins-cli.uninstall.test.ts src/cli/plugins-cli.update.test.ts src/cli/update-cli.test.ts src/auto-reply/reply/commands-plugins.install.test.ts src/auto-reply/reply/commands-plugins.test.ts src/commands/onboarding-plugin-install.test.ts src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts src/commands/doctor/shared/codex-route-warnings.test.ts src/commands/doctor/repair-sequencing.test.ts src/agents/auth-profile-runtime-contract.test.ts src/auto-reply/reply/agent-runner-execution.test.ts
- GitHub CI green on 05a2c71b90
Co-authored-by: Codex <noreply@openai.com>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import { handlePluginsCommand } from "./commands-plugins.js";
|
|
import { buildPluginsCommandParams } from "./commands.test-harness.js";
|
|
|
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
|
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
|
const replaceConfigFileMock = vi.hoisted(() => vi.fn(async () => undefined));
|
|
const buildPluginRegistrySnapshotReportMock = vi.hoisted(() => vi.fn());
|
|
const buildPluginDiagnosticsReportMock = vi.hoisted(() => vi.fn());
|
|
const buildPluginInspectReportMock = vi.hoisted(() => vi.fn());
|
|
const buildAllPluginInspectReportsMock = vi.hoisted(() => vi.fn());
|
|
const formatPluginCompatibilityNoticeMock = vi.hoisted(() => vi.fn(() => "ok"));
|
|
const refreshPluginRegistryAfterConfigMutationMock = vi.hoisted(() => vi.fn(async () => undefined));
|
|
|
|
vi.mock("../../cli/npm-resolution.js", () => ({
|
|
buildNpmInstallRecordFields: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../cli/plugins-command-helpers.js", () => ({
|
|
createPluginInstallLogger: vi.fn(() => ({})),
|
|
resolveFileNpmSpecToLocalPath: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock("../../cli/plugins-install-persist.js", () => ({
|
|
persistPluginInstall: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
vi.mock("../../cli/plugins-registry-refresh.js", () => ({
|
|
refreshPluginRegistryAfterConfigMutation: refreshPluginRegistryAfterConfigMutationMock,
|
|
}));
|
|
|
|
vi.mock("../../config/config.js", () => ({
|
|
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
|
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
|
replaceConfigFile: replaceConfigFileMock,
|
|
}));
|
|
|
|
vi.mock("../../infra/archive.js", () => ({
|
|
resolveArchiveKind: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock("../../infra/clawhub.js", () => ({
|
|
parseClawHubPluginSpec: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock("../../plugins/clawhub.js", () => ({
|
|
installPluginFromClawHub: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../plugins/install.js", () => ({
|
|
installPluginFromNpmSpec: vi.fn(),
|
|
installPluginFromPath: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../plugins/installed-plugin-index-records.js", () => ({
|
|
loadInstalledPluginIndexInstallRecords: vi.fn(
|
|
async (params = {}) => params.config?.plugins?.installs ?? {},
|
|
),
|
|
}));
|
|
|
|
vi.mock("../../plugins/status.js", () => ({
|
|
buildAllPluginInspectReports: buildAllPluginInspectReportsMock,
|
|
buildPluginDiagnosticsReport: buildPluginDiagnosticsReportMock,
|
|
buildPluginInspectReport: buildPluginInspectReportMock,
|
|
buildPluginRegistrySnapshotReport: buildPluginRegistrySnapshotReportMock,
|
|
formatPluginCompatibilityNotice: formatPluginCompatibilityNoticeMock,
|
|
}));
|
|
|
|
vi.mock("../../plugins/toggle-config.js", () => ({
|
|
setPluginEnabledInConfig: vi.fn((config: OpenClawConfig, id: string, enabled: boolean) => ({
|
|
...config,
|
|
plugins: {
|
|
...config.plugins,
|
|
entries: {
|
|
...config.plugins?.entries,
|
|
[id]: { enabled },
|
|
},
|
|
},
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../../utils.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../../utils.js")>("../../utils.js");
|
|
return {
|
|
...actual,
|
|
resolveUserPath: vi.fn((value: string) => value),
|
|
};
|
|
});
|
|
|
|
function buildCfg(): OpenClawConfig {
|
|
return {
|
|
plugins: { enabled: true },
|
|
commands: { text: true, plugins: true },
|
|
};
|
|
}
|
|
|
|
function buildPluginsParams(commandBodyNormalized: string, cfg: OpenClawConfig) {
|
|
return buildPluginsCommandParams({
|
|
commandBodyNormalized,
|
|
cfg,
|
|
});
|
|
}
|
|
|
|
describe("handlePluginsCommand", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
readConfigFileSnapshotMock.mockResolvedValue({
|
|
valid: true,
|
|
path: "/tmp/openclaw.json",
|
|
sourceConfig: buildCfg(),
|
|
resolved: buildCfg(),
|
|
hash: "config-1",
|
|
});
|
|
validateConfigObjectWithPluginsMock.mockReturnValue({
|
|
ok: true,
|
|
config: buildCfg(),
|
|
issues: [],
|
|
});
|
|
buildPluginRegistrySnapshotReportMock.mockReturnValue({
|
|
workspaceDir: "/tmp/plugins-workspace",
|
|
plugins: [
|
|
{
|
|
id: "superpowers",
|
|
name: "superpowers",
|
|
status: "disabled",
|
|
format: "openclaw",
|
|
bundleFormat: "claude",
|
|
},
|
|
],
|
|
});
|
|
buildPluginDiagnosticsReportMock.mockReturnValue({
|
|
workspaceDir: "/tmp/plugins-workspace",
|
|
plugins: [
|
|
{
|
|
id: "superpowers",
|
|
name: "superpowers",
|
|
status: "disabled",
|
|
format: "openclaw",
|
|
bundleFormat: "claude",
|
|
},
|
|
],
|
|
});
|
|
buildPluginInspectReportMock.mockReturnValue({
|
|
plugin: {
|
|
id: "superpowers",
|
|
},
|
|
compatibility: [],
|
|
bundleFormat: "claude",
|
|
shape: { commands: ["review"] },
|
|
});
|
|
buildAllPluginInspectReportsMock.mockReturnValue([
|
|
{
|
|
plugin: { id: "superpowers" },
|
|
compatibility: [],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("lists discovered plugins and inspects plugin details", async () => {
|
|
const listResult = await handlePluginsCommand(
|
|
buildPluginsParams("/plugins list", buildCfg()),
|
|
true,
|
|
);
|
|
expect(listResult?.reply?.text).toContain("Plugins");
|
|
expect(listResult?.reply?.text).toContain("superpowers");
|
|
expect(listResult?.reply?.text).toContain("[disabled]");
|
|
|
|
const showResult = await handlePluginsCommand(
|
|
buildPluginsParams("/plugins inspect superpowers", buildCfg()),
|
|
true,
|
|
);
|
|
expect(showResult?.reply?.text).toContain('"id": "superpowers"');
|
|
expect(showResult?.reply?.text).toContain('"bundleFormat": "claude"');
|
|
expect(showResult?.reply?.text).toContain('"shape"');
|
|
expect(showResult?.reply?.text).toContain('"compatibilityWarnings": []');
|
|
|
|
const inspectAllResult = await handlePluginsCommand(
|
|
buildPluginsParams("/plugins inspect all", buildCfg()),
|
|
true,
|
|
);
|
|
expect(inspectAllResult?.reply?.text).toContain("```json");
|
|
expect(inspectAllResult?.reply?.text).toContain('"plugin"');
|
|
expect(inspectAllResult?.reply?.text).toContain('"compatibilityWarnings"');
|
|
expect(inspectAllResult?.reply?.text).toContain('"superpowers"');
|
|
});
|
|
|
|
it("rejects internal writes without operator.admin", async () => {
|
|
const params = buildPluginsParams("/plugins enable superpowers", buildCfg());
|
|
params.command.channel = "webchat";
|
|
params.command.channelId = "webchat";
|
|
params.command.surface = "webchat";
|
|
params.ctx.Provider = "webchat";
|
|
params.ctx.Surface = "webchat";
|
|
params.ctx.GatewayClientScopes = ["operator.write"];
|
|
|
|
const result = await handlePluginsCommand(params, true);
|
|
expect(result?.reply?.text).toContain("requires operator.admin");
|
|
});
|
|
|
|
it("enables and disables a discovered plugin", async () => {
|
|
validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next }));
|
|
|
|
const enableParams = buildPluginsParams("/plugins enable superpowers", buildCfg());
|
|
enableParams.command.senderIsOwner = true;
|
|
|
|
const enableResult = await handlePluginsCommand(enableParams, true);
|
|
expect(enableResult?.reply?.text).toContain('Plugin "superpowers" enabled');
|
|
expect(replaceConfigFileMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
nextConfig: expect.objectContaining({
|
|
plugins: expect.objectContaining({
|
|
entries: expect.objectContaining({
|
|
superpowers: expect.objectContaining({ enabled: true }),
|
|
}),
|
|
}),
|
|
}),
|
|
afterWrite: { mode: "auto" },
|
|
}),
|
|
);
|
|
expect(refreshPluginRegistryAfterConfigMutationMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
reason: "policy-changed",
|
|
config: expect.objectContaining({
|
|
plugins: expect.objectContaining({
|
|
entries: expect.objectContaining({
|
|
superpowers: expect.objectContaining({ enabled: true }),
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const disableParams = buildPluginsParams("/plugins disable superpowers", buildCfg());
|
|
disableParams.command.senderIsOwner = true;
|
|
|
|
const disableResult = await handlePluginsCommand(disableParams, true);
|
|
expect(disableResult?.reply?.text).toContain('Plugin "superpowers" disabled');
|
|
expect(replaceConfigFileMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
nextConfig: expect.objectContaining({
|
|
plugins: expect.objectContaining({
|
|
entries: expect.objectContaining({
|
|
superpowers: expect.objectContaining({ enabled: false }),
|
|
}),
|
|
}),
|
|
}),
|
|
afterWrite: { mode: "auto" },
|
|
}),
|
|
);
|
|
expect(refreshPluginRegistryAfterConfigMutationMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
reason: "policy-changed",
|
|
config: expect.objectContaining({
|
|
plugins: expect.objectContaining({
|
|
entries: expect.objectContaining({
|
|
superpowers: expect.objectContaining({ enabled: false }),
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("refuses plugin enablement in Nix mode before reading or replacing config", async () => {
|
|
const previousNixMode = process.env.OPENCLAW_NIX_MODE;
|
|
process.env.OPENCLAW_NIX_MODE = "1";
|
|
try {
|
|
const params = buildPluginsParams("/plugins enable superpowers", buildCfg());
|
|
params.command.senderIsOwner = true;
|
|
|
|
const result = await handlePluginsCommand(params, true);
|
|
expect(result?.reply?.text).toContain("OPENCLAW_NIX_MODE=1");
|
|
expect(result?.reply?.text).toContain("nix-openclaw#quick-start");
|
|
expect(readConfigFileSnapshotMock).not.toHaveBeenCalled();
|
|
expect(replaceConfigFileMock).not.toHaveBeenCalled();
|
|
expect(refreshPluginRegistryAfterConfigMutationMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
if (previousNixMode === undefined) {
|
|
delete process.env.OPENCLAW_NIX_MODE;
|
|
} else {
|
|
process.env.OPENCLAW_NIX_MODE = previousNixMode;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("resolves write targets by indexed plugin name without loading diagnostics", async () => {
|
|
buildPluginRegistrySnapshotReportMock.mockReturnValue({
|
|
workspaceDir: "/tmp/plugins-workspace",
|
|
plugins: [
|
|
{
|
|
id: "superpowers",
|
|
name: "Super Powers",
|
|
status: "disabled",
|
|
format: "openclaw",
|
|
bundleFormat: "claude",
|
|
},
|
|
],
|
|
});
|
|
validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next }));
|
|
|
|
const params = buildPluginsParams("/plugins enable Super Powers", buildCfg());
|
|
params.command.senderIsOwner = true;
|
|
|
|
const result = await handlePluginsCommand(params, true);
|
|
expect(result?.reply?.text).toContain('Plugin "superpowers" enabled');
|
|
expect(buildPluginRegistrySnapshotReportMock).toHaveBeenCalled();
|
|
expect(buildPluginDiagnosticsReportMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns an explicit unauthorized reply for native /plugins list", async () => {
|
|
const params = buildPluginsParams("/plugins list", buildCfg());
|
|
params.command.senderIsOwner = false;
|
|
params.ctx.Provider = "telegram";
|
|
params.ctx.Surface = "telegram";
|
|
params.ctx.CommandSource = "native";
|
|
params.command.channel = "telegram";
|
|
params.command.channelId = "telegram";
|
|
params.command.surface = "telegram";
|
|
|
|
const result = await handlePluginsCommand(params, true);
|
|
expect(result).toEqual({
|
|
shouldContinue: false,
|
|
reply: { text: "You are not authorized to use this command." },
|
|
});
|
|
});
|
|
});
|