dsh-postapi-bridge

DSH PLUGIN浏览器与联网

仓库 ptrel1/dsh-postapi-bridge · npm dsh-postapi-bridge

浏览器与联网 未标注验证等级 安全审查:未标注 可信度:未标注 可安装插件
安装这个插件

为 MaiBot、飞书、微信等外部机器人与 CI/CD 提供 DSH 轻量 HTTP POST 调度网关,兼具多账号登录鉴权与 MCP 工具调用能力。

一句话选型结论

按资料字段整理

dsh-postapi-bridge 是浏览器与联网类的 DSH 可安装插件,未做沙盒验证,暂无安全审查结论,列出 4 项功能,有可直接复制的安装命令。验证通过不等于安全:插件会读写文件、执行命令或联网,装前先到仓库确认它做了什么;涉及密钥或系统权限的,建议先在隔离环境试装。

分类浏览器与联网 收录类型可安装插件 验证等级未标注验证等级 安全审查未标注
验证等级 UNKNOWN 未标注验证等级
GitHub 星标 0 复刻 0 · 未关闭 issue 0
最近更新 2026-09-28 创建于 2026-08-20
README 115.2 KB 正文体量,越大说明说明书写得越细

安装

可安装插件
在 DSH 里执行,装完用 dsh plugin list 确认已挂上;标了 L4 / L5 的命令已在沙盒里真实跑通
dsh plugin --profile web add dsh-postapi-bridge
  1. 1
    确认版本与运行环境

    仓库没有标注兼容版本与 Node.js 要求,先确认你的 DSH 版本能装,再继续。

  2. 2
    执行安装命令

    在 DSH 里执行上面的命令;多包仓库要注意区分具体子包名,别装错包。

  3. 3
    确认已挂载

    用 dsh plugin list 查看列表里是否出现这个插件,出现即挂载成功。

  4. 4
    试跑并检查权限

    加载后跑一个最小用例,确认它能正常工作;同时看它申请了哪些文件、命令或网络权限。

兼容 DSH 版本未标注版本不匹配时安装会报错
Node.js 要求未标注来自仓库 package.json 的 engines 字段
前置依赖无需要同时安装的同级依赖
npm 包名dsh-postapi-bridge可用 npm view 查看包信息

能做什么

4 项功能
字段来自项目仓库与公开索引,中文说明由本站标注

dsh-postapi-bridge:为 DeepSeek Harness 打通机器与人类双通道的鉴权网关

  • 免 Cookie 的 POST 任务调度网关
  • Bearer Token 鉴权与多账号登录
  • 直接调用 DSH 工具与 MCP
  • 请求溯源审计日志

上游标签

bridgedeepseek-harness-plugindsh-pluginpostapisecurity

验证等级与安全审查

未标注验证等级
自动化流程结论,不是人工审核
验证等级未标注验证等级上游未给出验证结论,安装前请自行确认
安全审查未标注静态分析结论,未标注不代表已通过审查
仓库健康度未标注按仓库活跃度与最近更新时间给出
可信度未标注综合仓库活跃度与验证结果

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。
  • 列出了 4 项功能,能直接看出它解决什么问题。

先想清楚的地方

  • 安全审查还没结论,装之前先看仓库改了什么、要什么权限。
  • 没有声明 Node.js 版本要求,版本不匹配可能装不上。
  • 验证通过不等于安全:插件会读写文件、执行命令或联网,装前先到仓库确认它做了什么。

更适合

  • 需要在浏览器与联网场景里给 DSH 补能力的人。
  • 能接受在本地装依赖、愿意自己确认权限的人。

不太适合

  • 只使用 DSH 官方内置能力、不接受第三方插件的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
插件验证等级安全审查星标兼容版本安装命令
dsh-postapi-bridge当前 未做沙盒验证 未标注 0 未标注 有
dsh-our-free-model L3 · 安装规范通过 低风险 8,796 未标注 有
dsh-web L3 · 安装规范通过 低风险 8,549 未标注 无
BrowserSkill L4 · 沙盒安装通过 低风险 8,456 未标注 有
dsh-pocket L5 · 运行验证通过 低风险 1,656 0.2.0-rc.2 等 12 个 有
Browser4 L4 · 沙盒安装通过 中风险 1,152 未标注 有

仓库档案

浏览器与联网
字段来自项目仓库与公开索引,中文说明由本站标注
作者 / 维护方ptrel1仓库 dsh-postapi-bridge · 默认分支 main
分类浏览器与联网integration
收录类型可安装插件声明了 dsh.bundle、可用 dsh plugin add 安装的条目
开发语言JavaScript仓库主语言
许可证MIT商用或二次分发前请确认条款原文
星标 / 复刻 / issue0 / 0 / 0收录时的数值,非实时
创建 / 更新2026-08-20 · 2026-09-28最后推送 2026-09-28
本站收录2026-09-23首次进入本站目录的时间

README 全文

115.2 KB
作者原文,中文版优先;只做安全转义,不改内容

dsh-postapi-bridge 🚪🔐

> DeepSeek Harness (DSH) 双半侧统一网关与鉴权桥梁插件
> 融合 人类端多用户安全登录 + 机器端轻量 HTTP POST / RESTful API 调度网关。
> ⭐ 主要目的之一:让 DSH 支持公网访问——用账号密码自助换取官方动态凭证,替代"登录服务器抓 token"(见「🎯 核心目的之一」)。

> ⚠️ 文档时效说明(20260923 核对):本文部分章节写于 DSH 0.1.2 时代,
> 其中「需修改 DSH 源码加 requireSession 扩展点」的路径已不适用于 0.1.5+——
> 官方已在 client/connection 内原生提供 browserAuth.isAuthenticated() 门禁,
> 无需再打源码补丁。改动细节见下方「🔐 公网账号系统」章节的现状标注。
> 阅读时以带 ✅/⚠️ 标注的现状说明为准。


📦 npm 发布状态

> ⚠️ npm 上的版本已滞后:registry 上最新为 0.1.0,而本仓库当前已是 0.6.6。
> 请勿以 npm 版本为准——0.1.0 缺少 sessionAuth 服务接线,且缺少其后的全部安全加固
> (会话指纹绑定、step-up 提权、伪回环绕过修复、机器通道 fail-closed 等)。
> 以本仓库为准(当前 0.6.7)。
>
> ✅ 推荐安装方式:从本仓库源码安装
> bash > dsh plugin --profile web add git+https://github.com/ptrel1/dsh-postapi-bridge.git >
> 🔗 npm 页面(版本较旧,仅供参考):https://www.npmjs.com/package/dsh-postapi-bridge


版本记录 v0.9.0(交付物临时签名下载)

  • 新增两个端点:为「present 过的交付物」签发有时效、绑用户的下载地址。
    客户端(含安卓)用普通 GET 即可拉文件,不必再组合 Cookie/Bearer。
接口 方法 鉴权 说明
/api/dsh/v1/file-ticket POST 已登录(插件会话) 为一条 present 记录签发临时下载票据
/api/dsh/v1/file/ GET 已登录 + 票据绑定本人 流式下载,支持 Range 续传
  • 有效期规则(两段独立):
签发 file-ticket  ──► 「待激活」,距 issuedAt < 48h 内有效
│
第一次下载请求    ──► 激活:activatedAt = now,此后可用到 now + 5h
│
激活后 5h 内     ──► 可任意下载(含 Range 续传、可重复请求,无次数限制)
now >= activatedAt + 5h         ──► 410
未激活 且 now >= issuedAt + 48h ──► 410
过期后           ──► 重新调 file-ticket 拿新票(唯一续期方式)

> ⚠️ 过期的是「票据」,源文件永远不删不改。
> 交付物是模型产出的工作区文件,生命周期属于用户/工作区;本插件只签发访问凭证,
> 无权也不该在任何过期路径上删除别人目录里的文件。

  • 签发请求:{ "sessionId": "...", "seq": 7, "index": 0 }
    (seq = deliverables/presented 事件的 seq,index = 该事件 files 数组下标,
    与官方前端 presentedFileUrl(sessionId, seq, index) 同一套坐标)。
  • 200:
{
"ok": true,
"url": "/api/dsh/v1/file/",
"issuedAt": 1730000000000,
"activateDeadline": 1730172800000,
"expiresAt": 1730172800000,
"activateWindowHours": 48,
"sessionHours": 5,
"filename": "报告.docx",
"size": 12345
}

> ⚠️ expiresAt 语义 = 待激活截止时刻,不是下载有效期。
> 真正的下载有效期从首次下载起算 sessionHours(默认 5h),签发时无法预知,
> 因此不可能提前写进响应。expiresAt 仅为向后兼容保留的歧义字段,
> 新客户端请读 activateDeadline + sessionHours,忽略 expiresAt(计划 0.10.x 移除)。

> 🔒 响应体不返回 ticketId。 它是服务端状态库的键,属于内部实现细节;
> 对外暴露会让攻击者能探测「某张票是否存在」,也会把内部键变成对外的兼容契约。
> 客户端只需要 url。
- 401 未登录;403 路径逃逸;404 present 记录不存在;413 超限(签发时就拒)
- 下载:GET 该 url。
- 200 文件流;401 未登录;403 签名无效/非本人/票据记录与 token 不一致;
410 票据过期或不存在;404 文件不存在;413 超限
- 必带 Accept-Ranges: bytes;Content-Disposition: attachment; filename*=UTF-8''(中文名已编码)
- Range: bytes=0-9 → 206 + Content-Range;越界 → 416 + Content-Range: bytes */
- token:{ v, tid, sid, seq, index, uid } 的 base64url + HMAC-SHA256,secret 与
session cookie 同源,校验用 timingSafeEqual。
- 载荷不含 exp:签发时还不知道激活时刻,写死 exp 与「首下起 5h」直接矛盾;
时效一律查服务端状态。
- 载荷保留 sid/seq/index/uid 是为了验签后不查库就能做前置校验,但不作为最终
授权依据
(最终以状态库 + 登录态为准)。
- tid(16 hex 随机)是状态库的键,与 token 分离:日志里可以安全地记 tid 排查,
但 tid 本身不能用于下载(仍需签名 token)。
- 路径安全:文件路径与会话 cwd 双向 realpath 后判归属,不用字符串前缀
——工作区内可能存在指向外部的符号链接(模型自己就能建),前缀判断会把
/cwd/link -> /etc/passwd 误判为「在工作区内」。另校验必须是普通文件。
- 状态文件:$DSH_HOME/data/dsh-postapi-bridge/file-tickets.json(与 access-log 同目录)

{
"v": 1,
"tickets": {
"a1b2c3d4e5f60718": {
"sid": "sess-abc", "seq": 7, "index": 0, "uid": "alice",
"issuedAt": 1730000000000, "activatedAt": null
}
}
}
  • load() 惰性读(mtime 变化时重读,支持多实例共享)
  • 落盘 writeFileSync(tmp) + renameSync 原子替换;写失败只记日志,不抛错
  • cleanup(now):插件启动时一次 + 每次签发时一次(惰性,无需定时器)。
    只删过期票据记录,绝不触碰源文件。
  • 并发激活:Node 单线程 + 该段全程无 await,因此「判空 → 赋值 → 落盘」在进程内
    天然原子,保证只允许一次激活、后到者沿用已存在的值。这是对运行时模型的显式依赖,
    已在代码注释中标明:若将来改成异步落盘,必须重新引入互斥。
  • 为什么「激活」发生在登录鉴权之前(校验顺序第 5 步 < 第 7 步):激活是票据自身的
    生命周期事件,与「谁在用」无关。若把激活挪到鉴权之后,未登录探测就能反复刷新 48h 计时
    —— 攻击者在第 47h59m 匿名探一下就能把等待期再续下去,票据永远不过期。
    现在「第一次被访问即激活」把票据生命周期锁死在首次访问那一刻,这是正确性保证:
    泄露出去的票被别人先激活,原主仍能在同一 5h 窗口内使用(窗口共享,不额外损失),
    但它绝不会因此获得额外的 48h 延期。
  • 审计:file-ticket-issued(tid/sid/seq/index/uid/filename/size)、
    file-ticket-activated(tid/uid/issuedAt/activatedAt/sessionDeadline)、
    file-download(落 finish/close,记 bytes/range/activatedAt)、
    file-download-denied(带 reason)。全程不记 token 本体;审计里的 subPath 对
    /file/ 脱敏为 /file/。
  • 依赖:读取 present 事件走官方公开服务 ctx.sessionQuery.readEvent
    (与官方 present-open 路由同一通道,支持冷读持久化会话)。宿主未装配该服务时
    端点回 503(fail-closed)。

配置项(新增,均有默认值;非正数一律回退默认):

键 默认 说明
downloadActivateWindowHours 48 票据「待激活」上限,自 issuedAt 起算
downloadSessionHours 5 首次下载后的可用窗口,自 activatedAt 起算
downloadMaxMB 200 单文件下载上限,超过则签发阶段即 413
  • 版本号:package.json 与 AUDIT_VERSION 同步为 0.9.0。

版本记录 v1.5.0(交付物「直链下载」+ 聊天流内可点击卡片)

用户需求(原话)

> 这个在聊天框里发的是 dsh 自带的那个我打不开,你不能做一个卡片然后卡片点击就是直接的下载地址吗
> c 然后加上下载地址时效就是我想要的功能

痛点:DSH 官方交付物文件卡片在用户的手机 WebView 里点了没反应,用户拿不到文件。
方案 C:插件自己渲染一张卡片,卡片本身就是直接的下载链接(``),
点击即下载,不经过任何 POST 签票。

① 新端点 GET /dl/ —— 直链下载

payload = base64url({ v: 2, sid, seq, index, uid, exp })
token   = payload + &#x27;.&#x27; + base64url(HMAC-SHA256(secret, payload))

与既有 file-ticket(语义一个字节都没改)的分工:

file-ticket(保留不动) dl/(本版新增)
有效期 48h 待激活 + 首下起 5h 窗口 纯时间窗,签发即开始计时
激活 首次下载激活 不激活
次数 不限 不限
服务端状态 有(file-tickets.json) 无(exp 在 token 里)
用途 复杂分享 卡片直链,点击即下
  • 有效期配置项 dlTicketHours,默认 48。
  • 鉴权:需要会话 cookie(人类通道,与 /files 一致);token 里的 uid 必须等于当前会话用户。
    无 cookie → 401;uid 不符 → 403;签名错 → 403;过期 → 410(文案明确说明过期);文件已删除 → 404。
  • 响应:文件流 + Content-Disposition: attachment; filename*=UTF-8&#x27;&#x27;…(RFC5987)+ 支持 Range(复用 parseRangeHeader)。

> 为什么 dl 票把 exp 写进 token,而 file-ticket 不写:
> file-ticket 的窗口从「第一次下载」起算,签发时无法预知激活时刻,只能存服务端状态;
> dl 票的窗口从「签发」起算,exp 在签发时即确定 ⇒ 纯无状态即可验签 + 判过期。
> 无状态是刻意的:/files 一次要给 N 个文件各发一张票,若每张都写状态库,
> 一次列表请求就写 N 次磁盘。

② /files 列表返回直链

每条 file 新增两个字段(ticketUrl 保留,兼容旧前端):

{
&quot;seq&quot;: 2365, &quot;index&quot;: 0, &quot;name&quot;: &quot;DSH-PostAPI-v0.3.0.apk&quot;, &quot;size&quot;: 722,
&quot;downloadUrl&quot;: &quot;/api/dsh/v1/dl/eyJ2IjoyLCJzaWQiOiJzZXNzLTEiLCJzZXEiOjIzNjUsImluZGV4IjowLCJ1aWQiOiJhbGljZSIsImV4cCI6MTc5MjgzMTUzNzEwN30.FAKE_SIGNATURE_FOR_DOC&quot;,
&quot;downloadExpiresAt&quot;: 1792831537107,
&quot;ticketUrl&quot;: &quot;/api/dsh/v1/file-ticket&quot;
}

③ 前端:聊天流内可点击卡片

  • 位置:conversation.chat.turnTail(与官方交付物卡片同一个 slot);只渲染属于当前 turn 的文件(按 turn 字段过滤)。
  • 数据来源:复用 loadSessionFiles 缓存,不新增请求。
  • 形态:整张卡片是 ``,点击 = 浏览器直接下载,不发任何 JS 请求。
  • 样式 dsa-dlc-*(与抽屉的 dsa-dl-* 分开),只用 --dsw-alias-* 语义 token,暗色模式自动跟随。
  • 文件名超长 ellipsis 截断;不显示 description(用户反馈过文字太长撑破)。
  • downloadUrl 缺失或格式不对 → 静默不渲染该卡片。
  • 独立 DOM + 独立 try/catch:不影响官方卡片渲染。

🔴 为什么卡片用 ` 而不是onClick + fetch`

这是用户的核心诉求,也是本次改动的唯一目的:

  1. 用户卡住的正是那条链 ——「点击 → 前端发请求 → 再导航」。官方卡片在手机 WebView 里那一步没走通,
    用户点了没反应、拿不到文件。再用 fetch 换地址,等于重演同一个坑。
  2. ` 由**浏览器原生**处理:不依赖 JS 跑通、不依赖fetch` 成功、不依赖 WebView 的 XHR 实现。
    只要请求带上会话 cookie,浏览器自己就会发这个 GET 并落盘。
  3. 原生下载走系统下载管理器:手机上有进度、可断点续传(后端支持 Range)、可转存/分享 ——
    这些是 fetch + Blob 给不了的。
  4. 同源 cookie 会随浏览器发起的下载请求天然携带,不需要 credentials: &#x27;same-origin&#x27; 之类 JS 手段。
  5. 失败可见:401/403/410 时浏览器显示后端文案,而不是我们这一层的「点了没反应」。

⇒ 静态断言(test/dl-card.mjs ③b)钉住:卡片区域不得出现 onClick / fetch / triggerBrowserDownload / location.href / window.open。

④ 保留现有能力

  • 抽屉(conversation.session.header.actions)继续保留 —— 它是会话级汇总视图。
  • file-ticket 端点逻辑不动(test/dl-ticket.mjs B16 守着)。
  • 官方 deliverables.file.actions 下载按钮保留。

🔴 顺带修的正经 bug:文件被删后返回 403 而不是 404

resolveSafeFilePath 里 isRealpathInside 对「路径不存在」(realpath ENOENT)与
「路径在 cwd 之外」都返回 false,而调用方把 false 一律翻成 &#x27;escape&#x27;(403)。后果实测:

  • 下载已删除的交付物 → 403 Forbidden(应 404);
  • /files 的 skippedReasons 记成 path-escape(实际只是文件没了)。

用户看到「没权限」,排查方向被彻底带偏。修法:先 lstat 判存在(不存在 → missing → 404),
再做 realpath 归属判定。安全性不变(符号链接的归属判定仍由 isRealpathInside 完成)。

测试

文件 覆盖
test/dl-ticket.mjs 纯函数 6 项 + 端到端 16 项:正常下载字节一致、Range(206)/后缀 Range、过期 410、边界两侧、签名篡改 403、uid 不符 403、无 cookie 401、文件删除 404、伪造坐标 404、不限次、/file-ticket 未变
test/dl-card.mjs turn 过滤、不重复、老数据不误挂、downloadUrl 缺失静默降级、`` 结构、不得 onClick/fetch、不显示 description、独立命名、ellipsis 三件套、暗色 token、复用缓存、挂载点与 try/catch
test/_dl-mutation-check.mjs 改坏验证(单独运行):去时效 / 去 uid 校验 / 卡片改 onClick+fetch / 去 turn 过滤 / 不下发 downloadUrl —— 5 个变异全部必须变红

> ⚠️ 端到端用例的会话指纹必须是 uaClass: &#x27;mobile&#x27; + 真机 UA。
> verifyFingerprint 对 uaClass === &#x27;other&#x27;(node/curl 类 UA)一律拒绝;
> 早期用 node-test UA 的用例只是侥幸没走到指纹校验,一旦真走就会 403。


版本记录 v1.4.1(修:同一秒内多次上报互相覆盖 · 丢日志)

Bug(用户实测)

同一 IP 在同一秒内连续两次 POST /client-log,返回同一个文件名,落盘只剩最后一次:

第一次: {&quot;ok&quot;:true,&quot;stored&quot;:true,&quot;file&quot;:&quot;20261022-114349-12ca17b4.json&quot;,&quot;bytes&quot;:430}
第二次: {&quot;ok&quot;:true,&quot;stored&quot;:true,&quot;file&quot;:&quot;20261022-114349-12ca17b4.json&quot;,&quot;bytes&quot;:539}

根因

v1.4.0 的文件名是 &lt;到秒的时间戳&gt;-.json。
唯一区分量是 IP —— 同一 IP 同一秒的多次上报必然同名,而 rename 会覆盖已存在文件,
于是 snapshot / perf / flush 同秒到达时互相吞日志(手机上正是高频上报)。

修法

  • 文件名 → -&lt;16位随机hex&gt;.json:时间戳精确到毫秒 + 8 字节密码学随机,
    同一毫秒内并发也不撞(碰撞概率 ~2⁻⁶⁴);IP 彻底退出命名。
  • 时间戳改用本地时间(不再 toISOString() 的 UTC):运维按本地时间找文件不会差一个时区。
  • 写盘 tmp 文件名加 pid + 随机 后缀(旧实现恒为 .tmp,并发写同一目标会互相截断)。
  • apply(ctx, config) 支持可注入 config.now(仅供测试把"同一毫秒"做成确定性用例,生产恒为 Date.now)。

测试

  • 新增 test/client-log-naming.mjs:同一毫秒取名 1000 次不重复;时钟钉死后并发 5 次上报 → 5 个不同文件且内容都在;
    真实时钟下并发 5 次 / 串行 10 次 → 各自落盘、无覆盖;目录无 .tmp 残留。
  • test/_mutation-check.mjs 新增变异「去掉文件名随机后缀」——并发用例必须变红(防假绿)。

版本记录 v1.4.0(客户端日志自动上报 · 远程诊断手机端卡顿)

背景(为什么要做这个)

用户反馈:在安卓 App 的 WebView 里输入,比用 Edge 浏览器明显卡。
但手机上拉不到 Console 日志(没有 adb 环境)——现场不可观测,排查无从下手。

于是做成「自动上报」:前端把环境快照 / 长任务 / 掉帧 / 错误批量打到服务端落盘,
运维在电脑上直接读文件即可。

🔴 为什么这个端点免鉴权(本功能唯一的安全妥协)

用户手机上的 /files 返回 401 —— WebView 里没有插件会话 cookie(登录态在 App 侧,
与浏览器登录是两条链路)。日志端点若要求 cookie 或 Bearer,用户就永远传不上来,
「远程诊断」这个唯一目的即落空。

因此 /client-log 与 /mobile-login 同级:在任何 token / cookie 校验之前分流。
(test/client-log-static.mjs ① 用行号断言把位置钉死,防止后人挪动。)

安全性不靠凭据,靠下面五条限额组成的边界:

# 限制 作用
① 单次请求体 ≤ 256 KB(超 → 413) 防单次大包打满内存/磁盘;readBody 边读边计数,超限立刻断开
② 每 IP 每小时 ≤ 20 次(超 → 429) 防刷;独立计数器,不与 stepupFails 混用(语义不同)
③ 目录总量 ≤ 50 MB 超限自动删最旧文件
④ 只保留最近 50 个文件 同上(两条上限都触发删除)
⑤ 字段白名单 + entries ≤ 200 多余字段丢弃;超出保留最新(末尾),并记 meta.receivedEntries 让截断不静默

残余风险(已明确接受):匿名者可写脱敏后的垃圾数据挤掉旧日志。
缓解:条数/体积上限决定「挤掉」需要持续大量请求,且每次都会留下审计记录(client-log-received)。

🔴 脱敏(最重要的一环,且必须发生在写盘之前)

日志来源是页面里的一切文本 —— 包括 console.error 的实参,而 DSH 的错误信息里
经常夹着请求 URL、Authorization 头、后端回显的 token。这些一旦落盘并被人拉走,
就等于把会话凭据永久留档。

因此 normalizeClientLogRecord() 在写盘之前对每个字符串字段调用脱敏;
test/client-log-static.mjs ⑥ 断言 normalize 的行号 早于 writeClientLogFile。
覆盖的模式见下节「脱敏规则」。

脱敏规则(具体实现)

规则 正则要点 命中示例
A-1 Authorization 头 (\&quot;?authorization\&quot;?\s*[:=]\s*\&quot;?)(?:(?:bearer\|basic\|token)\s+)?([A-Za-z0-9._~+/=\-]{8,}) Authorization: Bearer sk-live-DEADBEEF → ``
A-2 裸 Bearer / Basic ((?:bearer\|basic)\s+)([A-Za-z0-9._~+/=\-]{8,}) Bearer abcdef123456 → Bearer
B 敏感键值对 键名白名单逐个生成正则(见下),值支持 &quot;…&quot; / &#x27;…&#x27; / 反引号 / 裸值 token=abc123、dsh_postapi_session=xyz、password=hunter2、&quot;apiKey&quot;:&quot;sk-…&quot;、cookie: …
C 长 hex (?&lt;![0-9a-fA-F])[0-9a-fA-F]{32,}(?![0-9a-fA-F]) sha256/HMAC/签名 → ``
D 长 base64url (?&lt;![A-Za-z0-9_\-+/=])[A-Za-z0-9_\-+/=]{32,}={0,2}(?![A-Za-z0-9_\-+/=]) 随机 token / JWT 段 → ``

B 的键名白名单(CLIENT_LOG_SECRET_KEYS):
dsh_postapi_session · dsh_stepup · dsh-auth-* · authorization · cookie · set-cookie
· token · access_token · refresh_token · id_token · password · passwd · pwd
· secret · sessionsecret · api_?key · apikey

执行顺序:A → B → C → D。顺序有意义 —— 先处理「键值对」再处理「裸串」,
否则 value 会被拆成两半(残留片段)。

另外两处防护:
- extra 字段递归脱敏(对象/数组/嵌套都走 redactClientLogValue,深度上限 6 防环);
- 截断顺序是「先脱敏 → 再截断 → 再脱敏」(先截断可能把 32 位 token 截成 31 位而漏网;
截断本身又可能切出新的「32 位 hex」形态)。

误伤控制:四条规则都要求「键名/协议前缀」或「长度 ≥ 32 且字符集符合」,
实测普通文本(UA、错误栈、中文)零误伤(见 test/client-log.mjs 的快照用例)。

存储

$DSH_HOME/data/dsh-postapi-bridge/client-logs/-&lt;16位随机hex&gt;.json
例:20261022-194532-645-2dd03ae1e0eac39b.json
  • 目录复用 auditDataDir(config)(与 access-log.jsonl 同根,不进 git、不被 sync.sh 清理);
  • 文件名不参与任何身份标识:时间戳(本地时间)精确到毫秒 + randomBytes(8)(16 位 hex)。
    不用 IP —— 同一 IP 高频上报是常态,拿 IP 当区分量必然在"同一秒"上撞车(v1.4.1 修的正是这个);
  • 原子写盘:先写 .tmp(tmp 名含 pid + 随机,避免并发共用同一 tmp)再 rename;
  • 清理时机:写盘之后再 prune(先保证最新一份在,即使进程在清理中途被杀)。

请求体(字段白名单,其余丢弃)

{
&quot;source&quot;: &quot;web&quot; | &quot;app&quot;,
&quot;kind&quot;: &quot;snapshot&quot; | &quot;perf&quot; | &quot;error&quot; | &quot;flush&quot;,
&quot;ua&quot;: &quot;&quot;,
&quot;env&quot;: {
&quot;screen&quot;: [390, 844],
&quot;dpr&quot;: 3,
&quot;deviceMemory&quot;: 4,
&quot;hardwareConcurrency&quot;: 8,
&quot;supports&quot;: { &quot;webgl&quot;: true, &quot;compression&quot;: false }
},
&quot;entries&quot;: [
{ &quot;t&quot;: 1792661621796, &quot;level&quot;: &quot;warn&quot;, &quot;kind&quot;: &quot;longtask&quot;, &quot;duration&quot;: 320, &quot;msg&quot;: &quot;...&quot; }
]
}

响应:

状态 含义
200 { ok:true, stored:true, file, bytes, entries, truncated, pruned }
400 非法 JSON
405 非 POST(GET 同名路径走原 fail-closed 逻辑)
413 超 256 KB
429 该 IP 一小时内已上报 20 次
500 落盘失败(不谎报 200)

审计

每个请求一条 client-log-received:ip / ua / source / kind / bytes / storedBytes
/ entries(条数)/ receivedEntries / truncated / file / prunedFiles / usedInWindow。

🔴 不记日志内容本身(内容已在文件里,且可能含用户文本)。
另有 client-log-throttled(429)与 client-log-rejected(413 / 400)两类拒绝记录。

前端自动采集(lib/client.js)

> 🔴 开关说明:配置项 clientLogAutoReport(默认 true)。
> 但前端拿不到 config —— client entry 由宿主以 { name } 创建,从不透传插件 config
> (同一已知边界见 apiPrefix 处的说明)。因此前端固定开启。
> 现场临时关闭的逃生阀:localStorage.setItem(&#x27;dsh-postapi-client-log&#x27;, &#x27;off&#x27;) 后刷新。

采集项 触发 说明
snapshot 页面加载后立即一次 ua(关键:能看出 WebView 内核版本)/ screen / dpr / deviceMemory / hardwareConcurrency / supports{webgl,compression}
longtask PerformanceObserver([&#x27;longtask&#x27;]) 只记 > 100 ms
fps requestAnimationFrame 采样 FPS < 30 持续 2 秒才记一条(每满 2 秒记一次并清零,避免刷屏)
fid PerformanceObserver([&#x27;first-input&#x27;]) 首次输入延迟
cls PerformanceObserver([&#x27;layout-shift&#x27;]) 单次布局偏移值
error window.onerror / unhandledrejection / console.error 包装 立即上报(单条也发)
flush 每 60 秒 或 visibilitychange → hidden 只传上次上报之后的新条目;空则不发请求

设计红线(每条对应一个坑):

  1. 不污染 console:console.error 包装后用 apply 调原函数、参数原样透传、返回值原样返回,
    自身任何异常都被吞掉(采集失败绝不能影响控制台输出);卸载时恢复原函数。
  2. 回调里只 push:PerformanceObserver / rAF 回调只入队,脱敏与序列化留到上报时
    —— 长任务本身说明主线程紧张,回调里再做重活就是雪上加霜。
  3. 页面不可见时暂停 rAF 采样(document.hidden),重新可见恢复。
  4. 失败静默、不重试:fetch(...).catch() 一律吞掉,绝不弹提示、绝不安排重试(避免断网雪崩)。
  5. 不阻塞页面:fetch + keepalive: true,且不 await。
  6. 不用 unload(浏览器会丢弃其中的异步请求),改用 visibilitychange → hidden。
  7. 只传增量:每条 entry 带自增 seq,上报成功才把 lastSentSeq 推到已发送的最大值;
    失败则不动 → 下次 flush 重发同一批(不丢数据、不重复)。
  8. 队列上限 500 条(超出丢最旧的):一次 flush 上限 200 条,500 足够覆盖几次失败的积压。

测试

文件 项数 覆盖
test/_e2e-client-log.mjs 56 免鉴权 200 / 文件创建 / 413 / 429 / 脱敏原文不落盘 / 截断保留最新 / 白名单 / 50 文件与 50MB 清理 / 非法输入不炸
test/client-log.mjs 20 环境快照字段 / 长任务阈值(真实 install 路径)/ FPS 持续 2 秒 / 隐藏暂停 / 增量不重复 / 失败静默不抛 / console.error 原行为
test/client-log-static.mjs 15 分流位置行号(早于 token/cookie 校验)/ 五条限额存在 / 脱敏早于写盘 / 审计不含内容 / prune 只删 .json
test/_mutation-check.mjs 3 🔴 改坏验证:去掉脱敏 / 去掉大小限制 / 去掉限频 —— 每个都必须让测试变红
node --test test/*.mjs                  # 全量(316 项)
node test/_e2e-client-log.mjs           # 服务端端到端(打印逐条结果)
node test/_mutation-check.mjs           # 改坏验证(必须「全部被捕获」)

数据样例(脱敏后的真实形状,假数据)

{
&quot;v&quot;: 1,
&quot;receivedAt&quot;: &quot;2026-10-22T10:29:30.123Z&quot;,
&quot;source&quot;: &quot;app&quot;,
&quot;kind&quot;: &quot;perf&quot;,
&quot;ua&quot;: &quot;Mozilla/5.0 (Linux; Android 13; wv) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36&quot;,
&quot;env&quot;: {
&quot;screen&quot;: [390, 844], &quot;dpr&quot;: 3, &quot;deviceMemory&quot;: 4, &quot;hardwareConcurrency&quot;: 8,
&quot;supports&quot;: { &quot;webgl&quot;: true, &quot;compression&quot;: false }
},
&quot;entries&quot;: [
{ &quot;t&quot;: 1792661621796, &quot;level&quot;: &quot;warn&quot;, &quot;kind&quot;: &quot;longtask&quot;, &quot;duration&quot;: 320, &quot;msg&quot;: &quot;longtask 320ms&quot; },
{ &quot;t&quot;: 1792661623401, &quot;level&quot;: &quot;warn&quot;, &quot;kind&quot;: &quot;fps&quot;, &quot;duration&quot;: 22, &quot;msg&quot;: &quot;fps 22 for 2013ms&quot; },
{ &quot;t&quot;: 1792661625100, &quot;level&quot;: &quot;info&quot;, &quot;kind&quot;: &quot;fid&quot;, &quot;duration&quot;: 180, &quot;msg&quot;: &quot;first-input delay 180ms&quot; },
{ &quot;t&quot;: 1792661626900, &quot;level&quot;: &quot;info&quot;, &quot;kind&quot;: &quot;cls&quot;, &quot;duration&quot;: 0.0421, &quot;msg&quot;: &quot;layout-shift 0.0421&quot; },
{ &quot;t&quot;: 1792661630200, &quot;level&quot;: &quot;error&quot;, &quot;kind&quot;: &quot;console.error&quot;,
&quot;msg&quot;: &quot;Failed to load /api/dsh/v1/files?sessionId=abc&amp; 401&quot; },
{ &quot;t&quot;: 1792661630300, &quot;level&quot;: &quot;error&quot;, &quot;kind&quot;: &quot;error&quot;, &quot;duration&quot;: null,
&quot;msg&quot;: &quot;Cannot read properties of undefined (reading &#x27;map&#x27;) @ bundle.js:1204&quot;,
&quot;extra&quot;: { &quot;source&quot;: &quot;window.onerror&quot; } }
],
&quot;meta&quot;: { &quot;receivedEntries&quot;: 6, &quot;keptEntries&quot;: 6, &quot;truncated&quot;: false }
}

注意:/apis/dsh/v1/files?sessionId=abc&amp; 里的 就是脱敏结果 —— 原串是 `?token=xxx`,落盘后只剩。

🔴 实现时踩到的坑(记录下来免得后人重踩)

这也是一份 Node 侧测试陷阱清单(test/_installer-harness.mjs 文件头有完整版):

  1. new Function(src) 求值出的函数,闭包绑定到全局作用域;而 globalThis.window = x
    只写属性、不产生全局变量绑定 → bundle 内部 typeof window 恒为 undefined。
    表现:安装器第一行 if (!win ...) return function () {} 直接早退,看起来像"什么都没装"。
    Chromium 不会这样(浏览器 window 既是全局对象属性又有 var 语义)。
    ⇒ 测试夹具统一用 (0, eval) 让被测代码落在真正的全局作用域。
  2. 只重新求值「安装器」也没用:它引用的模块级常量(阈值)属于原闭包 → ReferenceError
    → 被安装器自己的 try/catch 吞掉 → "装上了但什么都不采",比①更难发现。
    ⇒ 安装器改为接受 thresholds 参数(浏览器不传 → 用模块常量),测试显式注入。
  3. 替换回调的参数个数:s.replace(re, function (m, head) {...}) 里 head 是第一个捕获组;
    若正则的实际第一个组是整串(例如 ((?:bearer|basic)\s+) 之外的写法),head 会拿到匹配到的数字字符
    —— 实测症状是脱敏结果变成 0。写替换回调时必须数清捕获组。
  4. 静态断言的「行距」会被无关代码干扰:test/download-button-static.mjs 用
    「credentials 行与 /file-ticket 行距 < 8」判定,任何新增的 credentials: &#x27;same-origin&#x27;
    都会抢占 lineNo 的第一处命中。⇒ 新代码里别写同样的字面量,或在注释里也别写。

版本记录 v1.3.0(App 在线更新支持 · 服务端)

设计取舍

用户排除的方案:npm 发布 / 会话 present / 独立文件服务器。
最终方案:数据目录放文件 + 本插件只读分发。

为什么放数据目录而不是插件目录:
- 插件目录在 npm 安装 / sync.sh 重部署时会被 rm -rf 再拷一份,APK 会丢;
- 数据目录天然不进 git、不被 sync 复制、npm install 也不碰;
- 与审计日志同根(auditDataDir),运维只需记一个位置。

目录约定

$DSH_HOME/data/dsh-postapi-bridge/app/
├── app-latest.apk          ← 最新 APK
└── app-version.json        ← { version, code, notes, publishedAt, size, sha256, filename }

本插件只读这个目录,不负责发布(运维手工放文件)。

两个端点(属人类通道,与 /files 同鉴权)

端点 说明
GET /api/dsh/v1/app-version 返回最新版本信息;未配置 → 503(fail-closed)
GET /api/dsh/v1/app-latest.apk 流式返回 APK,支持 Range

为什么 503 而不是 404:404 会让客户端以为「地址错了」,而这里表达的是「服务端还没配置分发」。

为什么不走 file-ticket:这是公开分发,不需要 48h 待激活 / 5h 窗口那套时效。
但鉴权仍走人类通道(会话 cookie)—— 与 /files 一致,不放公网脚本。

🔴 安全设计(三条硬约束)

  1. 路径固定:文件名是编译期常量(APP_APK_NAME / APP_VERSION_NAME),
    不接受任何用户输入参与拼接;不列目录。
  2. realpath 归属校验:解析出的真实路径必须仍在 appDistDir 内 —— 防止 app 目录被
    symlink 到别处时读到任意文件。用 path.relative 而非 startsWith
    (后者会把 /a/app-evil 误判成 /a/app 的子路径)。
  3. 大小上限:读之前先 stat,超过 appApkMaxMB(默认 200)直接 413。
    APK 用 createReadStream 流式返回,不整份读进内存。

审计事件:app-version(ok/deny)与 app-download(ok/deny),均不含敏感信息。

配置项

键 默认 说明
appApkMaxMB 200 APK 大小上限(MB);0 表示不限制(不建议)

实现时踩到的坑(已由测试抓到)

readFileSync 在本文件是别名导入(readFileSync as _readFileSync)。
直接写 readFileSync(...) 会 ReferenceError,表现为运行时 503 app version unreadable
——而文件明明存在。已统一改用别名,并加静态断言 ⑰ 防止再犯。

测试

  • test/_e2e-app-dist.mjs(33 项):版本正常/缺失/JSON 非法/缺必填字段;
    APK 下载 Content-Type 与字节一致 / Range 206 / 后缀 Range / 不存在 404 / 超限 413;
    鉴权三种情形;symlink 逃逸必须 403 且不泄漏外部内容。
  • 静态断言 ⑪–⑰:路径固定、realpath 校验、复用 parseRangeHeader、
    503 fail-closed、大小上限、人类通道归属、_readFileSync 别名。

改坏验证:去掉 realpath 校验 / 缺失时返回空 200 / 去掉大小上限 / 改回裸 readFileSync
—— 四条全部 FAIL。

curl 示例(假数据演示)

# 1) 查版本(需登录 cookie)
curl -s -b &quot;dsh_postapi_session=&quot; \
http://127.0.0.1:3080/api/dsh/v1/app-version

# 正常响应
# {&quot;ok&quot;:true,&quot;version&quot;:&quot;0.1.1&quot;,&quot;code&quot;:2,&quot;notes&quot;:&quot;修复状态栏遮挡&quot;,
#  &quot;publishedAt&quot;:1792658737107,&quot;size&quot;:6452454,&quot;sha256&quot;:&quot;f6acbc9...&quot;,
#  &quot;filename&quot;:&quot;DSH-PostAPI-v0.1.1.apk&quot;,&quot;url&quot;:&quot;/api/dsh/v1/app-latest.apk&quot;,&quot;apkReady&quot;:true}

# 未配置时
# {&quot;ok&quot;:false,&quot;error&quot;:&quot;app distribution not configured&quot;}   HTTP 503

# 2) 下载 APK(支持断点续传)
curl -s -b &quot;dsh_postapi_session=&quot; \
-H &quot;Range: bytes=0-1023&quot; \
http://127.0.0.1:3080/api/dsh/v1/app-latest.apk -o part.bin
# 206,Content-Range: bytes 0-1023/

- 版本号:package.json 与 AUDIT_VERSION 同步为 1.3.0。


版本记录 v1.2.0(旧 WebView 兼容 polyfill)

背景

安卓 App(WebView)里报错:

AbortSignal.any is not a function

发生在「选择工作区目录」时。该调用来自 DSH 官方客户端,不是本插件代码:

packages/api/gateway/src/client/index.ts:480      AbortSignal.any([...])
packages/api/gateway/src/client/remote-events.ts:129
packages/api/gateway/src/client/remote-stream.ts:108
packages/api/gateway/src/index.ts:491 / 1146

官方客户端依赖的现代 API(按出现次数):

API 次数 最低 Chrome
AbortSignal.any 271 116
AbortSignal.timeout 60 103
.at() 44 92
replaceAll() 14 85
Promise.withResolvers 8 119

方案:客户端注入 polyfill

不能改官方源码,因此在 lib/client.js 的 factory 最先几行先把这些 API 补上,
让官方代码跑到时能正常工作。

为什么必须放在 factory 顶层:必须早于任何 DSH 代码调用这些 API。
factory 是插件模块入口,官方 UI bundle 的加载在其后,这是可用的最早时机。
放到 useEffect/apply 里就太晚了。

全部带守卫:已存在则不覆盖(Node 26 / 新 Chrome 的原生实现更好)。

各 polyfill 的实现要点

AbortSignal.any(signals) —— 最容易写错的一个

易错点 处理
入参里已有 aborted 的 signal 同步立刻返回已 aborted 的结果,不等事件
空数组 永不 abort(不是立刻 abort),且不注册任何监听
重复触发 监听用 { once: true }
监听器泄漏 任一触发后移除其余全部监听器
reason 透传 优先取入参 signal.reason;老环境无 reason 属性时退回 AbortError
非可迭代输入 宽松按空处理(标准抛 TypeError,这里容错更安全)

AbortSignal.timeout(ms)

  • 超时后 abort,reason 为 TimeoutError(DOMException);
  • 非有限值/负数按 0 处理;
  • 标准未提供取消接口,故不做清理。

Promise.withResolvers()

纯语法糖:resolve/reject 必须绑定到同一个 promise。

Array.prototype.at()

  • 负索引从尾部数;越界返回 undefined;
  • 索引用 Math.trunc 截断(小数 1.9 → 1),不能用 Math.floor(负数的 -0.5 会错成 -1)。

String.prototype.replaceAll()

  • 字符串参数:替换全部(split/join,函数式替换则逐次 indexOf);
  • 正则参数:必须带 g,否则与标准一样抛 TypeError(不掩盖调用方 bug);
  • 空字符串的搜索值:按标准在每个字符间插入。

日志

补了才打,格式固定,F12 一眼可见:

[dsh-postapi-download] polyfill: AbortSignal.any
[dsh-postapi-download] polyfill: Promise.withResolvers

也可在控制台查 window.__postapiPolyfilled(本次补了哪些)。

> ⚠️ 这两条日志刻意不走 dlLog:polyfill 在 factory 最前面执行(早于 dlLog 定义),
> 且必须无条件可见 —— 排查旧 WebView 兼容问题时,不应因本地把诊断开关设成 off 就看不到。
> diagnostic-log-static 里为此开了明确例外并写明理由。

测试(test/polyfill.mjs,14 项)

关键难点:Node 26 已原生支持全部这些 API,直接 import 会让守卫把 polyfill 全跳过,
等于什么都没测。做法是每条用例先删掉原生实现再重新求值 bundle,逼 polyfill 真安装。

覆盖:单 signal abort / 多 signal 第二个 abort / 空数组 / 已 aborted 入参 / reason 透传 /
监听器清理 / 不覆盖原生 / timeout 超时与未超时 / withResolvers 三件套 / .at() 正负索引与越界 /
replaceAll 字符串与正则(含不带 g 应抛错)/ 守卫与日志格式。

防回归(⑩ 系列)

⑩  polyfill 覆盖 5 个 API,且在 factory 顶层(早于 require(react))
⑩b 每个 polyfill 都有「已存在则不覆盖」的守卫
⑩c 不得在 Node 环境炸(typeof 守卫 + try/catch 包裹)
⑩d AbortSignal.any 处理了已 aborted 入参(不能只 addEventListener)

版本:package.json 与 AUDIT_VERSION 同步为 1.2.0。


版本记录 v1.1.1(修复:抽屉遮罩用错 CSS 变量,打开后整页变白)

用户反馈

> 交付物文件抽屉打开后为什么背景变成白色了,不是透明

根因

遮罩用了 --dsw-alias-bg-overlay:

.dsa-dl-mask { background: var(--dsw-alias-bg-overlay, rgba(0,0,0,.32)); }  /* ✗ */

而 --dsw-alias-bg-overlay 的官方语义是「弹层/浮层自身的背景色」——
不透明表面色(design-platform.css:180 → --dsw-static-neutral-bluish-150,浅色主题下接近白色)。
拿它当遮罩 ⇒ 遮罩层是不透明白色 ⇒ 整个页面被盖住。

修法

元素 变量 浅色 深色 说明
.dsa-dl-mask(遮罩) --dsw-alias-bg-mask-1 rgba(0,0,0,.24) rgba(0,0,0,.5) 半透明黑 + backdrop-filter: blur(2px)
.dsa-dl-drawer(抽屉) --dsw-alias-bg-layer-1 近白 近黑 不透明层表面色

两个变量的定义见官方 dsh-client-ui-theme/src/styles/design-platform.css:170/173(浅色)与 :270/273(深色)。

深色模式确认:bg-mask-1 深浅色都是黑色半透明(0.24 / 0.5),不会出现纯白或纯黑。
兜底 rgba(0,0,0,.24) 在深色主题下若变量缺失会略淡,但仍是半透明黑,可接受。

防回归(⑨ 系列)

⑨  遮罩必须用 bg-mask-*(半透明),不得用 bg-overlay;兜底 alpha 必须在 (0,1) 开区间
⑨b 抽屉背景必须不透明(不得 transparent/none),且应使用 bg-layer-1
⑨c 遮罩与抽屉不得共用同一个背景变量(各司其职)

改坏验证:遮罩改回 bg-overlay / 抽屉设为 transparent / 兜底 alpha 改成 1 /
两者共用变量 —— 四条全部 FAIL。

版本:package.json 与 AUDIT_VERSION 同步为 1.1.1。


版本记录 v1.1.0(交付物入口改为「紧凑按钮 + 右侧抽屉」)

> 纯前端交互形态变更,后端接口完全未动。

形态演进(三轮,最终定稿)

版本 形态 问题
v0.12.0 会话顶部平铺面板 文件多/描述长时撑破聊天区
v1.1.0 中途 聊天流内 turn 尾平铺卡片 同样占空间,且会话级清单会逐 turn 重复
v1.1.0 最终 紧凑按钮 + 右侧抽屉 —

用户原话驱动:
> 但是文字太长了,超出屏幕了,上面应该放按钮而不是一个框
> 应该一个按钮可以打开一个面板?

最终交互

会话顶部:[📎 交付物文件 (3)]        ← 紧凑按钮,N=0 时完全不渲染
↓ 点击
┌─────────────────────────────────┐
│ 交付物文件 (3)              [✕] │
├─────────────────────────────────┤
│ test-download.txt      909 B  [下载] │
│ download-test-2026.md  722 B  [下载] │
│ DSH-PostAPI.apk        6.1 MB [下载] │
└─────────────────────────────────┘
  • 挂载 conversation.session.header.actions(会话级信息放会话级位置)。
  • 抽屉:遮罩点击关闭 + ESC 关闭 + 关闭按钮三件套;列表内部滚动,抽屉不超视口。
  • 每行:文件名(ellipsis 截断)+ 大小 + 下载按钮;description 只放 title(hover 看全文),
    不进正文 —— 这是「文字太长超出屏幕」的根源。

🔴 为什么是抽屉而不是居中浮层

DSH 本身以右侧 sidebar 为组织骨架,抽屉从右侧滑出与之一致,且不遮挡正文;
居中浮层会盖住聊天内容,还要额外处理居中与尺寸自适应,收益为零。

🔴 修复:新文件不刷新页面看不到(用户实测)

现象:磁盘 3 个 present 事件,界面只显示 2 个,刷新后才看到第 3 个。

根因:组件只在挂载时取一次数,新事件到达后不会再拉。

修法(三层):

  1. TTL 从 8s 缩到 3s —— 被动兜底,用户可感知延迟上限;
  2. 组件轮询 —— 抽屉未开 4s / 打开 2s,卸载即清 timer;
  3. invalidateFilesCache(sessionId) 主动失效 —— 拿到「有新事件」信号时立刻打掉缓存重拉。

为什么不会打爆后端:
- 每次轮询都先走 loadSessionFiles,命中缓存不联网;
- 多个组件共享同一 sessionId 缓存条目,不会 N 倍放大;
- 无 sessionId 不起轮询;组件卸载清理 timer。

实测效果:2 → 新增 1 → 3,最迟 4 秒内自动出现,无需刷新。

缓存机制

filesCache: Map&lt;&quot;|&quot;, { at, promise }&gt;
FILES_CACHE_TTL_MS = 3000
  • 并发调用共享同一个 in-flight Promise(不会重复发);
  • 失败不缓存(网络抖动/401 后下次渲染会重试,不会整会话永久空白);
  • 返回 maxTurn 供上层判断「有没有新事件」。

关于官方卡片为何不渲染(结论:不确定)

已查明的事实:
- @deepseek-ai/dsh-client-ui-deliverables 确实安装在 ~/.dsh/profiles/node_modules/;
- 它是 @deepseek-ai/dsh-web-app 的依赖,而 dsh-web-app 在 profile bundles 列表里;
- 其源码 src/client/index.ts / Deliverables.tsx 确实注册了 conversation.chat.turnTail。

但「为什么浏览器里零个 [data-presented-file] 节点」,我没有把握给出结论。
可能原因(未验证):客户端 bundle 是否真的打包了该模块、是否被 profile 的精简配置裁掉、
或 deliverablesDefinition 未注册。不编结论。

我方 slots.inject 用的是自有 id(dsh-postapi-bridge/files)与独立 slot,
与官方 id(@deepseek-ai/dsh-client-ui-deliverables)不同,不会覆盖官方注册。


版本记录 v1.0.3(🔴 listEvents 不返回 data —— files: [] 的根因)

> v1.0.2 让接口活了(200),但 files 恒为空。本版修掉最后一块。

铁证(官方类型定义)

packages/session-query/session-query/src/types.ts:59:

export interface SessionEventRecord {
sessionId: SessionId
seq: SessionSeq
type: SessionEventType
time: number
surface: SessionEventSurface
}          // ← 没有 data 字段

官方注释(session-query/src/index.ts:265):
> List lightweight raw-log event records for one logical session.

根因

// 旧实现:从 listEvents 的记录里读 data
for (const r of records) {
if (r.type !== &#x27;deliverables/presented&#x27;) continue
const data = r.data                                  // ← 永远 undefined
if (!data || !Array.isArray(data.files)) continue    // ← 永远 continue
}

⇒ events 恒为空 → 走「无交付物」分支 → 200 + files: []。

修法:两段式(listEvents 取 seq → readEvent 取 data)

① sq.listEvents(sessionId)              → [{sessionId, seq, type, time, surface}]  无 data
② pickPresentedEvents(records)          → [{seq, type, time}]  只看 type/seq,按 seq 倒序
③ 对每个 seq: readPresentedEvent(...)   → 完整事件(window.target 带 data.files)
④ flattenPresentedFiles(hydrated)       → 候选条目

新增共用小函数 readPresentedEvent(sessionId, seq),清单端点与下载端点共用,
保证两边对「事件长什么样」的理解一致(下载端点的 readPresentedFile 本就走这条路,一直是对的)。

单条 readEvent 失败只跳过该条(skippedReasons[&#x27;event-unreadable&#x27;]++),不整批失败 ——
与「单文件不合格只跳过」同一原则。并行发起 Promise.all 减少往返。

顺手的一处优化

原先为了取会话 cwd 会额外多发一次 readEvent 探针。现在既然要逐个 readEvent 取 data,
cwd 直接从第一个成功的事件里取,少一次往返。

🔴 为什么测试没抓到(同一个教训的第二次)

测试桩的 listEvents 返回了带 data 的完整事件 —— 与真实 API 不符。
所以测试绿、线上空。

本版已把所有桩的 listEvents 改成只返回轻量记录(无 data),readEvent 返回完整事件。
改完后 e2e 依然全绿 ⇒ 证明代码确实走了 readEvent 路径,而不是碰巧从桩的 data 里读到。

防回归(G5–G8)

G5. pickPresentedEvents 的实现里不得出现 .data(它只该看 seq/type)
G6. handlePresentedList 必须逐个 readPresentedEvent,且不得把 records 直接喂给 flatten
G7. readPresentedEvent 存在、从 window.target 取完整事件、校验 data.files、有 event-unreadable 兜底
G8. 单条读取失败只跳过不整批失败(unreadable.count++ / continue / bump)

语义变更(已在测试名标注)

函数 旧语义 新语义
pickPresentedEvents 保留 data.files 合法的记录 只保留 type + 整数 seq 的记录,无 seq 一律丢(无法 readEvent)
flattenPresentedFiles 输入任意 输入必须是 readEvent 拿到的完整事件(含 data.files)

版本:package.json 与 AUDIT_VERSION 同步为 1.0.3。


版本记录 v1.0.2(🔴 真正根因:ctx.sessionQuery 读取方式错误)

> 全天所有「400/500 空响应」的真正原因在这里。 之前关于参数名、路径名、网关拦截的推测全是误判。

铁证(线上真实异常)

GET /api/dsh/v1/files?sessionId=&lt;真实会话&gt;
→ 500 {&quot;ok&quot;:false,&quot;error&quot;:&quot;files list failed: cannot get property \&quot;sessionQuery\&quot; without inject&quot;}

根因

export const inject = [&#x27;webServer&#x27;, &#x27;agents&#x27;, &#x27;sessions&#x27;]   // ← 不含 sessionQuery

cordis 规定:访问未在 inject 里声明的服务属性会直接 throw。所以这行"防御代码"本身就会抛:

if (!ctx.sessionQuery || typeof ctx.sessionQuery.listEvents !== &#x27;function&#x27;) {  // ← throw 在这一行
sendJson(res, 503, ...)
}

!ctx.sessionQuery 的读取动作就已经抛了,根本走不到判断。
/files、/file-ticket、/file/ 三个端点的全部 sessionQuery 访问都有此问题。

修法:ctx.get(&#x27;sessionQuery&#x27;)

cordis 官方提供安全探测(vendor/cordis/lib/index.js:763):

/**
* Read a service from the store **without the inject requirement**.
* @returns the service value, or `undefined` when not (yet) provided.
*/
get(name, strict = true) { ... }

新增 sessionQueryOf(ctx) 辅助函数,所有取用点改为经由它:

function sessionQueryOf(ctx) {
try {
return typeof ctx?.get === &#x27;function&#x27; ? ctx.get(&#x27;sessionQuery&#x27;) : undefined
} catch { return undefined }
}

不改 inject(否则精简 profile 里整个插件不加载)。服务不存在时返回 undefined,
现有的 if (!sq || typeof sq.listEvents !== &#x27;function&#x27;) 判断即可正常工作。

为什么每次现取、不在 apply() 里缓存

ctx.get(name) 默认 strict 模式,只返回「已激活」的服务。若 sessionQuery 的激活时机
晚于本插件(cordis 里服务就绪顺序不保证),apply 时取到的会是 undefined,缓存下来就
永久失效 —— 插件表面上"加载成功",但所有下载端点永远 503。

现取能自然跟随服务上线;ctx.get() 本身极轻(一次属性查找),无性能顾虑。

为什么全套测试都没抓到(关键教训)

测试桩的 ctx 是手工对象,ctx.sessionQuery 天然可读。 生产环境的 cordis ctx 才会 throw。

新增 test/inject-guard.mjs(9 项):桩 ctx 用 Proxy 模拟 cordis 语义 ——
读未 inject 的属性直接 throw cannot get property &quot;x&quot; without inject,
再验证三个端点在「sessionQuery 不存在」时返回 503(而非抛异常/500)。

防回归(G 系列静态断言)

G1. index.js 非注释代码中不得出现 ctx.sessionQuery(会 throw without inject)
G2. 所有 sessionQuery 取用都经由 sessionQueryOf(ctx)
G3. sessionQueryOf 有 try/catch 兜底(桩 ctx 实现差异也不冒泡成 500)
G4. inject 声明里**不**包含 sessionQuery(否则精简 profile 里整个插件不加载)

验收:grep -c &#x27;ctx.sessionQuery&#x27; lib/index.js → 0

更正:此前的误判

版本 当时的结论 实际
v0.12.3 /presented 与宿主 present.open 路径冲突 ❌ 路径无关;/files 改名无害但非根因
v1.0.0 sessionId 参数名被网关保留 ❌ 反了(被保留的是 sid)
v1.0.1 改回 sessionId ✅ 参数名确实该用 sessionId,但不是根因
v1.0.2 — ✅ 根因:ctx.sessionQuery 直接读属性会 throw

版本:package.json 与 AUDIT_VERSION 同步为 1.0.2。


版本记录 v1.0.1(更正 v1.0.0 的参数名错误:sid → sessionId)

> ⚠️ v1.0.0 的破坏性变更是错的,本版改回来。

错在哪

v1.0.0 声称「宿主网关拦截 sessionId query 参数」,于是把参数改成了 sid。
恰恰相反 —— 被宿主保留的是 sid,不是 sessionId。

实测判据(20261022,v1.0.0 线上,同一路径只换参数名,值均为真实会话 ID)

无参数               → 400 size=56  Missing required query param: sid   ✅ 进了 handler
?sid=&lt;真实ID&gt;        → 400 size=0                                        ❌ 没进 handler
?sessionId=&lt;真实ID&gt;  → 400 size=56  Missing required query param: sid   ✅ 进了 handler
?session=&lt;真实ID&gt;    → 400 size=56                                       ✅
?session_id=&lt;真实ID&gt; → 400 size=56                                       ✅
?id=&lt;真实ID&gt;         → 400 size=56                                       ✅
?q=&lt;真实ID&gt;          → 400 size=56                                       ✅

决定性判据:在 handler 入口加临时诊断日志,?sid= 时零输出(请求根本没进来),
其它参数名都有输出。⇒ sid 是宿主网关的保留参数名(网关拿它做自己的会话解析,
值不认识就直接返回空白 400),sessionId 不是保留名。

为什么 v1.0.0 会判反

当时看到 ?sessionId=x → 400 size=0 就下了结论,但那是 v0.12.x 时代的数据:
那时 handler 用的正是 sessionId,请求进了 handler,是内部 listEvents 抛异常导致空响应。
把「两个时期、两种原因」的现象混在了一起。

本版已一并查清:handler 内部能完整跑完,不会抛异常(见 test/files-handler.mjs)。

改动

项 v1.0.0(错) v1.0.1(正确)
query 参数 ?sid= ?sessionId=
错误文案 Missing required query param: sid ... sessionId
前端 /files?sid= /files?sessionId=
body 字段 sessionId sessionId(不变,现在与 query 一致)

新增测试:直接驱动 handler(test/files-handler.mjs,6 项)

回答「handler 到底能不能跑完」这个问题 —— 用桩 ctx + 真实形状记录 + 真实 HTTP:

✔ A. 缺 sessionId → 400 且 body 有明确文案(不是空响应)
✔ B. ?sid= 不被接受(证明参数名就是 sessionId)
✔ C. ?sessionId= → handler 完整跑完,返回 200 且带文件列表(核心)
✔ D. handler 没有抛异常(否则 catch 会返回 500)

结论:/files?sessionId=&lt;真实会话&gt; 返回 200 + ok:true + 非空 files 数组,handler 不抛异常。

防回归(⑧ 系列已反转)

⑧  必须用 queryOf(req, &#x27;sessionId&#x27;),不得用保留名 &#x27;sid&#x27;
⑧b client.js 调 /files 必须用 sessionId 参数
⑧c 错误文案指向 sessionId(而非 sid)
⑧d body 字段也用 sessionId

改坏验证:把参数名换回 sid / 文案换回 sid / 前端换回 sid,三条断言全部 FAIL。

版本:package.json 与 AUDIT_VERSION 同步为 1.0.1。


版本记录 v1.0.0(🎉 首个正式版:公网文件下载 + 安卓登录 + 安全模型定型)

> 一句话:DSH 的登录/网关插件正式进入 1.0 —— 公网可用的文件下载、安卓客户端 JSON 登录、
> 以及一套经过线上故障打磨的安全模型。

🔴 v1.0.0 的破坏性变更:query 参数 sessionId → sid

> ⛔ 本节已被 v1.0.1 更正:方向搞反了。被宿主保留的是 sid,不是 sessionId。
> 参数已改回 sessionId,见上方 v1.0.1 章节。以下内容仅作历史记录。

这是本次升 Major 的唯一原因。

宿主的 API 网关会拦截任何带 sessionId query 参数的 /api/* 请求,直接返回空白 400
(无 body、无 content-type),请求根本到不了本插件的 handler。实测对照(同一条路径,只改参数名):

GET /api/dsh/v1/files?a=1                       → 400, size=62  {&quot;ok&quot;:false,&quot;error&quot;:&quot;Missing required query param: …&quot;}  ✅ 到我们的 handler
GET /api/dsh/v1/files?q=1&amp;w=2                   → 400, size=62  ✅
GET /api/dsh/v1/files?session=x                 → 400, size=62  ✅
GET /api/dsh/v1/files?session_id=x              → 400, size=62  ✅
GET /api/dsh/v1/files?id=x                      → 400, size=62  ✅
GET /api/dsh/v1/files?sessionId=x               → 400, size=0   ❌ 被宿主吃掉
GET /api/dsh/v1/files?sessionId=abc&amp;sessionId=def → 400, size=0  ❌

只有 sessionId 这个名字被拦。 这是参数名层面的拦截,与路径无关。

项 v0.12.x v1.0.0
清单接口 query GET /files?sessionId= GET /files?sid=
错误文案 Missing required query param: sessionId ... sid
POST /file-ticket 的 body 字段 { sessionId, seq, index } 保持不变(body 不是 query,不受影响)

> ⚠️ v0.12.3 的更正:当时把 /presented 改名 /files,以为是路径与宿主 present.open 冲突,
> 那是误判——真正原因是 sessionId 参数名。/files 这个名字本身没问题,予以保留。

从 0.8.0 到 1.0.0 的能力汇总

① 公网文件下载(v0.9.0 起,v0.12.0 落地前端)

接口 方法 鉴权 说明
/api/dsh/v1/files GET 已登录(会话 cookie) 列出会话内交付物元数据(查询参数 sid)
/api/dsh/v1/file-ticket POST 已登录(会话 cookie) 为一条 present 记录签发票据(body 用 sessionId)
/api/dsh/v1/file/ GET 已登录 + 票据绑本人 流式下载,支持 Range 续传
/api/dsh/v1/dl/ GET 已登录 + 票据绑本人(v1.5.0) 直链下载:纯时间窗(签发即起算,默认 48h)、不激活、不限次、支持 Range。卡片 `` 直接指向它
  • 有效期:签发票据后 48h 内「待激活」;首次下载即激活,此后 5h 内不限次数(含 Range 续传);
    过期需重新签发。过期的是票据,源文件永远不删不改。
  • 直链时效(v1.5.0 /dl/):签发即起算 48h(dlTicketHours),不激活、不限次;
    过期 410,重开聊天界面即拿到新直链。同样,过期的是链接,源文件永远不删不改。
  • 路径安全:文件路径与会话 cwd 双向 realpath 后判归属(不用字符串前缀,防符号链接逃逸)。
  • 前端:PresentedFilesPanel 自渲染文件列表 + 下载按钮(不依赖官方 deliverables 卡片——
    该卡片在部分环境不渲染)。日志前缀 [dsh-postapi-download],可 localStorage 关闭。

② 安卓客户端 JSON 登录(v0.8.0 起)

接口 方法 鉴权 说明
/api/dsh/v1/mobile-login POST 免密(账号密码,按 IP 限流) 返回 launchUrl + 会话 cookie
/api/dsh/v1/client-log POST 免鉴权(五条限额 + 写盘前脱敏) 客户端日志自动上报(v1.4.0):环境快照 / 长任务 / 掉帧 / 错误
  • 返回 launchUrl = :///?token=&lt;启动令牌&gt;,客户端持有 cookie 后 WebView 直接加载即可完成
    官方 GET /?token= 铸 cookie。
  • 失败统一 401 Invalid credentials(账号不存在/密码错/被禁用不区分,防用户名枚举)。
  • 按 IP 限流(5 次失败锁 10 分钟,与 step-up 共用计数)。

③ 安全模型(v0.11.0 → v0.12.4 定型)

指纹判定最终形态:

```
UA 类别不匹配 → 拒绝(mismatch,计 3 次吊销)
UA 类别匹配 + 哈希命中(当前或历史 N=3) + IP 匹配 → match(最强)
UA 类别匹配 + 哈希命中 + IP 漂移 → ip-drift(放行 + 审计)
UA 类别匹配 + 哈希未命中 → ua-hash-drift(放行 + 审计,正常升级路径)

README 较长,此处只展示前 60 KB。完整内容请到仓库查看 ↗。

常见问题

7 问
答案由本站依据条目字段整理,不引用上游原文
dsh-postapi-bridge 怎么安装?

在 DSH 里执行 `dsh plugin --profile web add dsh-postapi-bridge` 即可。对应的 npm 包名是 `dsh-postapi-bridge`。

dsh-postapi-bridge 的验证等级是什么意思?

未标注验证等级。上游未给出验证结论,安装前请自行确认

dsh-postapi-bridge 安全吗?

未标注。

dsh-postapi-bridge 装之前要先确认什么?

先看它会读写哪些文件、执行哪些命令、连接哪些网络地址;验证等级只说明装得上、跑得起来,不等于安全。涉及密钥或系统权限的,建议先在隔离环境试装。

dsh-postapi-bridge 用的是什么许可证?

上游标注为 MIT。商用或二次分发前请到仓库确认许可证原文。

dsh-postapi-bridge 有哪些功能?

免 Cookie 的 POST 任务调度网关、Bearer Token 鉴权与多账号登录、直接调用 DSH 工具与 MCP、请求溯源审计日志。

这条记录的数据可靠吗?

本站按仓库名去重后合并整理,字段以作者仓库与 npm 元数据为准;安装命令、仓库地址与 README 保持原文,带 L4 / L5 标记的安装命令已在隔离沙盒里真实跑通。

浏览器与联网
按星标排序,可作替代方案

dsh-our-free-model

dsh-our-free-model

在 DeepSeek Harness 中装上这个 DSH plugin,无需登录、注册或填 API Key,即可直接调用 DeepSeek

L3 · 安装规范通过 8,796 星

dsh-web

dsh-web

侧边栏多列任务看板:卡片交给真实 DSH 智能体会话执行,支持 cron 定时(Host 侧到点执行,关浏览器也生效)。

L3 · 安装规范通过 8,549 星

BrowserSkill

BrowserSkill

BrowserSkill 的 DeepSeek Harness 浏览器自动化桥接插件,通过原生浏览器工具控制可见的 Chrome 和 Edg

L4 · 沙盒安装通过 8,456 星

dsh-pocket

dsh-pocket

DSH Pocket 让 DeepSeek Harness 装进口袋:电脑跑 dsh web,手机扫码即实时同步访问(局域网+公网),人在外

L5 · 运行验证通过 1,656 星

Browser4

Browser4

L4 · 沙盒安装通过 1,152 星

agentrq

agentrq

L4 · 沙盒安装通过 1,140 星

ptrel1 的其它插件

2 个

插件的元信息来自作者仓库;分类、验证等级与安全结论的中文说明为中文整理,最近核验 —。