From 5480a7efbcc9881746ad28496e6bd4dc00bb08b9 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 8 Sep 2026 16:09:52 +0800 Subject: [PATCH] feat(plugins): confirm trusted code on first enable --- .../components/settings/PluginManagerCard.tsx | 42 +++++++++++++++- .../web/src/lib/plugins/plugin-trust.test.mjs | 43 +++++++++++++++++ apps/web/src/lib/plugins/plugin-trust.ts | 48 +++++++++++++++++++ docs/plugin-development.md | 2 + docs/plugin-development.zh-CN.md | 2 + packages/shared/src/i18n/en-US.ts | 5 ++ packages/shared/src/i18n/zh-CN.ts | 5 ++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/plugins/plugin-trust.test.mjs create mode 100644 apps/web/src/lib/plugins/plugin-trust.ts diff --git a/apps/web/src/components/settings/PluginManagerCard.tsx b/apps/web/src/components/settings/PluginManagerCard.tsx index be88539b..457bbe97 100644 --- a/apps/web/src/components/settings/PluginManagerCard.tsx +++ b/apps/web/src/components/settings/PluginManagerCard.tsx @@ -18,6 +18,13 @@ import { getPluginDetailPage, getPluginDetailPath, hasPluginSettings, type Plugi import type { ScheduledTask } from "@edgeever/shared"; import { api, getOrCreateClientDeviceId } from "@/lib/api"; import { ScheduledTaskRunHistoryDialog } from "@/components/execution/ScheduledTaskRunHistoryDialog"; +import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs"; +import { + acknowledgePluginTrustWarning, + hasAcknowledgedPluginTrustWarning, + PLUGIN_TRUST_WARNING_COPY, + shouldRequestPluginTrustAcknowledgement, +} from "@/lib/plugins/plugin-trust"; const permissionLabel = (permission: string) => permission.replace(":", " · "); @@ -301,6 +308,7 @@ export const PluginManagerCard = ({ const [error, setError] = useState(null); const [activePanel, setActivePanel] = useState(null); const [pendingUpdate, setPendingUpdate] = useState(null); + const [pendingTrustPluginId, setPendingTrustPluginId] = useState(null); const marketplaceQuery = useQuery({ queryKey: ["plugin-marketplace", "v1"], queryFn: () => loadPluginMarketplace(), staleTime: 5 * 60_000 }); const extensionVersionKey = snapshot.extensions .map((extension) => `${extension.manifest.id}:${extension.manifest.version}:${extension.source.kind}`) @@ -375,6 +383,26 @@ export const PluginManagerCard = ({ } }; + const toggleExtension = (extension: InstalledExtension, enabled: boolean) => { + if (shouldRequestPluginTrustAcknowledgement({ + acknowledged: hasAcknowledgedPluginTrustWarning(), + enabled, + extensionType: extension.manifest.type, + })) { + setPendingTrustPluginId(extension.manifest.id); + return; + } + void run(extension.manifest.id, () => host.setEnabled(extension.manifest.id, enabled)); + }; + + const confirmPluginTrust = () => { + const pluginId = pendingTrustPluginId; + if (!pluginId) return; + acknowledgePluginTrustWarning(); + setPendingTrustPluginId(null); + void run(pluginId, () => host.setEnabled(pluginId, true)); + }; + const applyUpdate = async (update: PluginUpdateInfo) => { const extension = snapshot.extensions.find((candidate) => candidate.manifest.id === update.pluginId); if (!extension) throw new Error("Extension is no longer installed."); @@ -453,7 +481,7 @@ export const PluginManagerCard = ({ commands={snapshot.commands.filter((command) => command.pluginId === selectedExtension.manifest.id)} panels={snapshot.panels.filter((panel) => panel.pluginId === selectedExtension.manifest.id)} pendingId={pendingId} - onToggle={(enabled) => void run(selectedExtension.manifest.id, () => host.setEnabled(selectedExtension.manifest.id, enabled))} + onToggle={(enabled) => toggleExtension(selectedExtension, enabled)} onUpdate={() => { const update = updateQuery.data?.updates.find((candidate) => candidate.pluginId === selectedExtension.manifest.id); if (update) setPendingUpdate(update); @@ -605,7 +633,7 @@ export const PluginManagerCard = ({ aria-label={t("plugins.toggle", { name: extension.manifest.name })} checked={extension.enabled} disabled={pendingId === id} - onCheckedChange={(enabled) => void run(id, () => host.setEnabled(id, enabled))} + onCheckedChange={(enabled) => toggleExtension(extension, enabled)} /> @@ -699,6 +727,16 @@ export const PluginManagerCard = ({ onConfirm={() => void run(`update:${pendingUpdate.pluginId}`, () => applyUpdate(pendingUpdate))} /> ) : null} + {pendingTrustPluginId ? ( + setPendingTrustPluginId(null)} + onConfirm={confirmPluginTrust} + /> + ) : null} ); diff --git a/apps/web/src/lib/plugins/plugin-trust.test.mjs b/apps/web/src/lib/plugins/plugin-trust.test.mjs new file mode 100644 index 00000000..36f87f8b --- /dev/null +++ b/apps/web/src/lib/plugins/plugin-trust.test.mjs @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { + acknowledgePluginTrustWarning, + hasAcknowledgedPluginTrustWarning, + PLUGIN_TRUST_ACKNOWLEDGEMENT_STORAGE_KEY, + PLUGIN_TRUST_WARNING_COPY, + shouldRequestPluginTrustAcknowledgement, +} from "./plugin-trust.ts"; + +describe("plugin trust acknowledgement", () => { + test("uses a versioned key so materially changed copy can be acknowledged again", () => { + expect(PLUGIN_TRUST_ACKNOWLEDGEMENT_STORAGE_KEY).toEndWith(String(PLUGIN_TRUST_WARNING_COPY.version)); + }); + + test("persists a one-time acknowledgement", () => { + const values = new Map(); + const storage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }; + + expect(hasAcknowledgedPluginTrustWarning(storage)).toBe(false); + acknowledgePluginTrustWarning(storage); + expect(hasAcknowledgedPluginTrustWarning(storage)).toBe(true); + }); + + test("falls back to asking again when browser storage is unavailable", () => { + const storage = { + getItem: () => { throw new Error("blocked"); }, + setItem: () => { throw new Error("blocked"); }, + }; + + expect(hasAcknowledgedPluginTrustWarning(storage)).toBe(false); + expect(() => acknowledgePluginTrustWarning(storage)).not.toThrow(); + }); + + test("asks only before the first client-plugin enable", () => { + expect(shouldRequestPluginTrustAcknowledgement({ acknowledged: false, enabled: true, extensionType: "plugin" })).toBe(true); + expect(shouldRequestPluginTrustAcknowledgement({ acknowledged: true, enabled: true, extensionType: "plugin" })).toBe(false); + expect(shouldRequestPluginTrustAcknowledgement({ acknowledged: false, enabled: false, extensionType: "plugin" })).toBe(false); + expect(shouldRequestPluginTrustAcknowledgement({ acknowledged: false, enabled: true, extensionType: "theme" })).toBe(false); + }); +}); diff --git a/apps/web/src/lib/plugins/plugin-trust.ts b/apps/web/src/lib/plugins/plugin-trust.ts new file mode 100644 index 00000000..abf8e019 --- /dev/null +++ b/apps/web/src/lib/plugins/plugin-trust.ts @@ -0,0 +1,48 @@ +type PluginTrustStorage = Pick; + +export const PLUGIN_TRUST_WARNING_COPY = { + version: 1, + titleKey: "plugins.trustWarning.title", + descriptionKey: "plugins.trustWarning.description", + confirmLabelKey: "plugins.trustWarning.confirm", +} as const; + +export const PLUGIN_TRUST_ACKNOWLEDGEMENT_STORAGE_KEY = + `edgeever:plugins:trusted-code:${PLUGIN_TRUST_WARNING_COPY.version}`; + +const getDefaultStorage = (): PluginTrustStorage | null => { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +}; + +export const hasAcknowledgedPluginTrustWarning = (storage?: PluginTrustStorage | null) => { + const resolvedStorage = storage === undefined ? getDefaultStorage() : storage; + try { + return resolvedStorage?.getItem(PLUGIN_TRUST_ACKNOWLEDGEMENT_STORAGE_KEY) === "acknowledged"; + } catch { + return false; + } +}; + +export const acknowledgePluginTrustWarning = (storage?: PluginTrustStorage | null) => { + const resolvedStorage = storage === undefined ? getDefaultStorage() : storage; + try { + resolvedStorage?.setItem(PLUGIN_TRUST_ACKNOWLEDGEMENT_STORAGE_KEY, "acknowledged"); + } catch { + // The current enable action may proceed; browsers that block storage will ask again next time. + } +}; + +export const shouldRequestPluginTrustAcknowledgement = ({ + acknowledged, + enabled, + extensionType, +}: { + acknowledged: boolean; + enabled: boolean; + extensionType: "plugin" | "theme"; +}) => enabled && extensionType === "plugin" && !acknowledged; diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 7f00bf86..8b33ad55 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -8,6 +8,8 @@ Theme packages contain only a validated manifest and documented design tokens. T Client plugins use an Obsidian-style trusted-code model. Enabling a plugin trusts it with the full EdgeEver plugin context; declared capabilities are optional descriptive metadata and do not gate API calls. The plugin module runs in the client JavaScript environment, so users must install plugins only from developers they trust. +The first time a user enables a client plugin on a device, EdgeEver presents a community-plugin trust confirmation. Once acknowledged, it is not shown for every plugin. Theme packages do not trigger the confirmation because they cannot execute JavaScript. + Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bindings, or internal React state through the public API. ## Plugin manifest diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index 44c1f58f..5b252e45 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -8,6 +8,8 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。 客户端插件采用类似 Obsidian 的受信任代码模型。启用插件即代表信任它使用完整的 EdgeEver 插件上下文;能力声明只是可选的描述性元数据,不限制 API 调用。插件模块运行在客户端 JavaScript 环境中,因此用户只能安装来自可信开发者的插件。 +用户在一台设备上首次启用客户端插件时,EdgeEver 会显示一次社区插件信任确认;确认后不会对每个插件重复提示。主题包不能执行 JavaScript,因此不会触发该确认。 + 公开 API 不会向插件暴露 EdgeEver Repository、IndexedDB 数据库、Cloudflare Binding 或 React 内部状态。 ## 插件 Manifest diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index c01149fd..5002e8c8 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -659,6 +659,11 @@ export const enUS = { permissions: { publicNetwork: "Read content from any public website", }, + trustWarning: { + title: "Turn on community plugins?", + description: "Community plugins run third-party code on your behalf and inherit EdgeEver's access, including the ability to read and modify notes and connect to the internet. Enable only plugins you trust.", + confirm: "Turn on community plugins", + }, toggle: "Enable or disable {{name}}", uninstall: "Uninstall", settings: { diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 9bd85457..aab5920d 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -659,6 +659,11 @@ export const zhCN = { permissions: { publicNetwork: "读取任意公开网站内容", }, + trustWarning: { + title: "开启社区插件?", + description: "社区插件会代表你运行第三方代码,并继承 EdgeEver 的访问能力,包括读取和修改笔记、连接互联网。请仅启用你信任的插件。", + confirm: "开启社区插件", + }, toggle: "启用或停用 {{name}}", uninstall: "卸载", settings: {