工作区界面没有文件系统和网络的直接访问权。持久化用宿主提供的 KV 和 Blob 存储,导入导出用系统对话框或拖放,联网由宿主代理。
存储分层
| 数据 | 使用 | 不要 |
|---|---|---|
少量设置、有界索引、blob_id | storage.kv | 存 Base64、全文、缩略图、无界数组 |
| 图片、文档、音视频、大块状态 | storage.blob | 把会话内的 src 当作持久地址 |
| 用户可见的备份和导入 | files.dialog | 获取或拼接系统路径 |
上限:KV 单项 64 KiB、每个插件总量 1 MiB;Blob 单个默认 64 MiB、每个插件总量默认 512 MiB。用户可以在「设置 → 插件」中调整 Blob 上限(单个 1–256 MiB,总量 64 MiB–8 GiB)。运行时请读 storage.getUsage() 返回的 blob_item_limit_bytes / blob_total_limit_bytes,不要把默认值写死在插件里。
KV
const state = await stabledgePlugin.storage.get("state");
await stabledgePlugin.storage.set("state", nextState);
await stabledgePlugin.storage.delete("state");
const usage = await stabledgePlugin.storage.getUsage();
getUsage() 返回 kv_used_bytes、kv_total_limit_bytes、kv_item_limit_bytes、blob_used_bytes、blob_total_limit_bytes、blob_item_limit_bytes。用量检查只用于提前提示,真正写入时仍要处理宿主返回的配额错误。
大型可编辑对象建议采用“Blob 存正文 + KV 存索引”:
- 写入新 Blob;
- 回读并校验;
- 更新 KV 索引,指向新的
blob_id; - 成功后删除旧 Blob;
- 失败时保留旧索引,清理新产生的孤儿 Blob。
Blob
const ref = await stabledgePlugin.storage.blob.put(blob, {
media_type: "application/json",
filename: "asset.json"
});
const reopened = await stabledgePlugin.storage.blob.resolve(savedBlobId);
const all = await stabledgePlugin.storage.blob.list();
await stabledgePlugin.storage.blob.delete(ref.blob_id);
Blob 引用
| 字段 | 类型 | 说明 |
|---|---|---|
blob_id | string | 持久标识,只保存它和必要的元数据。 |
src | string | 当前会话内的同源临时网址,关闭重开后会变化,不要持久化。 |
media_type | string | MIME 类型。 |
size_bytes | number | 字节数。 |
filename | string | null | 原始文件名。 |
created_at | number | 创建时间(Unix 秒)。 |
读取 Blob
Bridge 没有 storage.blob.get 之类的读取方法,读取一律通过 src 发起同源请求,图片和媒体可以直接设置元素的 src:
const current = await stabledgePlugin.storage.blob.resolve(blobId);
const response = await fetch(current.src);
if (!response.ok) throw new Error(`blob read failed: ${response.status}`);
const value = await response.json();
img.src = current.src;
允许存入的类型:图片和音频(按文件头识别)、application/pdf、application/zip、application/gzip,以及 application/json 和 text/plain(必须是 UTF-8,JSON 必须可解析)。不允许 SVG 和 HTML。
文件拖放
在清单工作区中声明 drops,同时申请 storage.blob。用户把文件拖入工作区(包括从应用内文件树拖拽)时,宿主整批校验扩展名、数量、大小和类型,写入插件 Blob 后发送事件:
stabledgePlugin.events.on("host.files_dropped", async ({ files }) => {
for (const file of files) await importBlob(file);
});
插件只收到 Blob 引用,拿不到系统路径。任何一个文件不合规时整批拒绝,不会发送“部分成功”的事件。
系统文件对话框
申请 files.dialog(建议设为可选),并同时申请 storage.blob:
const opened = await stabledgePlugin.files.openFile({
extensions: ["json", "png"],
max_bytes: 10 * 1024 * 1024,
title: "导入文件"
});
if (!opened.cancelled) await importBlob(opened.file);
const exported = await stabledgePlugin.files.exportBlob(ref, {
suggested_filename: "backup.json"
});
if (!exported.cancelled) showSaved(exported.bytes_written);
openFile 的 extensions 只能取以下白名单的子集:png jpg jpeg gif webp avif bmp wav mp3 flac ogg m4a pdf zip gz gzip json txt。用户取消是正常结果。目前没有持久的目录授权,也没有在授权目录内按相对路径读写的接口。
iframe 内容安全策略
宿主为工作区 iframe 注入以下 CSP:
default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self';
object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'
也就是说:脚本、字体、媒体只能来自包内文件和 Blob src;<img> 额外允许 data: 和任意 https: 图片;fetch 只能请求自身源(即 Blob src),外部请求一律走 net.fetch;不能嵌入子 iframe,不能提交表单。
网络请求
界面侧和 Worker 侧有各自的网络路径,权限归属不同,不能混用:
| 调用方 | API | 权限归属 | 范围 |
|---|---|---|---|
| 工作区 / 顶部栏页面 | stabledgePlugin.net.fetch | "worker": "host" | http/https 地址 |
| Worker | callHost("host.net.fetch", …) | 真实 Worker id | 需要该 Worker 的 net.fetch 权限,协议为 http/https;带凭据时必须 https 且命中 allowed_origins |
界面侧 net.fetch
const response = await stabledgePlugin.net.fetch("https://api.example.com/v1/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: "hello" }),
response_type: "json",
timeout_ms: 15_000
});
if (response.status === 200) render(response.body);
| 选项 | 说明 |
|---|---|
method | GET、HEAD、POST、PUT、PATCH、DELETE。 |
headers | 请求头。 |
body | 请求体字符串。 |
body_encoding | utf8(默认)、base64 或 binary_base64。后两种先解码再按字节发送,用于上传图片或 multipart,解码后不超过 32 MiB。 |
response_type | text(默认)、json、base64、blob。blob 需要当前有活动的工作区和 storage.blob。 |
timeout_ms | 超时时间。 |
max_bytes | 单次响应上限,宿主的硬上限(32 MiB)仍然生效。response_type 为 blob 时,还受用户设置的单个 blob 上限约束。 |
响应包含 status、headers 和 body_type:text / json / base64 时内容在 body 中,blob_ref 时在 blob 中。宿主不跟随重定向,不共享浏览器 Cookie。
- 界面侧会做以下检查:插件已启用、清单声明了 host 的
net.fetch、用户已授权、网址是 http/https。scope和connect只用于安装页展示,不是安全边界。 - 默认只能访问公网,访问局域网和本机服务需要单独申请权限,安装时由用户单独确认,之后可以撤销;界面侧和 Worker 侧规则相同,见局域网与本机地址。
- 服务地址由用户自行配置时,不要把地址写死在
connect或scope中,省略它们即可;改地址不需要重新打包。 - 未声明、未授权或插件未启用时返回
PLUGIN_HOST_SERVICE_DENIED,不可重试;网址不是 http/https 时返回PLUGIN_HOST_NET_DENIED。
Worker 侧 host.net.fetch
宿主检查以下几项:
- 该 Worker 没有相应的
net.fetch权限时,返回PLUGIN_WORKER_PERMISSION_DENIED; - 协议不是 http 或 https 时,返回
PLUGIN_HOST_NET_DENIED; - 默认只能访问公网,访问局域网和本机服务需要单独申请权限,安装时由用户单独确认,之后可以撤销,见局域网与本机地址。
带 credential_refs 的请求必须用 https 并通过 allowed_origins 校验,否则返回 PLUGIN_CREDENTIAL_ORIGIN_DENIED。错误统一以 JSON-RPC -32010 返回,message 格式为 CODE:msg。详见 Worker · 网络。界面侧的 host 授权不能让 Worker 联网,反之亦然。
局域网与本机地址
界面侧的 net.fetch、Worker 侧的 host.net.fetch,以及 events.connect 建立的连接目标(SSE、WebSocket、Webhook),默认都拦截以下地址。要访问它们,需要在清单中声明 net.local,并由用户授权。
- IPv4:
0.0.0.0/8、10.0.0.0/8、127.0.0.0/8、169.254.0.0/16、172.16.0.0/12、192.168.0.0/16、100.64.0.0/10、192.0.0.0/24、组播224.0.0.0/4、保留240.0.0.0/4、广播地址。 - IPv6:
::、::1、fc00::/7、fe80::/10、ff00::/8。::ffff:映射地址和64:ff9b::地址按其中嵌入的 IPv4 地址判定。 - 主机名:
localhost、*.localhost、*.local。
198.18.0.0/15:网址中直接写这一段的 IP 时拒绝;域名解析到这一段时放行,以兼容代理软件的 Fake-IP 模式。
- DNS:请求前宿主先在本地解析域名。解析结果中只要有一个地址被拒,整个请求就被拒绝;通过后,用这次解析的结果建立连接。
- 重定向:宿主不自动跟随,3xx 响应原样返回给插件。插件按
Location重新请求时,宿主会重新判定目标地址。 - 云元数据地址:
169.254.169.254和fd00:ec2::254一律拒绝,即使已获得net.local也不例外。 - 错误码:目标是局域网或本机地址、而插件没有
net.local权限时,返回PLUGIN_HOST_NET_PRIVATE_TARGET。Worker 侧按惯例包在 JSON-RPC-32010中,message为PLUGIN_HOST_NET_PRIVATE_TARGET:…。
宿主服务
services.invoke 只能调用宿主已登记的服务和方法,例如读取聚合系统指标:
const sample = await stabledgePlugin.services.invoke("host.system_metrics", "sample", {});
对应权限为 { "permission": "system.metrics.read", "worker": "host" }。它不是通用的命令通道,也不能用来转发 Worker operation。
打开外链
申请 ui.open_external,并在真实的用户点击中调用:
button.addEventListener("click", () =>
stabledgePlugin.ui.openExternal("https://example.com/help")
);
只接受绝对的 http(s) 网址。不要使用 window.open、顶层跳转、javascript:、file: 或带用户名密码的网址。
凭据与日志
- 长期凭据不要放进插件包、KV、快照、网址或前端源码;需要 API 密钥或 OAuth 时,使用清单的
credentials,由宿主保管和注入。 - 不要把 API 密钥、Cookie、Authorization、完整查询串或响应正文写进日志。
- 本机服务不可达时要显示离线或重连状态,不要让工作区白屏。