mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 16:01:35 +00:00
* refactor(config): consolidate media model lists * refactor(config): unify memory configuration * refactor(config): consolidate TTS ownership * refactor(config): move typing policy to agents * refactor(config): retire product-level config surfaces * refactor(config): share scoped tool policy type * chore(config): refresh generated baselines * fix(config): honor agent typing overrides * fix(config): migrate sibling config consumers * refactor(infra): keep base64url decoder private * fix(config): strip invalid legacy TTS values * chore(config): refresh rebased baseline hash * fix(doctor): route legacy messages.tts.realtime voice to talk during tts move * refactor(config): polish final layout names * refactor(config): freeze retired tuning defaults * feat(config): add fast mode default symmetry * refactor(config): key agent entries by id * docs(config): update final layout reference * test(config): cover final layout migrations * chore(config): refresh final layout baselines * fix(config): align final layout runtime readers * fix(config): align remaining readers * fix(config): stabilize final layout migrations * fix(config): finalize config projection proof * fix(config): address final layout review * docs(release): preserve historical config names * fix(config): complete keyed agent migration * fix(config): close final migration gaps * fix(config): finish full-branch review * fix(config): complete runtime secret detection * fix(config): close final review findings * fix(config): finish canonical docs and heartbeat migration * fix(config): integrate latest main after rebase * refactor(env): isolate test-only controls * refactor(env): isolate build and development controls * refactor(env): collapse process identity indirection * refactor(env): remove duplicate config and temp aliases * docs(env): define the operator-facing allowlist * ci(env): ratchet production variable count * fix(env): remove stale provider helper import * fix(env): make ratchet sorting explicit * test(env): keep test seam in dead-code audit * test(env): cover ratchet growth and boundary; document surface budgets * docs(config): document tier-eval consolidations * docs(config): clarify speech preference ownership * test(memory): align retired tuning fixtures * refactor(memory): freeze engine heuristics * refactor(config): apply tier-eval tranche * refactor(tts): move persona shaping to providers * refactor(compaction): move prompt policy to providers * test(config): align hookified prompt fixtures * chore(deadcode): classify test-only exports * chore(github): remove unused spawn helper * chore(deadcode): classify queue diagnostics * chore(deadcode): remove unused lane snapshot export * chore(plugin-sdk): ratchet consolidated surface * fix(config): integrate latest main after rebase
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
// Plugin management Gateway handler tests cover DTO mapping, trust errors, and reload planning.
|
|
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const managementMocks = vi.hoisted(() => {
|
|
class ManagedPluginLifecycleError extends Error {
|
|
readonly kind: "invalid-request" | "unavailable";
|
|
readonly code?: string;
|
|
readonly version?: string;
|
|
readonly warning?: string;
|
|
|
|
constructor(
|
|
message: string,
|
|
details?: {
|
|
kind?: "invalid-request" | "unavailable";
|
|
code?: string;
|
|
version?: string;
|
|
warning?: string;
|
|
},
|
|
) {
|
|
super(message);
|
|
this.kind = details?.kind ?? "invalid-request";
|
|
this.code = details?.code;
|
|
this.version = details?.version;
|
|
this.warning = details?.warning;
|
|
}
|
|
}
|
|
return {
|
|
ManagedPluginLifecycleError,
|
|
install: vi.fn(),
|
|
list: vi.fn(),
|
|
setEnabled: vi.fn(),
|
|
uninstall: vi.fn(),
|
|
};
|
|
});
|
|
const searchMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../../plugins/management-service.js", () => ({
|
|
ManagedPluginLifecycleError: managementMocks.ManagedPluginLifecycleError,
|
|
formatManagedPluginLifecycleError: (error: unknown) =>
|
|
error instanceof Error ? error.message : String(error),
|
|
installManagedPlugin: (...args: unknown[]) => managementMocks.install(...args),
|
|
listManagedPlugins: (...args: unknown[]) => managementMocks.list(...args),
|
|
setManagedPluginEnabled: (...args: unknown[]) => managementMocks.setEnabled(...args),
|
|
uninstallManagedPlugin: (...args: unknown[]) => managementMocks.uninstall(...args),
|
|
}));
|
|
|
|
vi.mock("../../plugins/catalog-search.js", () => ({
|
|
searchInstallablePluginPackages: (...args: unknown[]) => searchMock(...args),
|
|
}));
|
|
|
|
const { pluginsHandlers } = await import("./plugins.js");
|
|
|
|
async function callHandler(
|
|
method: string,
|
|
params: Record<string, unknown>,
|
|
runtimeConfig: Record<string, unknown> = {},
|
|
) {
|
|
let ok: boolean | null = null;
|
|
let response: unknown;
|
|
let error: unknown;
|
|
await expectDefined(
|
|
pluginsHandlers[method],
|
|
"pluginsHandlers[method] test invariant",
|
|
)({
|
|
params,
|
|
req: {} as never,
|
|
client: null as never,
|
|
isWebchatConnect: () => false,
|
|
context: {
|
|
getRuntimeConfig: () => runtimeConfig,
|
|
notifyPluginMetadataChanged: pluginMetadataChanged,
|
|
} as never,
|
|
respond: (success, result, requestError) => {
|
|
ok = success;
|
|
response = result;
|
|
error = requestError;
|
|
},
|
|
});
|
|
return { ok, response, error };
|
|
}
|
|
|
|
const pluginMetadataChanged = vi.fn();
|
|
|
|
const workboard = {
|
|
id: "workboard",
|
|
name: "Workboard",
|
|
installed: true,
|
|
enabled: false,
|
|
state: "disabled" as const,
|
|
featured: true,
|
|
order: 10,
|
|
};
|
|
|
|
describe("plugin management Gateway handlers", () => {
|
|
beforeEach(() => {
|
|
pluginMetadataChanged.mockReset();
|
|
managementMocks.install.mockReset();
|
|
managementMocks.list.mockReset();
|
|
managementMocks.setEnabled.mockReset();
|
|
managementMocks.uninstall.mockReset();
|
|
searchMock.mockReset();
|
|
});
|
|
|
|
it("signals the config reloader after persisted plugin metadata changes", async () => {
|
|
const result = await callHandler("plugins.refresh", {});
|
|
|
|
expect(pluginMetadataChanged).toHaveBeenCalledOnce();
|
|
expect(result).toEqual({ ok: true, response: { ok: true }, error: undefined });
|
|
});
|
|
|
|
it("returns cold Workboard inventory without claiming runtime loaded state", async () => {
|
|
managementMocks.list.mockResolvedValue({
|
|
plugins: [workboard],
|
|
diagnostics: [],
|
|
mutationAllowed: true,
|
|
});
|
|
|
|
const result = await callHandler("plugins.list", {});
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
response: { plugins: [workboard], diagnostics: [], mutationAllowed: true },
|
|
error: undefined,
|
|
});
|
|
});
|
|
|
|
it("maps plugin-only ClawHub search results to the public DTO", async () => {
|
|
searchMock.mockResolvedValue([
|
|
{
|
|
score: 0.91,
|
|
package: {
|
|
name: "@openclaw/diffs",
|
|
displayName: "Diffs",
|
|
family: "code-plugin",
|
|
channel: "official",
|
|
isOfficial: true,
|
|
summary: "Readable diffs",
|
|
latestVersion: "1.2.3",
|
|
runtimeId: "diffs",
|
|
ownerHandle: "openclaw",
|
|
verificationTier: "source-linked",
|
|
stats: { downloads: 149263, installs: 280, stars: 0, versions: 83 },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await callHandler("plugins.search", { query: "diff", limit: 12 });
|
|
|
|
expect(searchMock).toHaveBeenCalledWith({ query: "diff", limit: 12 });
|
|
expect(result.response).toEqual({
|
|
results: [
|
|
{
|
|
score: 0.91,
|
|
package: {
|
|
name: "@openclaw/diffs",
|
|
displayName: "Diffs",
|
|
family: "code-plugin",
|
|
channel: "official",
|
|
isOfficial: true,
|
|
summary: "Readable diffs",
|
|
latestVersion: "1.2.3",
|
|
runtimeId: "diffs",
|
|
downloads: 149263,
|
|
verificationTier: "source-linked",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("omits malformed ClawHub download stats from the public DTO", async () => {
|
|
searchMock.mockResolvedValue([
|
|
{
|
|
score: 0.5,
|
|
package: {
|
|
name: "community/demo",
|
|
displayName: "Demo",
|
|
family: "code-plugin",
|
|
channel: "community",
|
|
isOfficial: false,
|
|
stats: { downloads: Number.NaN },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const result = await callHandler("plugins.search", { query: "demo" });
|
|
|
|
expect(result.response).toEqual({
|
|
results: [
|
|
{
|
|
score: 0.5,
|
|
package: {
|
|
name: "community/demo",
|
|
displayName: "Demo",
|
|
family: "code-plugin",
|
|
channel: "community",
|
|
isOfficial: false,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("derives Workboard restart state from its exact config path", async () => {
|
|
managementMocks.setEnabled.mockResolvedValue({
|
|
plugin: { ...workboard, enabled: true, state: "enabled" },
|
|
changedPaths: ["plugins.entries.workboard.enabled"],
|
|
warnings: ['Exclusive slot "memory" switched to "workboard".'],
|
|
});
|
|
|
|
const result = await callHandler("plugins.setEnabled", {
|
|
pluginId: "workboard",
|
|
enabled: true,
|
|
});
|
|
|
|
expect(managementMocks.setEnabled).toHaveBeenCalledWith({
|
|
pluginId: "workboard",
|
|
enabled: true,
|
|
});
|
|
expect(result.response).toMatchObject({
|
|
ok: true,
|
|
restartRequired: false,
|
|
warnings: ['Exclusive slot "memory" switched to "workboard".'],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ mode: "off", restartRequired: true },
|
|
{ mode: "restart", restartRequired: false },
|
|
{ mode: "hot", restartRequired: false },
|
|
] as const)(
|
|
"reports restartRequired=$restartRequired for $mode reload mode",
|
|
async ({ mode, restartRequired }) => {
|
|
managementMocks.setEnabled.mockResolvedValue({
|
|
plugin: { ...workboard, enabled: true, state: "enabled" },
|
|
changedPaths: ["plugins.entries.workboard.enabled"],
|
|
});
|
|
|
|
const result = await callHandler(
|
|
"plugins.setEnabled",
|
|
{ pluginId: "workboard", enabled: true },
|
|
{ gateway: { reload: { mode } } },
|
|
);
|
|
|
|
expect(result.response).toMatchObject({ ok: true, restartRequired });
|
|
},
|
|
);
|
|
|
|
it("classifies known enablement policy failures as invalid requests", async () => {
|
|
managementMocks.setEnabled.mockRejectedValue(
|
|
new managementMocks.ManagedPluginLifecycleError("Plugin is blocked"),
|
|
);
|
|
|
|
const result = await callHandler("plugins.setEnabled", {
|
|
pluginId: "workboard",
|
|
enabled: true,
|
|
});
|
|
|
|
expect(result.error).toMatchObject({
|
|
code: "INVALID_REQUEST",
|
|
message: "Plugin is blocked",
|
|
});
|
|
});
|
|
|
|
it("classifies unexpected enablement persistence failures as unavailable", async () => {
|
|
managementMocks.setEnabled.mockRejectedValue(new Error("rename EACCES"));
|
|
|
|
const result = await callHandler("plugins.setEnabled", {
|
|
pluginId: "workboard",
|
|
enabled: true,
|
|
});
|
|
|
|
expect(result.error).toMatchObject({
|
|
code: "UNAVAILABLE",
|
|
message: "rename EACCES",
|
|
});
|
|
});
|
|
|
|
it("forwards explicit ClawHub risk acknowledgement", async () => {
|
|
managementMocks.install.mockResolvedValue({
|
|
plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" },
|
|
});
|
|
|
|
await callHandler("plugins.install", {
|
|
source: "clawhub",
|
|
packageName: "@openclaw/diffs",
|
|
version: "1.2.3",
|
|
acknowledgeClawHubRisk: true,
|
|
});
|
|
|
|
expect(managementMocks.install).toHaveBeenCalledWith({
|
|
request: {
|
|
source: "clawhub",
|
|
packageName: "@openclaw/diffs",
|
|
version: "1.2.3",
|
|
acknowledgeClawHubRisk: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("returns structured ClawHub acknowledgement details", async () => {
|
|
managementMocks.install.mockRejectedValue(
|
|
new managementMocks.ManagedPluginLifecycleError("Review required", {
|
|
kind: "invalid-request",
|
|
code: "clawhub_risk_acknowledgement_required",
|
|
version: "1.2.3",
|
|
warning: "Suspicious release",
|
|
}),
|
|
);
|
|
|
|
const result = await callHandler("plugins.install", {
|
|
source: "clawhub",
|
|
packageName: "community/plugin",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toMatchObject({
|
|
code: "INVALID_REQUEST",
|
|
message: "Review required",
|
|
details: {
|
|
clawhubTrustCode: "clawhub_risk_acknowledgement_required",
|
|
version: "1.2.3",
|
|
warning: "Suspicious release",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("classifies ClawHub security outages as unavailable", async () => {
|
|
managementMocks.install.mockRejectedValue(
|
|
new managementMocks.ManagedPluginLifecycleError("Security service unavailable", {
|
|
kind: "unavailable",
|
|
code: "clawhub_security_unavailable",
|
|
}),
|
|
);
|
|
|
|
const result = await callHandler("plugins.install", {
|
|
source: "clawhub",
|
|
packageName: "community/plugin",
|
|
});
|
|
|
|
expect(result.error).toMatchObject({
|
|
code: "UNAVAILABLE",
|
|
details: { clawhubTrustCode: "clawhub_security_unavailable" },
|
|
});
|
|
});
|
|
|
|
it("classifies unexpected install persistence failures as unavailable", async () => {
|
|
managementMocks.install.mockRejectedValue(new Error("disk full"));
|
|
|
|
const result = await callHandler("plugins.install", {
|
|
source: "clawhub",
|
|
packageName: "community/plugin",
|
|
});
|
|
|
|
expect(result.error).toMatchObject({
|
|
code: "UNAVAILABLE",
|
|
message: "disk full",
|
|
});
|
|
});
|
|
|
|
it("returns removal actions and forces restart after uninstall", async () => {
|
|
managementMocks.uninstall.mockResolvedValue({
|
|
pluginId: "diffs",
|
|
removed: ["config entry", "install record", "directory"],
|
|
warnings: ["npm prune skipped"],
|
|
});
|
|
|
|
const result = await callHandler("plugins.uninstall", { pluginId: "diffs" });
|
|
|
|
expect(managementMocks.uninstall).toHaveBeenCalledWith({ pluginId: "diffs" });
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
response: {
|
|
ok: true,
|
|
pluginId: "diffs",
|
|
restartRequired: true,
|
|
removed: ["config entry", "install record", "directory"],
|
|
warnings: ["npm prune skipped"],
|
|
},
|
|
error: undefined,
|
|
});
|
|
});
|
|
|
|
it("classifies bundled uninstall refusals as invalid requests", async () => {
|
|
managementMocks.uninstall.mockRejectedValue(
|
|
new managementMocks.ManagedPluginLifecycleError(
|
|
"bundled plugin cannot be uninstalled: workboard; disable it instead",
|
|
),
|
|
);
|
|
|
|
const result = await callHandler("plugins.uninstall", { pluginId: "workboard" });
|
|
|
|
expect(result.error).toMatchObject({
|
|
code: "INVALID_REQUEST",
|
|
message: "bundled plugin cannot be uninstalled: workboard; disable it instead",
|
|
});
|
|
});
|
|
});
|