news 2026/10/27 1:48:56

Cindy 插件文件下载接口:从 cindy.downloads API 到宿主缓存与 SSRF 防护的完整实现解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cindy 插件文件下载接口:从 cindy.downloads API 到宿主缓存与 SSRF 防护的完整实现解析
  • 人工智能
  • AI Agent
  • 代码智能体
  • 大模型
  • AI 技能
  • Agent 记忆
  • 工具调用
  • MCP Clients

【免费下载链接】cindy

Consider it done. The open-source AI agent that works out of the box · 想到,就能做到。开源、开箱即用的 AI Agent。

项目地址:https://gitcode.com/GitHub_Trending/cind/cindy
点击查看免费下载

本文以 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 安全校验
sha25664 位小写十六进制字符串,必填
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附加字段说明
queuedloaded: 0, total, speedBps: 0已入队,尚未开始传输
downloadingloaded, total, percent, speedBps传输中,percent由宿主按高水位计算
verifying视为 loaded=total正在做 SHA-256 校验
retryingattempt, delayMs第 N 次重试,延时 delayMs
completedfromCache校验完成;命中缓存时fromCache: true
failed/cancelled无终态

界面开发的两条硬规则(源自规范文档,且有对应实现):

  1. 校验、重试等阶段不保证具有loaded/total,界面应使用不确定进度(indeterminate),不能伪造百分比。
  2. 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)完成校验与注入:

  1. params.downloadTokens === undefined时原样透传——旧请求不受影响,存量插件无须迁移;
  2. 携带凭据时,params必须是对象且不得自带downloads字段(防插件伪造注入结果);downloadTokens对象最多 32 项,每个 key 必须匹配^[a-zA-Z][a-zA-Z0-9_]{0,127}$,且不能是constructor/prototype/__proto__;
  3. Host 依次校验账号(scope)、插件身份、批准身份(approval)和文件身份(receipt):token 必须属于当前账号、当前插件、当前批准身份,且文件的size/ino/mtimeMs与发凭据时一致、未被符号链接替换;
  4. 校验通过后注入params.downloads[name] = 宿主内绝对路径,Node 代码即可fs.readFile(params.downloads.archive);
  5. 路径仅在本次 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阶段即等待槽位排队的体现。

十、调用方失效与存量兼容

规范文档最后一段的三条规则,在源码中各有落点:

  1. 账号切换、插件批准身份改变或逻辑页销毁 → 中止下载,不向新账号发送进度:current()闭包同时校验「未停止、调用方存活、scope 未变、插件仍启用、approval 未变」(见 downloadSlot.ts),失效后 250ms 监视器触发 abort,emit也拒绝发出事件;
  2. 旧宿主不存在downloads对象时应提示升级,不能假装已接入进度:这是面向插件作者的兼容性要求——插件应做能力探测(cindy.downloads是否存在),缺失时显式告知用户升级客户端,而不是静默降级或伪造进度条;
  3. 现有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。

项目地址:https://gitcode.com/GitHub_Trending/cind/cindy
点击查看免费下载

相关推荐

上一篇:终端视频下载工具yoinks批量提取音乐实战教程
下一篇:interview-coach-skill故事映射引擎深度解析:4级匹配评分+7步冲突解决算法

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 1:47:59

res-downloader 使用教程:4 步把想存的视频从手机屏幕搬进硬盘

res-downloader 使用教程&#xff1a;4 步把想存的视频从手机屏幕搬进硬盘 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-downloader 某…

作者头像 李华
网站建设 2026/10/27 1:34:45

VMware Workstation Pro 完整卸载详细图文教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华