feat(plugins): adopt trusted code permissions

This commit is contained in:
tianma-if committed 2026-09-08 15:37:32 +08:00
1 parent 72b90d3482
commit 46dfc2d304
10 files changed
+96 -89

No files matched your search

@@ -1,5 +1,5 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { BadgeCheck, BookOpen, CalendarClock, Download, ExternalLink, History, PanelRightOpen, Play, Puzzle, RefreshCw, Settings2, Trash2 } from "lucide-react";
import { AlertTriangle, BadgeCheck, BookOpen, CalendarClock, Download, ExternalLink, History, PanelRightOpen, Play, Puzzle, RefreshCw, Settings2, Trash2 } from "lucide-react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -439,6 +439,10 @@ export const PluginManagerCard = ({
</div>
</CardHeader>
<CardContent className="grid gap-4 p-4 pt-0 sm:px-5 sm:pb-5">
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50/60 p-3 text-xs leading-5 text-amber-900">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("plugins.trustedCodeNotice")}</span>
</div>
{error ? <div role="alert" className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">{error}</div> : null}
{!selectedPluginId ? <LegacyManualScheduledTasksSection /> : null}
+13 -21
View File
@@ -65,7 +65,7 @@ beforeEach(() => {
});
describe("EdgeEverPluginHost", () => {
test('generic AI/public network calls require declared permissions while public reads allow arbitrary public hosts', async () => {
test('enabled plugins can use host capabilities without manifest permission declarations', async () => {
const calls = [];
const directCalls = [];
globalThis.window.fetch = async (...input) => { directCalls.push(input); return new Response('direct'); };
@@ -73,23 +73,20 @@ describe("EdgeEverPluginHost", () => {
aiAdapter: { status: async () => ({ configured: true }), generate: async input => { calls.push(input); return { text: 'HELLO' }; } },
publicNetworkAdapter: { fetchPublic: async input => { calls.push(input); return { url: input.url, status: 429, statusText: 'Too Many Requests', headers: {}, body: new TextEncoder().encode('limited').buffer }; } },
});
const install = async (id, permissions, networkHosts) => {
host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, permissions: ['ui:commands', ...permissions], ...(networkHosts ? { networkHosts } : {}) }, 'https://example.org/manifest.json');
const install = async (id, permissions) => {
host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, ...(permissions ? { permissions } : {}) }, 'https://example.org/manifest.json');
await host.setEnabled(id, true);
};
await install('org.test.denied', ['network']);
await expect(host.runCommand('org.test.denied', 'ai')).rejects.toThrow();
await expect(host.runCommand('org.test.denied', 'public')).rejects.toThrow(); expect(calls).toHaveLength(0);
await host.runCommand('org.test.denied', 'direct-unlisted');
await install('org.test.trusted');
await host.runCommand('org.test.trusted', 'ai'); expect(capabilityResults.get('org.test.trusted')).toEqual({ text: 'HELLO' });
await host.runCommand('org.test.trusted', 'public'); expect(capabilityResults.get('org.test.trusted')).toEqual({ status: 429, text: 'limited', url: 'https://example.org/feed' });
await host.runCommand('org.test.trusted', 'direct-unlisted');
expect(String(directCalls[0][0])).toBe('http://192.168.1.8/feed');
expect(directCalls[0][1].credentials).toBe('include');
expect(new Headers(directCalls[0][1].headers).get('Authorization')).toBe('Bearer test');
await install('org.test.allowed', ['network', 'network:public', 'ai:generate']);
await expect(host.runCommand('org.test.allowed', 'post')).rejects.toThrow(); expect(calls).toHaveLength(0);
await host.runCommand('org.test.allowed', 'ai'); expect(capabilityResults.get('org.test.allowed')).toEqual({ text: 'HELLO' });
await host.runCommand('org.test.allowed', 'public'); expect(capabilityResults.get('org.test.allowed')).toEqual({ status: 429, text: 'limited', url: 'https://example.org/feed' });
await host.runCommand('org.test.allowed', 'unlisted'); expect(calls.at(-1).url).toBe('https://unlisted.org/feed');
expect(calls[0].signal.aborted).toBe(false); await host.setEnabled('org.test.allowed', false); expect(calls[0].signal.aborted).toBe(true);
await expect(host.runCommand('org.test.trusted', 'post')).rejects.toThrow();
await host.runCommand('org.test.trusted', 'unlisted'); expect(calls.at(-1).url).toBe('https://unlisted.org/feed');
expect(calls[0].signal.aborted).toBe(false); await host.setEnabled('org.test.trusted', false); expect(calls[0].signal.aborted).toBe(true);
await host.dispose();
});
test("lets a permitted plugin idempotently own schedules for its registered commands", async () => {
@@ -207,9 +204,8 @@ describe("EdgeEverPluginHost", () => {
expect(host.getSnapshot().panels).toEqual([{ pluginId: "org.edgeever.test-plugin", id: "fixture", title: "Fixture panel", presentation: "dialog" }]);
expect(notices).toEqual(["hello from plugin"]);
expect(host.getSnapshot().recentActions[0]).toMatchObject({ id: "hello", type: "command" });
await expect(host.runCommand("org.edgeever.test-plugin", "read-without-permission")).rejects.toThrow("notes:read");
await expect(host.runCommand("org.edgeever.test-plugin", "update-without-read-permission")).rejects.toThrow("notes:read");
await expect(host.runCommand("org.edgeever.test-plugin", "subscribe-without-read-permission")).rejects.toThrow("notes:read");
await host.runCommand("org.edgeever.test-plugin", "read-without-permission");
await host.runCommand("org.edgeever.test-plugin", "subscribe-without-read-permission");
await host.runCommand("org.edgeever.test-plugin", "replace-selection");
expect(replacement).toBe("HELLO");
await host.runCommand("org.edgeever.test-plugin", "write-secret");
@@ -477,11 +473,7 @@ describe("EdgeEverPluginHost", () => {
version: "1.0.0",
apiVersion: "1",
entry,
permissions: [
"notes:read", "notes:write", "metadata:write", "resources:read", "resources:write",
"templates:read", "templates:write", "editor:read", "editor:write",
"ui:commands", "ui:navigation", "ui:panels", "ui:embeds",
],
permissions: [],
settings: { fields: [{ key: "endpoint", type: "text", label: "Endpoint", default: "https://api.example" }] },
}, "https://plugins.example/capabilities/manifest.json");
+4 -3
View File
@@ -335,9 +335,10 @@ const readInstalledExtensions = (): InstalledExtension[] => {
};
const assertPermission = (manifest: PluginManifest, permission: PluginPermission) => {
if (!manifest.permissions.includes(permission)) {
throw new Error(`${manifest.name} has not declared the ${permission} permission.`);
}
// Enabled plugins are trusted code. Capability declarations are descriptive metadata,
// retained for compatibility and user review rather than runtime authorization.
void manifest;
void permission;
};
const EVENT_PERMISSIONS: Partial<Record<keyof PluginEventMap, PluginPermission>> = {
+22 -22
View File
@@ -6,7 +6,7 @@ EdgeEver's P0 extension API supports trusted client plugins and no-code theme pa
Theme packages contain only a validated manifest and documented design tokens. They do not execute JavaScript.
Client plugins use an Obsidian-style trusted-code model. Their declared permissions gate calls made through the EdgeEver plugin context, but the plugin module itself runs in the client JavaScript environment. Users must install plugins only from developers they trust.
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.
Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bindings, or internal React state through the public API.
@@ -42,7 +42,7 @@ styles.css (optional)
GitHub plugins must use `./main.js` as `entry`, and `main.js` must be a single-file bundle without relative module imports. EdgeEver reads the default-branch manifest, locates the matching Release, downloads assets in parallel, verifies GitHub's SHA-256 digest when present, and caches the verified package in device-local IndexedDB. `main.js` is limited to 5 MB and `styles.css` to 1 MB.
EdgeEver checks for updates when the Plugin Marketplace opens, when the window regains focus, and every 30 minutes, but never installs silently. The user must click Update and confirm. If a new version adds plugin permissions or network hosts, the confirmation lists the additional access. For GitHub distribution, the Release `manifest.json` must exactly match the default-branch manifest used for the update prompt or installation is rejected. Marketplace installs only follow newer versions verified in the Registry.
EdgeEver checks for updates when the Plugin Marketplace opens, when the window regains focus, and every 30 minutes, but never installs silently. The user must click Update and confirm. If a new version changes declared capabilities or legacy network-host metadata, the confirmation lists those changes for review. For GitHub distribution, the Release `manifest.json` must exactly match the default-branch manifest used for the update prompt or installation is rejected. Marketplace installs only follow newer versions verified in the Registry.
Updates use a rollback-capable switch. Old and new package versions are cached separately. If the new version cannot activate, EdgeEver restores the previous manifest, enabled state, and package instead of leaving the plugin broken or disabled.
@@ -88,7 +88,7 @@ Registry format:
Marketplace installs display a Verified badge. GitHub and manifest sideloads clearly display their unverified source, but EdgeEver does not block them. Uninstalling also deletes all device-local cached package versions, ordinary plugin storage for the current workspace, and Secret Storage.
Supported permissions:
Optional capability declarations, retained for disclosure and compatibility:
- `notes:read`
- `notes:write`
@@ -111,7 +111,7 @@ Supported permissions:
- `ui:panels`
- `ui:embeds`
Network access through `context.network.fetch()` requires the `network` capability declaration, but it is not restricted to a static host list. `networkHosts` remains accepted as legacy descriptive metadata and is not enforced. The anonymous, read-only `network:public` transport is available when a plugin needs a cross-origin public response without credentials.
Network access through `context.network.fetch()` is not restricted by capability declarations or a static host list. `networkHosts` remains accepted as legacy descriptive metadata and is not enforced. The anonymous, read-only `network:public` transport is available when a plugin needs a cross-origin public response without credentials.
## Plugin entry
@@ -149,7 +149,7 @@ Every registration returns a disposer. The host also disposes registered command
## Schedules API
Desktop plugins can persistently schedule one of their own registered commands. Declare both `ui:commands` and `schedules`, register the command first, then use a stable plugin-local key with `upsert()`:
Desktop plugins can persistently schedule one of their own registered commands. Register the command first, then use a stable plugin-local key with `upsert()`:
```js
export default {
@@ -221,8 +221,8 @@ context.tags.delete("unused");
`notes.query()` returns lightweight summaries. Use `notes.queryContent()` when a plugin must scan Markdown across many notes, such as a Tasks index, Calendar, Kanban board, or Linter. Both APIs accept at most 200 notes per page; follow `nextOffset` until it is `null`. Prefer the summary query whenever full content is unnecessary.
All writes go through EdgeEver's shared repository/business layer, including offline queueing and desktop adapters. Plugins do not access a storage implementation directly.
`notes.update()` requires both `notes:write` and `notes:read` because the update flow reads the current revision and returns the complete updated note. This prevents write access from becoming an indirect note-content read capability.
`notes.editMarkdown()` requires the same permissions and performs optimistic concurrency checks with the `revision` and `contentHash` returned by `notes.get()`. It is intended for plugins such as task togglers, linters, and index maintainers that change only specific Markdown ranges:
`notes.update()` reads the current revision and returns the complete updated note.
`notes.editMarkdown()` performs optimistic concurrency checks with the `revision` and `contentHash` returned by `notes.get()`. It is intended for plugins such as task togglers, linters, and index maintainers that change only specific Markdown ranges:
```ts
const note = await context.notes.get(noteId);
@@ -237,28 +237,28 @@ await context.notes.editMarkdown(noteId, {
```
Edit ranges use JavaScript UTF-16 string offsets and half-open ranges `[from, to)`. Ranges in one call must not overlap, exceed the note, or split a Unicode surrogate pair. The host rejects writes with an error carrying `code: "NOTE_CONFLICT"` when the note baseline changed or the active editor has unsaved changes; plugins should reload and ask the user to retry. Invalid ranges use `code: "INVALID_MARKDOWN_EDIT"`. The SDK exports `PluginApiError` and `PluginApiErrorCode` for TypeScript error narrowing.
Notebook and tag reads require `metadata:read`; notebook and tag changes require `metadata:write`.
Enabled plugins can read and change notebooks and tags without capability gates.
Attachments use their own permissions and still flow through the same Web/Desktop repository adapters:
Attachments flow through the same Web/Desktop repository adapters:
```ts
context.resources.list(noteId); // resources:read
const blob = await context.resources.read(resourceId); // resources:read
context.resources.upload(noteId, file); // resources:write
context.resources.list(noteId);
const blob = await context.resources.read(resourceId);
context.resources.upload(noteId, file);
context.resources.update(resourceId, { file, expectedContentHash });
context.resources.rename(resourceId, filename);
context.resources.delete(resourceId);
```
`resources.update()` requires both resource permissions and uses the `contentHash` returned by `resources.list()` as an optimistic-concurrency baseline. A stale baseline throws `PluginApiError` with `code: "RESOURCE_CONFLICT"`. Replacements are currently limited to 100 MiB and require the resource to be synchronized and online. The host stores new bytes under a new object key and switches the database pointer conditionally, so a rejected update does not damage the previous object.
`resources.update()` uses the `contentHash` returned by `resources.list()` as an optimistic-concurrency baseline. A stale baseline throws `PluginApiError` with `code: "RESOURCE_CONFLICT"`. Replacements are currently limited to 100 MiB and require the resource to be synchronized and online. The host stores new bytes under a new object key and switches the database pointer conditionally, so a rejected update does not damage the previous object.
Subscribing to `note.*` events requires `notes:read`, subscribing to `tag.changed` requires `metadata:read`, subscribing to `template.*` requires `templates:read`, and subscribing to `resource.*` requires `resources:read`. The sync-queue status event carries no note or metadata content and requires no additional read permission.
Enabled plugins may subscribe to `note.*`, `tag.changed`, `template.*`, `resource.*`, and sync-queue events without capability gates.
Successful note, tag, template, and resource changes made through EdgeEver's normal repository layer—including user actions and plugin actions—feed the same plugin event stream. `workspace.synced` reports completed repository sync passes. Failed mutations do not emit success events.
## Templates API
Templates are shared workspace data rather than plugin-local settings. Reading them requires `templates:read`; creating and deleting require `templates:write`; updating requires both. Applying a template also requires `notes:write` because it creates a note:
Templates are shared workspace data rather than plugin-local settings. Enabled plugins can read, create, update, delete, and apply them without capability gates:
```ts
const template = await context.templates.create({
@@ -271,7 +271,7 @@ const note = await context.templates.use(template.id, notebookId);
context.events.on("template.updated", ({ template }) => console.log(template.name));
```
`templates.create({ noteId })` can capture an existing note and additionally requires `notes:read`. Plugins can also call `templates.list()` and `templates.delete(templateId)`.
`templates.create({ noteId })` can capture an existing note. Plugins can also call `templates.list()` and `templates.delete(templateId)`.
## Host-rendered settings
@@ -360,7 +360,7 @@ Reading returns `null` when no editable note is open; writes throw an error. `ed
### Plugin embeds
`ui:embeds` allows a plugin to register a renderer for its own constrained, block-level embed type. Inserting an embed additionally requires `editor:write`:
An enabled plugin can register a renderer for its own constrained, block-level embed type and insert it into the editor:
```ts
const disposeEmbed = context.editor.embeds.register({
@@ -385,7 +385,7 @@ The host assigns the embed ID and plugin ID, so a plugin cannot impersonate anot
## Note navigation
With `ui:navigation` declared, a plugin can open an existing note from a task, calendar, index, or search panel:
An enabled plugin can open an existing note from a task, calendar, index, or search panel:
```ts
await context.ui.openNote(noteId, { search: "- [ ] Ship release" });
@@ -469,13 +469,13 @@ The first demonstrates note queries, selection replacement, commands, and a cust
- Plugins run only while the app is open.
- Desktop plugins can persistently schedule their own registered commands, and users can manage those schedules and inspect paginated run history from the plugin page. A schedule is synced through the workspace, bound to one desktop device, and runs only while EdgeEver is open on that device. A missed occurrence can either be skipped or coalesced into one recovery run. This is not an always-on server background runtime.
- There is no webhook receiver, server background runtime, marketplace submission backend, or automated review pipeline.
- Declared permissions are API capability checks, not a hard sandbox for trusted JavaScript.
- Capability declarations are optional descriptive metadata, not API authorization or a sandbox.
- Custom panels open from the unified desktop plugin menu or extension settings and cannot yet be pinned to the main navigation or editor sidebar.
- Secret storage is device-local and does not sync to other devices.
## Generic AI and public network capabilities (unreleased)
Plugins may declare `ai:generate` to use the current workspace's default AI model. These calls take ordinary prompts; source parsing, business workflows and prompts belong to the plugin. Credentials stay in the host.
Enabled plugins may use the current workspace's default AI model. Declaring `ai:generate` is optional disclosure metadata. These calls take ordinary prompts; source parsing, business workflows and prompts belong to the plugin. Credentials stay in the host.
```ts
const status = await context.ai.status(); // { configured, modelName? }
@@ -489,7 +489,7 @@ const result = await context.ai.generate({
`system` is limited to 8,000 characters, `prompt` to 90,000, output to 5,000 tokens, and generation to 120 seconds. The backend requires an interactive user session, disables AI in public demo mode, and redacts provider errors. AI calls have a four-request per-workspace guard in each backend instance; this is not a distributed quota. Model charges follow the configured provider. Plugin deactivation aborts outstanding calls.
The default `network.fetch(url, init)` transport is a trusted browser request. It accepts arbitrary HTTP/HTTPS destinations, methods, bodies, request headers such as `Authorization`, and the requested browser credential mode. It remains subject to the runtime browser's CORS and cookie policy. `networkHosts` is legacy descriptive metadata and is not a security boundary. To read a cross-origin public feed or API without credentials, add both `network` and `network:public` and explicitly select `transport: "public"`.
The default `network.fetch(url, init)` transport is a trusted browser request. It accepts arbitrary HTTP/HTTPS destinations, methods, bodies, request headers such as `Authorization`, and the requested browser credential mode. It remains subject to the runtime browser's CORS and cookie policy. `networkHosts` is legacy descriptive metadata and is not a security boundary. To read a cross-origin public feed or API without credentials, explicitly select `transport: "public"`; listing `network` and `network:public` remains useful disclosure but is optional.
```json
{
@@ -513,4 +513,4 @@ The host selects the least expensive safe transport without changing the plugin
All native/server drivers share one policy package. Desktop and self-hosted Bun validate every DNS answer and pass the validated address directly to TLS; private, special-use and mixed public/private answers are rejected. Cloudflare fallback uses workerd's default public-only Internet egress and no private-service bindings. Synthetic VPN/fake-IP DNS answers in reserved ranges are rejected; do not disable this check. Nonstandard workerd deployments must preserve public-only global egress. The Web fallback returns bounded binary bytes rather than Base64 JSON, avoiding Base64's transfer expansion.
Plugin capability declarations primarily communicate intent and catch accidental API use; enabled plugins are trusted JavaScript, not a security sandbox. A plugin that can read notes and use the network can transmit those notes. The public transport intentionally grants anonymous read access to arbitrary public HTTPS hosts. Backend routes independently require user authentication and enforce public-only egress so the shared EdgeEver service cannot be used to reach private networks; they do not claim server-attested per-plugin isolation. The backend never receives a source enum, search window, evidence schema or report workflow.
Plugin capability declarations communicate intent; enabled plugins are trusted JavaScript, not a security sandbox. A plugin can read notes and transmit them over the network. The public transport intentionally grants anonymous read access to arbitrary public HTTPS hosts. Backend routes independently require user authentication and enforce public-only egress so the shared EdgeEver service cannot be used to reach private networks; they do not claim server-attested per-plugin isolation. The backend never receives a source enum, search window, evidence schema or report workflow.
+22 -22
View File
@@ -6,7 +6,7 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。
主题包只包含经过校验的 Manifest 和公开 Design Tokens,不执行 JavaScript。
客户端插件采用类似 Obsidian 的受信任代码模型。插件声明的权限会限制它通过 EdgeEver 插件上下文调用的 API,但插件模块本身仍运行在客户端 JavaScript 环境中。用户只能安装来自可信开发者的插件。
客户端插件采用类似 Obsidian 的受信任代码模型。启用插件即代表信任它使用完整的 EdgeEver 插件上下文;能力声明只是可选的描述性元数据,不限制 API 调用。插件模块运行在客户端 JavaScript 环境中,因此用户只能安装来自可信开发者的插件。
公开 API 不会向插件暴露 EdgeEver Repository、IndexedDB 数据库、Cloudflare Binding 或 React 内部状态。
@@ -42,7 +42,7 @@ styles.css(可选)
GitHub 插件的 `entry` 固定为 `./main.js`,`main.js` 必须是无需相对模块导入的单文件 Bundle。EdgeEver 会读取默认分支 Manifest、查找相同版本的 Release、并行下载资产、验证 GitHub 提供的 SHA-256 Digest(如果存在),然后把验证后的包缓存到当前设备的 IndexedDB。`main.js` 上限为 5 MB,`styles.css` 上限为 1 MB。
EdgeEver 会在插件市场页面打开、窗口重新获得焦点及每 30 分钟检查一次更新,但不会静默安装。用户必须点击「更新」并确认;如果新版新增插件权限或网络域名,确认框会明确列出新增访问范围。GitHub 分发的 Release `manifest.json` 必须与默认分支中用于提示更新的 Manifest 完全一致,否则安装会被拒绝。市场安装只跟随 Registry 中已经验证的新版本。
EdgeEver 会在插件市场页面打开、窗口重新获得焦点及每 30 分钟检查一次更新,但不会静默安装。用户必须点击「更新」并确认;如果新版改变能力声明或旧版网络域名元数据,确认框会列出这些变化供用户查看。GitHub 分发的 Release `manifest.json` 必须与默认分支中用于提示更新的 Manifest 完全一致,否则安装会被拒绝。市场安装只跟随 Registry 中已经验证的新版本。
升级采用可回滚切换:新旧版本的包会分别缓存;如果新版无法激活,EdgeEver 会恢复原 Manifest、原启用状态和上一版本代码,而不是留下一个被破坏或被停用的插件。
@@ -88,7 +88,7 @@ Registry 格式:
市场安装显示“已验证”,GitHub 或 Manifest 自由安装会明确显示未经验证的来源,但 EdgeEver 不阻止用户安装。卸载插件时会同时删除本机缓存的全部插件版本、当前工作区的普通插件存储和 Secret Storage。
当前支持以下权限:
以下能力声明仅用于披露和向后兼容,均为可选:
- `notes:read`
- `notes:write`
@@ -111,7 +111,7 @@ Registry 格式:
- `ui:panels`
- `ui:embeds`
通过 `context.network.fetch()` 访问网络时需要声明 `network` 能力,但不受静态域名列表限制。`networkHosts` 仅作为兼容旧版的描述性元数据保留,不参与拦截。插件需要无凭据读取跨域公开响应时,可以声明匿名只读的 `network:public` 传输。
通过 `context.network.fetch()` 访问网络不受能力声明或静态域名列表限制。`networkHosts` 仅作为兼容旧版的描述性元数据保留,不参与拦截。插件需要无凭据读取跨域公开响应时,可以使用匿名只读的 `network:public` 传输。
## 插件入口
@@ -149,7 +149,7 @@ export default definePlugin({
## 定时任务 API
桌面插件可以持久化定时执行自己的已注册命令。Manifest 需要同时声明 `ui:commands` 和 `schedules`,先注册命令,再用稳定的插件内计划键调用 `upsert()`:
桌面插件可以持久化定时执行自己的已注册命令。先注册命令,再用稳定的插件内计划键调用 `upsert()`:
```js
export default {
@@ -221,8 +221,8 @@ context.tags.delete("unused");
`notes.query()` 返回轻量摘要。当插件必须扫描多篇笔记的 Markdown 时,例如 Tasks 索引、日历、看板或 Linter,应使用 `notes.queryContent()`。两个接口每页最多返回 200 篇笔记;持续使用 `nextOffset` 翻页,直到它为 `null`。不需要正文时应优先使用摘要查询。
所有写入都经过 EdgeEver 的共享 Repository 和业务层,包括离线队列与桌面端适配器。插件不能直接访问具体存储实现。
`notes.update()` 同时需要 `notes:write` 和 `notes:read`,因为更新流程需要读取当前版本并会返回更新后的完整笔记;这可以避免写权限被间接用于读取笔记正文。
`notes.editMarkdown()` 同样需要这两项权限,并用 `notes.get()` 返回的 `revision` 与 `contentHash` 做乐观并发检查。它适合任务勾选、Linter 和索引维护等只修改 Markdown 局部内容的插件:
`notes.update()` 会读取当前版本并返回更新后的完整笔记。
`notes.editMarkdown()` 使用 `notes.get()` 返回的 `revision` 与 `contentHash` 做乐观并发检查。它适合任务勾选、Linter 和索引维护等只修改 Markdown 局部内容的插件:
```ts
const note = await context.notes.get(noteId);
@@ -237,28 +237,28 @@ await context.notes.editMarkdown(noteId, {
```
编辑区间使用 JavaScript UTF-16 字符串偏移和半开区间 `[from, to)`。一次调用中的区间不得重叠、越界或切开 Unicode 代理对。笔记基线已经变化或当前编辑器存在未保存内容时,宿主拒绝写入并抛出带 `code: "NOTE_CONFLICT"` 的错误;插件应重新读取并要求用户重试。无效区间使用 `code: "INVALID_MARKDOWN_EDIT"`。SDK 导出 `PluginApiError` 和 `PluginApiErrorCode` 供 TypeScript 插件缩小错误类型。
读取笔记本和标签需要 `metadata:read`,修改笔记本和标签需要 `metadata:write`。
启用后的插件无需能力门禁即可读取和修改笔记本与标签。
附件使用独立权限,并继续通过 Web/桌面端共用的 Repository 适配器执行:
附件继续通过 Web/桌面端共用的 Repository 适配器执行:
```ts
context.resources.list(noteId); // resources:read
const blob = await context.resources.read(resourceId); // resources:read
context.resources.upload(noteId, file); // resources:write
context.resources.list(noteId);
const blob = await context.resources.read(resourceId);
context.resources.upload(noteId, file);
context.resources.update(resourceId, { file, expectedContentHash });
context.resources.rename(resourceId, filename);
context.resources.delete(resourceId);
```
`resources.update()` 同时需要两项资源权限,并使用 `resources.list()` 返回的 `contentHash` 做乐观并发检查。基线过期时会抛出 `code: "RESOURCE_CONFLICT"` 的 `PluginApiError`。当前替换上限为 100 MiB,并要求资源已同步且设备在线。宿主会把新内容写入新的对象键,再有条件地切换数据库指针,因此被拒绝的更新不会损坏旧对象。
`resources.update()` 使用 `resources.list()` 返回的 `contentHash` 做乐观并发检查。基线过期时会抛出 `code: "RESOURCE_CONFLICT"` 的 `PluginApiError`。当前替换上限为 100 MiB,并要求资源已同步且设备在线。宿主会把新内容写入新的对象键,再有条件地切换数据库指针,因此被拒绝的更新不会损坏旧对象。
订阅 `note.*` 事件需要 `notes:read`,订阅 `tag.changed` 需要 `metadata:read`,订阅 `template.*` 需要 `templates:read`,订阅 `resource.*` 需要 `resources:read`。同步队列状态事件不包含笔记或元数据,因此无需额外读取权限。
启用后的插件无需能力门禁即可订阅 `note.*`、`tag.changed`、`template.*`、`resource.*` 和同步队列事件。
通过 EdgeEver 正常 Repository 层成功完成的笔记、标签、模板和资源变更——无论来自用户操作还是插件操作——都会进入同一条插件事件流。`workspace.synced` 用于报告已完成的 Repository 同步;失败的变更不会发送成功事件。
## 模板 API
模板是工作区共享数据,而不是插件私有设置。读取模板需要 `templates:read`;创建和删除需要 `templates:write`;更新需要两者。套用模板会创建笔记,因此还需要 `notes:write`:
模板是工作区共享数据,而不是插件私有设置。启用后的插件无需能力门禁即可读取、创建、更新、删除和套用模板:
```ts
const template = await context.templates.create({
@@ -271,7 +271,7 @@ const note = await context.templates.use(template.id, notebookId);
context.events.on("template.updated", ({ template }) => console.log(template.name));
```
`templates.create({ noteId })` 可以从已有笔记生成模板,此时还需要 `notes:read`。插件也可以调用 `templates.list()` 和 `templates.delete(templateId)`。
`templates.create({ noteId })` 可以从已有笔记生成模板。插件也可以调用 `templates.list()` 和 `templates.delete(templateId)`。
## 宿主统一渲染的设置
@@ -360,7 +360,7 @@ if (document) {
### 插件 Embed
`ui:embeds` 允许插件为自己的受约束块级 Embed 类型注册渲染器。插入 Embed 还需要 `editor:write`:
启用后的插件可以为自己的受约束块级 Embed 类型注册渲染器,并将其插入编辑器:
```ts
const disposeEmbed = context.editor.embeds.register({
@@ -385,7 +385,7 @@ await context.editor.insertEmbed({
## 笔记导航
声明 `ui:navigation` 后,插件可以从任务、日历、索引或搜索面板打开一篇现有笔记:
启用后的插件可以从任务、日历、索引或搜索面板打开一篇现有笔记:
```ts
await context.ui.openNote(noteId, { search: "- [ ] 发布版本" });
@@ -469,13 +469,13 @@ await context.ui.panels.open("dashboard", { state: { resourceId } });
- 插件只在应用打开期间运行。
- 桌面插件可以持久化定时执行自己的已注册命令,用户则可以在插件页面管理这些计划并分页查看执行记录。计划通过工作区同步、绑定一台桌面设备,并且只在该设备运行 EdgeEver 时执行;错过的计划可以选择跳过,或在恢复后合并补跑一次。这不是服务端常驻后台运行时。
- 暂无 Webhook 接收端、服务端后台运行环境、市场投稿后台和自动审核流水线。
- 权限声明属于 API 能力检查,不是针对受信任 JavaScript 的严格沙箱。
- 能力声明只是可选的描述性元数据,不是 API 授权或安全沙箱。
- 自定义面板可以从桌面端统一插件菜单或插件管理页打开,尚未支持固定到主导航或编辑器侧栏。
- Secret Storage 仅保存在当前设备,不会同步到其他设备。
## 通用 AI 与公开网络能力(尚未发布)
插件可声明 `ai:generate` 调用当前工作区的默认 AI 模型。接口只接受普通提示词;来源解析、业务流程和提示词属于插件,凭据保留在宿主。
启用后的插件可以调用当前工作区的默认 AI 模型,`ai:generate` 仅是可选的披露元数据。接口只接受普通提示词;来源解析、业务流程和提示词属于插件,凭据保留在宿主。
```ts
const status = await context.ai.status(); // { configured, modelName? }
@@ -489,7 +489,7 @@ const result = await context.ai.generate({
`system` 最多 8,000 字符,`prompt` 最多 90,000 字符,输出最多 5,000 token,生成最长 120 秒。后端要求交互式用户会话,公开演示模式禁用 AI,供应商错误脱敏。每个后端实例对每工作区的 AI 调用设置四路并发保护,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。
默认的 `network.fetch(url, init)` 是受信任的浏览器请求,可以访问任意 HTTP/HTTPS 地址,使用任意方法、正文、`Authorization` 等请求头以及调用方指定的浏览器凭据模式;它仍受所在运行时的 CORS 与 Cookie 策略约束。`networkHosts` 仅为兼容旧版保留,不是安全边界。需要无凭据读取跨域公开订阅或 API 时,同时声明 `network` 和 `network:public`,并显式选择 `transport: "public"`:
默认的 `network.fetch(url, init)` 是受信任的浏览器请求,可以访问任意 HTTP/HTTPS 地址,使用任意方法、正文、`Authorization` 等请求头以及调用方指定的浏览器凭据模式;它仍受所在运行时的 CORS 与 Cookie 策略约束。`networkHosts` 仅为兼容旧版保留,不是安全边界。需要无凭据读取跨域公开订阅或 API 时,显式选择 `transport: "public"` 即可;列出 `network` 和 `network:public` 仍有助于披露用途,但不是必需条件:
```json
{
@@ -513,4 +513,4 @@ const feed = await response.text(); // 插件自己解析。
桌面端、自托管与云端驱动共用同一策略包。桌面端和 Bun 自托管会校验全部 DNS 结果,并把已校验地址直接交给 TLS;私网、特殊用途和混合公私地址全部拒绝。Cloudflare 回退使用 workerd 默认的仅公开 Internet 出口,不使用私网服务绑定。VPN/fake-IP DNS 返回的保留地址也会拒绝,不应禁用检查;非标准 workerd 部署须保留仅公开网络出口。Web 回退返回有大小限制的二进制正文,不再使用 Base64 JSON,避免 Base64 的额外传输体积。
插件能力声明主要用于告知意图并发现误用;启用后的插件属于受信任 JavaScript,不是安全沙箱。一个同时能够读取笔记和访问网络的插件可以把笔记发送出去。公开传输有意允许匿名读取任意公开 HTTPS 域名。后端仍独立要求用户认证并限制仅公开网络,避免共享的 EdgeEver 服务被用来访问私网;它不声称提供服务端证明的插件隔离。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。
插件能力声明用于说明意图;启用后的插件属于受信任 JavaScript,不是安全沙箱。插件可以读取笔记并通过网络发送。公开传输有意允许匿名读取任意公开 HTTPS 域名。后端仍独立要求用户认证并限制仅公开网络,避免共享的 EdgeEver 服务被用来访问私网;它不声称提供服务端证明的插件隔离。后端不接收来源枚举、搜索时间范围、证据结构或报告流程。
+2 -4
View File
@@ -199,15 +199,13 @@ var parseExtensionManifest = (value) => {
throw new Error(`Unsupported plugin API version: ${String(value.apiVersion)}`);
if (typeof value.entry !== "string" || !value.entry.trim())
throw new Error("Plugin entry is required.");
if (!Array.isArray(value.permissions))
if (value.permissions !== undefined && !Array.isArray(value.permissions))
throw new Error("Plugin permissions must be an array.");
const allowedPermissions = new Set(PLUGIN_PERMISSIONS);
const permissions = [...new Set(value.permissions.map(String))];
const permissions = [...new Set((value.permissions ?? []).map(String))];
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
if (unsupported)
throw new Error(`Unsupported plugin permission: ${unsupported}`);
if (permissions.includes("network:public") && !permissions.includes("network"))
throw new Error("Public network transport also requires the network permission.");
const networkHosts = value.networkHosts === undefined ? undefined : Array.isArray(value.networkHosts) ? value.networkHosts.map(String) : (() => {
throw new Error("networkHosts must be an array.");
})();
+12 -1
View File
@@ -14,7 +14,7 @@ describe("extension manifests", () => {
})).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "schedules", "ui:commands", "ui:navigation", "ui:embeds"] });
});
test("rejects undeclared permissions", () => {
test("rejects unsupported capability metadata", () => {
expect(() => parseExtensionManifest({
type: "plugin",
id: "org.edgeever.bad",
@@ -62,6 +62,17 @@ describe("extension manifests", () => {
})).toMatchObject({ permissions: ["network"] });
});
test("normalizes an Obsidian-style trusted plugin without capability declarations", () => {
expect(parseExtensionManifest({
type: "plugin",
id: "org.edgeever.trusted",
name: "Trusted",
version: "1.0.0",
apiVersion: "1",
entry: "./main.js",
})).toMatchObject({ permissions: [] });
});
test("allows public read-only network plugins without a static host list", () => {
expect(parseExtensionManifest({
type: "plugin",
+2 -3
View File
@@ -613,12 +613,11 @@ export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
if (value.type === "plugin") {
if (value.apiVersion !== PLUGIN_API_VERSION) throw new Error(`Unsupported plugin API version: ${String(value.apiVersion)}`);
if (typeof value.entry !== "string" || !value.entry.trim()) throw new Error("Plugin entry is required.");
if (!Array.isArray(value.permissions)) throw new Error("Plugin permissions must be an array.");
if (value.permissions !== undefined && !Array.isArray(value.permissions)) throw new Error("Plugin permissions must be an array.");
const allowedPermissions = new Set<string>(PLUGIN_PERMISSIONS);
const permissions = [...new Set(value.permissions.map(String))];
const permissions = [...new Set((value.permissions ?? []).map(String))];
const unsupported = permissions.find((permission) => !allowedPermissions.has(permission));
if (unsupported) throw new Error(`Unsupported plugin permission: ${unsupported}`);
if (permissions.includes("network:public") && !permissions.includes("network")) throw new Error("Public network transport also requires the network permission.");
const networkHosts = value.networkHosts === undefined
? undefined
: Array.isArray(value.networkHosts)
+7 -6
View File
@@ -659,6 +659,7 @@ export const enUS = {
permissions: {
publicNetwork: "Read content from any public website",
},
trustedCodeNotice: "Enabling a plugin means trusting its code. Plugins can read and modify all notes, access the network, and use available host capabilities. Enable only plugins you trust.",
toggle: "Enable or disable {{name}}",
uninstall: "Uninstall",
settings: {
@@ -682,8 +683,8 @@ export const enUS = {
version: "Version",
source: "Source",
installedAt: "Installed",
permissions: "Permissions",
networkHosts: "Allowed websites",
permissions: "Declared capabilities (informational)",
networkHosts: "Declared websites (legacy metadata)",
},
updates: {
check: "Check for updates",
@@ -695,10 +696,10 @@ export const enUS = {
updating: "Updating…",
confirmTitle: "Update {{name}}?",
confirmDescription: "EdgeEver never installs updates automatically. Confirm to download and replace the current version while preserving its enabled state.",
addedAccessDescription: "This version requests additional access. Review these changes before updating.",
addedPermissions: "New permissions",
addedNetworkHosts: "Newly allowed websites",
allowAndUpdate: "Allow and update",
addedAccessDescription: "This version changes declared capabilities or legacy network metadata. Enabled plugins always have full plugin API access, so update only when you trust the source.",
addedPermissions: "New capability declarations",
addedNetworkHosts: "New website declarations (legacy metadata)",
allowAndUpdate: "Confirm and update",
},
noticeTitle: "Plugin",
panel: "Plugin panel",
+7 -6
View File
@@ -659,6 +659,7 @@ export const zhCN = {
permissions: {
publicNetwork: "读取任意公开网站内容",
},
trustedCodeNotice: "启用插件即代表信任其代码。插件可以读写全部笔记、访问网络并使用已开放的宿主能力;请只启用你信任的插件。",
toggle: "启用或停用 {{name}}",
uninstall: "卸载",
settings: {
@@ -682,8 +683,8 @@ export const zhCN = {
version: "版本",
source: "来源",
installedAt: "安装时间",
permissions: "权限",
networkHosts: "允许访问的网站",
permissions: "能力声明(仅供参考)",
networkHosts: "声明的网站(旧版元数据)",
},
updates: {
check: "检查更新",
@@ -695,10 +696,10 @@ export const zhCN = {
updating: "更新中…",
confirmTitle: "更新 {{name}}?",
confirmDescription: "EdgeEver 不会自动安装更新。确认后将下载并替换当前版本,原启用状态保持不变。",
addedAccessDescription: "此版本请求了新的访问权限。请确认这些变化后再更新。",
addedPermissions: "新增权限",
addedNetworkHosts: "新增允许访问的网站",
allowAndUpdate: "允许并更新",
addedAccessDescription: "此版本更改了能力声明或旧版网络元数据。启用插件始终具有完整插件 API 访问权,请确认来源可信后更新。",
addedPermissions: "新增能力声明",
addedNetworkHosts: "新增网站声明(旧版元数据)",
allowAndUpdate: "确认并更新",
},
noticeTitle: "插件",
panel: "插件面板",