fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-14 09:11:55 +08:00
1 parent 9b0af07607
commit b3b08b4ff2
17 files changed
+515 -36

No files matched your search

+4
View File
@@ -263,6 +263,8 @@ export interface PluginSummary {
/** Environment variables the plugin reads. Admins only. */
env?: PluginEnvVar[];
signedBy?: string | null;
/** A linked server decides whether it runs here. Admins only. */
managedByServer?: boolean;
}
export async function getPlugins(): Promise<PluginSummary[]> {
@@ -510,10 +512,12 @@ export async function previewPluginState(
export async function setPluginState(
pluginId: string,
enabled: boolean,
consent?: { version: string; capabilities: string[] },
): Promise<PluginStateChange> {
try {
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
enabled,
...consent,
});
return response.data;
} finally {
+1
View File
@@ -1967,6 +1967,7 @@
"syncEntities_other": "Desktop sync"
},
"consent": {
"enableTitle": "Enable {{name}}?",
"installTitle": "Install {{name}}",
"updateTitle": "Update {{name}}",
"agreeUpdate": "Agree and update",
+14 -10
View File
@@ -8,7 +8,7 @@ import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model";
export interface ConsentRequest {
plugin: PluginEntry;
mode: "install" | "update" | "upload";
mode: "install" | "update" | "upload" | "enable";
version: string;
/** What the prompt lists: all of them to install, only the new ones to update. */
capabilities: string[];
@@ -45,17 +45,19 @@ export function PluginConsentPrompt({
onCancel={busy ? undefined : onCancel}
icon={<PluginIconBox name={plugin.icon} size="sm" />}
title={
mode === "update"
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
: mode === "upload"
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
: t("plugins.manager.consent.installTitle", { name: plugin.name })
mode === "enable"
? t("plugins.manager.consent.enableTitle", { name: plugin.name })
: mode === "update"
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
: mode === "upload"
? t("plugins.manager.consent.uploadTitle", { name: plugin.name })
: t("plugins.manager.consent.installTitle", { name: plugin.name })
}
description={
<Facts>
{plugin.author && <span className="truncate">{plugin.author}</span>}
<span className="shrink-0">
{mode !== "install" && plugin.version
{(mode === "update" || mode === "upload") && plugin.version
? t("plugins.manager.versionChange", {
from: plugin.version,
to: version,
@@ -75,9 +77,11 @@ export function PluginConsentPrompt({
disabled={busy}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{mode === "update"
? t("plugins.manager.consent.agreeUpdate")
: t("plugins.manager.install")}
{mode === "enable"
? t("plugins.manager.enable")
: mode === "update"
? t("plugins.manager.consent.agreeUpdate")
: t("plugins.manager.install")}
</Button>
</>
}
+1 -1
View File
@@ -177,7 +177,7 @@ export function PluginDetail({
<Button
variant="outline"
size="sm"
disabled={busy || locked}
disabled={busy || plugin.managedByServer}
onClick={() => void manager.toggle(plugin)}
>
{plugin.enabled
+2 -2
View File
@@ -793,7 +793,7 @@ function InstalledCard({
<Button
variant="outline"
size="xs"
disabled={busy || locked}
disabled={busy || plugin.managedByServer}
onClick={() => void manager.toggle(plugin)}
>
{plugin.enabled
@@ -907,7 +907,7 @@ function InstalledRowActions({
<Button
variant="outline"
size="xs"
disabled={busy || locked}
disabled={busy || plugin.managedByServer}
onClick={() => void manager.toggle(plugin)}
>
{plugin.enabled
+5
View File
@@ -69,6 +69,8 @@ export interface PluginEntry {
/** The registry it is listed in or was installed from. */
registryId: string | null;
registryName: string | null;
/** A linked server decides whether it runs here. */
managedByServer: boolean;
}
export type PluginSource =
@@ -165,6 +167,7 @@ export function mergePlugins(
unverified: pluginSource(summary) === "unverified",
registryId: entry?.registryId ?? summary.registryId ?? null,
registryName: entry?.registryName ?? null,
managedByServer: summary.managedByServer ?? false,
});
}
@@ -208,6 +211,7 @@ export function mergePlugins(
unverified: false,
registryId: entry.registryId ?? "official",
registryName: entry.registryName ?? null,
managedByServer: false,
});
}
@@ -248,6 +252,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
unverified: true,
registryId: null,
registryName: null,
managedByServer: false,
};
}
+36 -3
View File
@@ -342,18 +342,42 @@ export function usePluginsManager() {
[failToast],
);
const doEnable = useCallback(
async (request: ConsentRequest) =>
run(request.plugin.id, async () => {
try {
const result = await setPluginState(request.plugin.id, true, {
version: request.version,
capabilities: request.capabilities,
});
if (result.state === "failed" || result.state === "blocked") {
toast.error(
t("plugins.manager.toast.failedToStart", {
name: request.plugin.name,
}),
);
}
} catch (error) {
failToast(error, "plugins.manager.errors.state", request.plugin.name);
}
}),
[run, t, failToast],
);
const confirmConsent = useCallback(async () => {
if (!consent) return;
const request = consent;
setConsent(null);
if (request.mode === "upload" && request.uploadToken) {
if (request.mode === "enable") {
await doEnable(request);
} else if (request.mode === "upload" && request.uploadToken) {
await doInstallUpload(request);
} else if (request.mode === "install") {
await doInstall(request.plugin, request.version, request.capabilities);
} else {
await doUpdate(request.plugin, request.version, request.capabilities);
}
}, [consent, doInstall, doUpdate, doInstallUpload]);
}, [consent, doInstall, doUpdate, doInstallUpload, doEnable]);
const nameOf = useCallback(
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
@@ -402,6 +426,15 @@ export function usePluginsManager() {
failToast(error, "plugins.manager.errors.state", plugin.name);
return;
}
if (enable && registry?.managedByServer) {
setConsent({
plugin,
mode: "enable",
version: plugin.version!,
capabilities: plugin.capabilities,
});
return;
}
await run(plugin.id, async () => {
try {
const result = await setPluginState(plugin.id, enable);
@@ -415,7 +448,7 @@ export function usePluginsManager() {
}
});
},
[confirm, t, run, failToast, nameOf],
[confirm, t, run, failToast, nameOf, registry?.managedByServer],
);
const retry = useCallback(
@@ -89,3 +89,26 @@ describe("PluginConsentPrompt", () => {
expect(container.innerHTML).toBe("");
});
});
it("shows explicit enable consent and only confirms when clicked", () => {
const onConfirm = vi.fn();
render(
<PluginConsentPrompt
request={{
plugin,
mode: "enable",
version: "1.0.0",
capabilities: ["ssh:connect"],
}}
onCancel={() => {}}
onConfirm={onConfirm}
/>,
);
expect(screen.getByText("plugins.manager.consent.enableTitle")).toBeTruthy();
expect(
screen.getByText("plugins.capabilities.ssh:connect.title"),
).toBeTruthy();
expect(onConfirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("plugins.manager.enable"));
expect(onConfirm).toHaveBeenCalledOnce();
});
+21 -2
View File
@@ -23,7 +23,9 @@ const plugin = {
contributes: {},
versions: [],
downloads: null,
managedByServer: false,
};
let linked = false;
vi.mock("@/plugins/use-plugins-manager", () => ({
usePluginsManager: () => ({
@@ -32,7 +34,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
refreshing: false,
refresh: vi.fn(),
registryError: null,
managedByServer: false,
managedByServer: linked,
busy: new Set(),
consent: null,
setConsent: vi.fn(),
@@ -56,7 +58,11 @@ async function renderTab() {
);
}
beforeEach(() => localStorage.clear());
beforeEach(() => {
localStorage.clear();
linked = false;
plugin.managedByServer = false;
});
afterEach(cleanup);
describe("PluginsTab", () => {
@@ -123,4 +129,17 @@ describe("PluginsTab", () => {
);
expect(grid?.style.gridTemplateColumns).toContain("auto-fill");
});
it("lets a linked desktop switch a plugin its server does not decide", async () => {
linked = true;
await renderTab();
const toggle = () =>
screen.getByRole("button", { name: en.plugins.manager.disable });
expect(toggle().hasAttribute("disabled")).toBe(false);
cleanup();
plugin.managedByServer = true;
await renderTab();
expect(toggle().hasAttribute("disabled")).toBe(true);
});
});
@@ -82,6 +82,14 @@ describe("mergePlugins", () => {
});
});
it("says whether the linked server decides an installed plugin", () => {
const rows = mergePlugins(
[summary({ managedByServer: true })],
[entry({ id: "other" })],
);
expect(rows.map((row) => row.managedByServer)).toEqual([true, false]);
});
it("lists registry plugins that are not installed with the latest capabilities", () => {
const rows = mergePlugins(
[],
@@ -0,0 +1,71 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest";
const api = vi.hoisted(() => ({
getPlugins: vi.fn(async () => [
{
id: "sample-plugin",
name: "Sample",
version: "1.0.0",
enabled: false,
state: "loaded",
source: "user",
capabilities: ["kv:own"],
managedByServer: false,
contributes: {},
},
]),
getPluginRegistry: vi.fn(async () => ({
plugins: [],
managedByServer: true,
registry: {},
})),
getDeveloperMode: vi.fn(async () => ({ enabled: false, signedOnly: false })),
previewPluginState: vi.fn(async () => ({
enable: [],
disable: [],
missing: [],
})),
setPluginState: vi.fn(async () => ({ state: "active" })),
}));
vi.mock("@/api/plugins-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/plugins-api")>()),
...api,
}));
vi.mock("@/components/surface/surface-scope", () => ({
useConfirm: () => vi.fn(async () => true),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { usePluginsManager } from "@/plugins/use-plugins-manager";
it("waits for local consent and sends the reviewed version and capabilities", async () => {
const { result } = renderHook(() => usePluginsManager());
await waitFor(() => expect(result.current.loading).toBe(false));
const plugin = result.current.plugins[0];
await act(async () => {
await result.current.toggle(plugin);
});
expect(result.current.consent).toMatchObject({
mode: "enable",
version: "1.0.0",
capabilities: ["kv:own"],
});
expect(api.setPluginState).not.toHaveBeenCalled();
act(() => result.current.setConsent(null));
expect(api.setPluginState).not.toHaveBeenCalled();
await act(async () => {
await result.current.toggle(plugin);
});
await act(async () => {
await result.current.confirmConsent();
});
expect(api.setPluginState).toHaveBeenCalledExactlyOnceWith(
"sample-plugin",
true,
{ version: "1.0.0", capabilities: ["kv:own"] },
);
});