- 人工智能
- AI Agent
- 代码智能体
- 大模型
- AI 技能
- Agent 记忆
- 工具调用
- MCP Clients
【免费下载链接】cindy
Consider it done. The open-source AI agent that works out of the box · 想到,就能做到。开源、开箱即用的 AI Agent。
本文以 Cindy 的插件开发规范文档 plugin-download-api.md 为核心,系统讲解插件逻辑页如何使用cindy.downloads.start/cindy.downloads.cancel下载匿名公开文件,并结合桌面端宿主源码(主进程cindy-brain目录下的下载槽位与缓存模块)深入剖析其参数校验、URL 安全边界、进度事件模型、Node 交接凭据机制与 16 GiB 缓存回收策略。读完本文,你可以完整掌握:插件侧如何发起一次可校验、可追踪、可交接的下载;宿主侧每一道防线(HTTPS 主机白名单、单跳 SSRF 守门、SHA-256 + 精确字节数校验)在哪里实现;以及缓存预算、凭据生命周期等运维层面的实际行为边界。
一、接口总览:start 与 cancel
插件逻辑页通过宿主注入的cindy.downloads对象发起下载:
- 发起:
cindy.downloads.start({ id, url, sha256, bytes }) - 取消:
cindy.downloads.cancel({ id })
start的返回结构是两种形态之一:
// 成功 { "ok": true, "token": "...", "bytes": 123456, "sha256": "...", "fromCache": false } // 失败 { "ok": false, "message": "下载失败,请重试(网络、权限或文件校验未通过)" }返回字段含义:
| 字段 | 说明 |
|---|---|
token | 文件凭据(receipt),用于后续通过node.request交接给同插件的 Node 运行时,见第五节 |
bytes | 校验通过后的实际字节数,与请求的bytes相等 |
sha256 | 实际校验出的 SHA-256 摘要 |
fromCache | 是否命中了已校验的缓存文件,未发起真实网络传输 |
cancel返回{ ok: true }后,对应下载会被中止,进度事件以cancelled阶段收尾。
这一契约在宿主侧由PluginDownloadSlot类实现,入口是handle()方法,它对start/cancel两种kind分别处理,见 downloadSlot.ts。
二、使用前提:必须声明 node 与 network.hosts
规范文档明确要求:插件必须声明node与network.hosts,仅允许已声明的 HTTPS 主机。宿主源码中这是一条硬性检查,缺任何一项都直接拒绝:
// apps/desktop/src/main/cindy-brain/downloadSlot.ts#L149-L151 const ghost = this.deps.getGhost(id); if (!ghost?.enabled || !ghost.manifest.node || !ghost.manifest.network?.hosts.length) throw Error('Download requires node and network.hosts declarations');也就是说,一个插件即使只想用这个接口拉一个公开文件,也必须在 manifest 中同时声明node能力和非空的network.hosts域名白名单。这是 Cindy「知情面即权限面」设计的一部分:插件的出网面在批准时就固定为network.hosts声明的集合,下载接口不允许越过这个集合。
URL 的逐条校验规则
validateUrl函数(downloadSlot.ts)对每一跳 URL 做如下检查,任何一条不满足都会失败:
- 原始 URL 字符串长度不超过 8192;
- 不允许包含控制字符或空白字符(正则
/[\u0000-\u0020\u007f]/); - 协议必须是
https:,显式端口、userinfo(用户名/密码)一律拒绝; - hostname 必须命中
network.hosts中的某条模式,匹配逻辑复用ghostNetworkHostMatches(ghost.ts),支持*.example.com这类通配子域模式(通配匹配采用cap-probe.探针法,避免*.x.com意外匹配x.com本身)。
重定向每跳重新检查:文档中「重定向每跳重新检查」对应的是下载器每跳都会经过guardedOutboundFetch的beforeDispatch钩子,每次连接前复核 URL 是否仍在声明主机白名单内(见第四节)。
三、匿名公开文件定位:不携带任何凭证
该接口的定位是下载匿名公开文件,因此:
- 不携带 Cookie、账号凭证或自定义请求头;
- 需要带授权信息的请求应继续使用插件的
fetch槽(走secrets/ 注入凭证机制),两者职责分离; - 源码中下载请求显式以
credentials: 'omit'调用,确保不会继承任何环境凭证:
// apps/desktop/src/main/cindy-brain/downloadSlot.ts#L250-L255 return guardedOutboundFetch(url, { ...init, credentials: 'omit' }, () => { controller.signal.throwIfAborted(); // A revoked owner is final: do not let the downloader retry this hop. if (!isUrlAllowed(url)) throw new DownloadError('URL_POLICY', 'Download URL is not allowed'); });同时,面板(逻辑页)不获得宿主文件路径:文件落在宿主管理的缓存目录中,逻辑页只能通过token引用它,不能直接读取或指定本机目标路径(start的参数集合被严格限定为type/kind/id/url/sha256/bytes之外的字段会被拒绝,见 downloadSlot.ts)。
四、传输安全:单跳、DNS 固定、仅 HTTPS 的 SSRF 守门
插件提供的 URL 不直接交给裸fetch,而是通过下载器的request注入点走 guardedOutboundFetch。该函数(定义于apps/desktop/src/main/maker-host/outbound-fetch.ts,底层调用@cindy/browser-control-runtime的fetchSingleHopWithSsrFGuard)保证:
- 单跳:每次只允许一跳访问,宿主跟随重定向后对每一跳重新执行 URL 校验与 DNS 解析;
- DNS 固定(pinning):SSRF 守门在发包前解析目标地址,后续连接只允许打到已确认的地址,防止 DNS rebinding 绕过;
- 仅 HTTPS:
requireHttps默认开启,除非宿主显式批准私网例外(插件下载场景不存在这种批准); - 调用方失效即终局拒绝:每次连接前的
beforeDispatch钩子会复核 URL 是否仍被允许,一旦调用方(账号/插件/批准身份)已失效,抛出URL_POLICY错误码——这是终局拒绝,下载器不会把它当网络错误重试:
// apps/desktop/src/main/cindy-brain/downloadSlot.ts#L250-L255 // A revoked owner is final: do not let the downloader retry this hop. if (!isUrlAllowed(url)) throw new DownloadError('URL_POLICY', 'Download URL is not allowed');此外源码中还有一个细节:缓存命中时永远不会走到request回调;缓存未命中时,旧的artifact文件会保留在旁边,直到下载器完成写入并验证后才发布新文件(见 downloadSlot.ts 的注释)。
五、请求参数校验与单飞(single-flight)去重
参数约束
start的完整参数约束在 downloadSlot.ts 中实现:
| 参数 | 约束 |
|---|---|
id | 字符串,匹配^[\w-]{1,100}$(字母、数字、下划线、连字符,最长 100) |
url | 非空字符串,长度 ≤ 8192,必须通过 URL 安全校验 |
sha256 | 64 位小写十六进制字符串,必填 |
bytes | 安全整数,1 ≤ bytes ≤ 8 GiB(8 * 1024 ** 3),必填,且必须与真实传输结果精确一致 |
SHA-256 与精确字节数双双必填是刻意设计:传输完成后,下载器会用真实结果复核result.size !== p.bytes即报「Downloaded size mismatch」。这使插件可以提前承诺文件的精确身份(摘要 + 长度),任何篡改或半路截断都会导致整体失败而非返回一个「差不多对」的文件。
并发与去重
- 同一插件同一时刻最多 8 个在途下载(
this.active.size >= 8时抛「Too many active downloads」); - 以
[账号作用域, 插件 id, 请求 id]为键做单飞去重:相同id且url/sha256/bytes指纹相同的重复start会复用同一个 Promise,不会发起第二次传输; - 若相同
id但内容指纹不同,则报「Download id already in use」; - 若旧请求的调用方已失效,宿主持续监视器(250ms 间隔的
watch定时器)会中止过期请求,新的start先等待旧请求收尾再重新入队。
两小时运行上限
单次下载从获得队列槽位起最多运行 2 小时,包含传输和重试,不计排队等待。源码中直接传给下载器:
// apps/desktop/src/main/cindy-brain/downloadSlot.ts#L264 timeout: { totalMs: 2 * 60 * 60 * 1000 },超时后释放下载槽位并返回{ ok: false };文档特别强调,此限制只约束下载本身,不改变 Node 调用的期限(第五节的交接路径另有其生命周期)。
六、进度事件:download-progress 的阶段模型
逻辑页通过onHostMessage订阅宿主事件:
{ "type": "event", "name": "download-progress", "data": { "id": "...", "phase": "downloading", "loaded": 123, "total": 456, "speedBps": 789 } }phase全枚举:queued/downloading/verifying/retrying/completed/failed/cancelled。
各阶段的附加字段(由 downloadSlot.ts 的emit调用逐一决定):
| phase | 附加字段 | 说明 |
|---|---|---|
queued | loaded: 0, total, speedBps: 0 | 已入队,尚未开始传输 |
downloading | loaded, total, percent, speedBps | 传输中,percent由宿主按高水位计算 |
verifying | 视为 loaded=total | 正在做 SHA-256 校验 |
retrying | attempt, delayMs | 第 N 次重试,延时 delayMs |
completed | fromCache | 校验完成;命中缓存时fromCache: true |
failed/cancelled | 无 | 终态 |
界面开发的两条硬规则(源自规范文档,且有对应实现):
- 校验、重试等阶段不保证具有
loaded/total,界面应使用不确定进度(indeterminate),不能伪造百分比。 downloading阶段的loaded是显示高水位,不是真实传输位置。续传重试若从零开始,传输字节数会回退,但宿主上报的loaded只增不减:
// apps/desktop/src/main/cindy-brain/downloadSlot.ts#L195-L197 // Transport bytes may reset when a server ignores Range on retry. Keep // display progress stable without changing the bytes used for validation. let displayedLoaded = 0; // ... displayedLoaded = Math.max(displayedLoaded, Math.min(e.loaded, p.bytes));这与统一下载器公开契约中的约定一致:onProgress上报的是原始传输字节,跨重试的显示归一化由调用方负责(downloader/index.ts)。实际的校验仍以真实传输结果(文件长度 + SHA-256)为准,高水位只影响显示,不影响正确性。
另外注意:进度事件只在调用方仍然有效(current()为真)时发出——账号切换、插件批准身份改变后,进度不会泄露给新账号。下载完成事件(completed)仅表示文件校验完成;插件自身后续的解包、内容验证应另行展示,不要把「下载完成」等同于「内容可用」。
七、向 Node 交接文件:downloadTokens 与 params.downloads
面板拿不到宿主路径,但插件的 Node 运行时可以读取宿主管理区内的文件——通过凭据交接完成:
// 逻辑页调用 await cindy.node.request({ downloadTokens: { archive: token }, // token 来自 start 的返回值 params: { /* 其他业务参数,必须是对象 */ } });宿主在withNodeDownloads中(downloadSlot.ts)完成校验与注入:
params.downloadTokens === undefined时原样透传——旧请求不受影响,存量插件无须迁移;- 携带凭据时,
params必须是对象且不得自带downloads字段(防插件伪造注入结果);downloadTokens对象最多 32 项,每个 key 必须匹配^[a-zA-Z][a-zA-Z0-9_]{0,127}$,且不能是constructor/prototype/__proto__; - Host 依次校验账号(scope)、插件身份、批准身份(approval)和文件身份(receipt):token 必须属于当前账号、当前插件、当前批准身份,且文件的
size/ino/mtimeMs与发凭据时一致、未被符号链接替换; - 校验通过后注入
params.downloads[name] = 宿主内绝对路径,Node 代码即可fs.readFile(params.downloads.archive); - 路径仅在本次 RPC 生命周期内有效:宿主为该文件持有引用计数(pin),RPC 返回后立即
release;Node 必须在返回前完成读取或复制,且不得回传、不得记录路径。
源码中注入与释放的对应实现:
// apps/desktop/src/main/cindy-brain/downloadSlot.ts#L100-L113 const { downloadTokens: _tokens, ...request } = payload; return await run({ ...request, params: { ...((payload.params as object) ?? {}), downloads }, }); // ... finally 中逐个 release(),释放 pin任何一步校验失败都会返回{ ok: false, errorCode: 'INVALID_REQUEST', message: '下载凭据已失效,请重新获取文件后重试' },插件应重新start获取新凭据。
八、宿主缓存:16 GiB 预算、LRU 回收与凭据生命周期
缓存由 PluginDownloadCache 实现,关键参数与规范文档一一对应:
- 总预算:每账号 16 GiB(构造函数默认值
16 * 1024 ** 3),包含在途预留; - 每项 64 KiB 管理开销:每个缓存条目额外计
+65536字节,注释明确说明「空的成功失败尝试不是永久免费的」,防止插件用大量失败的小请求刷占目录; - 回收策略:按最近使用(mtime)从旧到新删除,直到总量回到预算内;
- 不淘汰项:正在下载的目录(在
reserved中)和被 Node 借用中的目录(在pins中)跳过; - 回收失败即失败:缓存满且没有可回收空间时,
reserve抛出「Download cache is full」,下载直接失败,不会挤占其他插件空间。
// apps/desktop/src/main/cindy-brain/downloadCache.ts#L74-L81 for (const entry of entries.sort((a, b) => a.time - b.time)) { if (total <= this.limit) break; if (entry.dir === dir || this.reserved.has(entry.dir) || this.pins.has(entry.dir)) continue; await fs.rm(entry.dir, { recursive: true, force: true }); total -= entry.bytes; } if (total > this.limit) throw Error('Download cache is full');凭据隔离与失效规则(对应「缓存由账号、插件、请求身份隔离」):
- 每条 receipt 绑定
scope(账号)+plugin+approval(批准身份)三元组,acquire时逐字段比对,任何一项不符即「Invalid download receipt」; - id 相同且内容相同可复用:同一个已校验文件,同账号同插件同批准身份重复
start会命中缓存并复用 receipt(fromCache: true); - 卸载回收:插件卸载时宿主调用
removePlugin,中止该插件全部在途下载、删除其整个缓存子目录并吊销凭据(见 index.ts 处的调用); - 重启/账号切换后凭据失效:
revokeAll()清空全部 receipts,已持有的 token 不再有效,插件应重新start取凭据;文件本身可能仍在磁盘上,重新start通常会以缓存命中方式快速完成。
缓存目录结构也做了严格防御:目录名是[scope, id, fingerprint]的 SHA-256 十六进制串,宿主会拒绝缓存路径中出现符号链接、非文件项或别名替换(见 downloadSlot.ts 中对祖先目录realpath与逐文件lstat检查),防止通过精心构造的缓存项逃逸到宿主其他目录。
九、独立下载队列:与宿主更新队列隔离
下载使用独立的消费者队列,不占用宿主的应用更新队列。源码中 createDownloader() 的注释写得很直白:
A consumer-owned queue; bulk plugin downloads never queue ahead of app updates.
download()(默认单例调度器)服务于宿主自身的更新、二进制下载等任务;createDownloader()为插件下载创建一个独立的Scheduler({ maxConcurrent: 1 })队列,在 cindy-brain/index.ts 中作为download依赖注入给PluginDownloadSlot。
两条队列的调度器共享同一个统一下载器内核,因此插件下载复用统一下载器的全部传输能力:
- 逐块写盘:写完一块才拉下一块,避免大文件整体缓冲;
- 重试与续传:基于
Range的断点续传与指数退避重试; - 写入侧车文件:
artifact.part(部分文件)+artifact.meta.json(元数据),校验通过后才发布为正式artifact,这也是进度高水位机制能够成立的底层原因——续传时.part可能从零增长,但旧文件始终在旁边。
队列行为上的一个推论:插件下载在自身队列内串行(同账号maxConcurrent: 1),但PluginDownloadSlot允许每插件最多 8 个在途「下载槽位」,两者组合的效果是——槽位控制的是插件侧的并发申请数,真实网络传输由串行调度器逐个执行,queued阶段即等待槽位排队的体现。
十、调用方失效与存量兼容
规范文档最后一段的三条规则,在源码中各有落点:
- 账号切换、插件批准身份改变或逻辑页销毁 → 中止下载,不向新账号发送进度:
current()闭包同时校验「未停止、调用方存活、scope 未变、插件仍启用、approval 未变」(见 downloadSlot.ts),失效后 250ms 监视器触发 abort,emit也拒绝发出事件; - 旧宿主不存在
downloads对象时应提示升级,不能假装已接入进度:这是面向插件作者的兼容性要求——插件应做能力探测(cindy.downloads是否存在),缺失时显式告知用户升级客户端,而不是静默降级或伪造进度条; - 现有
fetch、Node、Library 接口不变,存量插件无须迁移:withNodeDownloads对未携带downloadTokens的请求原样透传,fetch槽与 Library 接口不受本接口引入的任何改动影响。
十一、实战要点小结
给插件开发者的可操作清单:
- manifest 中先声明
node与非空network.hosts,否则start必然被拒; start的四个字段一个都不能省:sha256与bytes必须来自发布方对文件的静态校验,不能事后猜测;- 进度 UI 按 phase 分支处理:只有
downloading有可靠的percent,其余阶段一律用不确定进度;completed后再执行自己的解包/内容校验并单独展示; - 需要 Node 解包文件时,用
node.request的downloadTokens交接,在 Node 侧本次 RPC 内读完整并释放,不要缓存路径; - 遇到「下载凭据已失效」类错误,正确处理是重新
start(缓存命中时几乎零成本)再交接,而不是重试旧 token; - 大文件规划:单文件 8 GiB 上限、每账号 16 GiB 共享预算、串行传输队列,意味着插件应避免在同一账号下并发堆放多个超大文件。
相关源码入口,便于继续深入:
- 槽位与请求处理:apps/desktop/src/main/cindy-brain/downloadSlot.ts
- 缓存预算与凭据:apps/desktop/src/main/cindy-brain/downloadCache.ts
- 注入装配与卸载/切号回收:apps/desktop/src/main/cindy-brain/index.ts
- SSRF 守门出网通道:apps/desktop/src/main/maker-host/outbound-fetch.ts
- 统一下载器与独立队列:apps/desktop/src/main/downloader/index.ts
- 域名白名单匹配:apps/desktop/src/shared/ghost.ts
- 规范原文:docs/dev-rules/plugin-download-api.md
- 人工智能
- AI Agent
- 代码智能体
- 大模型
- AI 技能
- Agent 记忆
- 工具调用
- MCP Clients
【免费下载链接】cindy
Consider it done. The open-source AI agent that works out of the box · 想到,就能做到。开源、开箱即用的 AI Agent。
相关推荐
Cindy 插件宿主公开上下文解析:app-context 如何向插件暴露 region 与 locale
Cindy 插件宿主公开上下文解析:app context 如何向插件暴露 region 与 locale 本篇围绕 Cindy 插件协议中的「宿主公开上下文」
人工智能AI Agent代码智能体大模型AI 技能Agent 记忆工具调用MCP Clients多智能体浏览器控制GUI 自动化本地部署插件系统桌面应用移动开发前端跨平台语音任务调度交互助手Egg 安全插件 @eggjs/security 完整指南:从 CSRF 防御到 SSRF 防护的源码级解析
Egg 安全插件 @eggjs/security 完整指南:从 CSRF 防御到 SSRF 防护的源码级解析 @eggjs/security 是 Egg 框架内
后端Web框架Swift Package Manager 插件构建参数(PackageManager.BuildParameters)完全指南:从 API 到宿主实现
Swift Package Manager 插件构建参数(PackageManager.BuildParameters)完全指南:从 API 到宿主实现 导读
开发工具构建工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考