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 + '.' + 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''…(RFC5987)+ 支持 Range(复用parseRangeHeader)。
> 为什么 dl 票把 exp 写进 token,而 file-ticket 不写:
> file-ticket 的窗口从「第一次下载」起算,签发时无法预知激活时刻,只能存服务端状态;
> dl 票的窗口从「签发」起算,exp 在签发时即确定 ⇒ 纯无状态即可验签 + 判过期。
> 无状态是刻意的:/files 一次要给 N 个文件各发一张票,若每张都写状态库,
> 一次列表请求就写 N 次磁盘。
② /files 列表返回直链
每条 file 新增两个字段(ticketUrl 保留,兼容旧前端):
{
"seq": 2365, "index": 0, "name": "DSH-PostAPI-v0.3.0.apk", "size": 722,
"downloadUrl": "/api/dsh/v1/dl/eyJ2IjoyLCJzaWQiOiJzZXNzLTEiLCJzZXEiOjIzNjUsImluZGV4IjowLCJ1aWQiOiJhbGljZSIsImV4cCI6MTc5MjgzMTUzNzEwN30.FAKE_SIGNATURE_FOR_DOC",
"downloadExpiresAt": 1792831537107,
"ticketUrl": "/api/dsh/v1/file-ticket"
}
③ 前端:聊天流内可点击卡片
- 位置:
conversation.chat.turnTail(与官方交付物卡片同一个 slot);只渲染属于当前 turn 的文件(按turn字段过滤)。 - 数据来源:复用
loadSessionFiles缓存,不新增请求。 - 形态:整张卡片是 ``,点击 = 浏览器直接下载,不发任何 JS 请求。
- 样式
dsa-dlc-*(与抽屉的dsa-dl-*分开),只用--dsw-alias-*语义 token,暗色模式自动跟随。 - 文件名超长
ellipsis截断;不显示 description(用户反馈过文字太长撑破)。 downloadUrl缺失或格式不对 → 静默不渲染该卡片。- 独立 DOM + 独立 try/catch:不影响官方卡片渲染。
🔴 为什么卡片用 ` 而不是onClick + fetch`
这是用户的核心诉求,也是本次改动的唯一目的:
- 用户卡住的正是那条链 ——「点击 → 前端发请求 → 再导航」。官方卡片在手机 WebView 里那一步没走通,
用户点了没反应、拿不到文件。再用fetch换地址,等于重演同一个坑。 ` 由**浏览器原生**处理:不依赖 JS 跑通、不依赖fetch` 成功、不依赖 WebView 的 XHR 实现。
只要请求带上会话 cookie,浏览器自己就会发这个 GET 并落盘。- 原生下载走系统下载管理器:手机上有进度、可断点续传(后端支持 Range)、可转存/分享 ——
这些是fetch+Blob给不了的。 - 同源 cookie 会随浏览器发起的下载请求天然携带,不需要
credentials: 'same-origin'之类 JS 手段。 - 失败可见:
401/403/410时浏览器显示后端文案,而不是我们这一层的「点了没反应」。
⇒ 静态断言(test/dl-card.mjs ③b)钉住:卡片区域不得出现 onClick / fetch / triggerBrowserDownload / location.href / window.open。
④ 保留现有能力
- 抽屉(
conversation.session.header.actions)继续保留 —— 它是会话级汇总视图。 file-ticket端点逻辑不动(test/dl-ticket.mjsB16 守着)。- 官方
deliverables.file.actions下载按钮保留。
🔴 顺带修的正经 bug:文件被删后返回 403 而不是 404
resolveSafeFilePath 里 isRealpathInside 对「路径不存在」(realpath ENOENT)与
「路径在 cwd 之外」都返回 false,而调用方把 false 一律翻成 'escape'(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: 'mobile' + 真机 UA。
> verifyFingerprint 对 uaClass === 'other'(node/curl 类 UA)一律拒绝;
> 早期用 node-test UA 的用例只是侥幸没走到指纹校验,一旦真走就会 403。
版本记录 v1.4.1(修:同一秒内多次上报互相覆盖 · 丢日志)
Bug(用户实测)
同一 IP 在同一秒内连续两次 POST /client-log,返回同一个文件名,落盘只剩最后一次:
第一次: {"ok":true,"stored":true,"file":"20261022-114349-12ca17b4.json","bytes":430}
第二次: {"ok":true,"stored":true,"file":"20261022-114349-12ca17b4.json","bytes":539}
根因
v1.4.0 的文件名是 <到秒的时间戳>-.json。
唯一区分量是 IP —— 同一 IP 同一秒的多次上报必然同名,而 rename 会覆盖已存在文件,
于是 snapshot / perf / flush 同秒到达时互相吞日志(手机上正是高频上报)。
修法
- 文件名 →
-<16位随机hex>.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 头 |
(\"?authorization\"?\s*[:=]\s*\"?)(?:(?: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 敏感键值对 | 键名白名单逐个生成正则(见下),值支持 "…" / '…' / 反引号 / 裸值 |
token=abc123、dsh_postapi_session=xyz、password=hunter2、"apiKey":"sk-…"、cookie: … |
| C 长 hex | (?<![0-9a-fA-F])[0-9a-fA-F]{32,}(?![0-9a-fA-F]) |
sha256/HMAC/签名 → `` |
| D 长 base64url | (?<![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/-<16位随机hex>.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(先保证最新一份在,即使进程在清理中途被杀)。
请求体(字段白名单,其余丢弃)
{
"source": "web" | "app",
"kind": "snapshot" | "perf" | "error" | "flush",
"ua": "",
"env": {
"screen": [390, 844],
"dpr": 3,
"deviceMemory": 4,
"hardwareConcurrency": 8,
"supports": { "webgl": true, "compression": false }
},
"entries": [
{ "t": 1792661621796, "level": "warn", "kind": "longtask", "duration": 320, "msg": "..." }
]
}
响应:
| 状态 | 含义 |
|---|---|
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('dsh-postapi-client-log', 'off') 后刷新。
| 采集项 | 触发 | 说明 |
|---|---|---|
snapshot |
页面加载后立即一次 | ua(关键:能看出 WebView 内核版本)/ screen / dpr / deviceMemory / hardwareConcurrency / supports{webgl,compression} |
longtask |
PerformanceObserver(['longtask']) |
只记 > 100 ms |
fps |
requestAnimationFrame 采样 |
FPS < 30 持续 2 秒才记一条(每满 2 秒记一次并清零,避免刷屏) |
fid |
PerformanceObserver(['first-input']) |
首次输入延迟 |
cls |
PerformanceObserver(['layout-shift']) |
单次布局偏移值 |
error |
window.onerror / unhandledrejection / console.error 包装 |
立即上报(单条也发) |
flush |
每 60 秒 或 visibilitychange → hidden |
只传上次上报之后的新条目;空则不发请求 |
设计红线(每条对应一个坑):
- 不污染 console:
console.error包装后用apply调原函数、参数原样透传、返回值原样返回,
自身任何异常都被吞掉(采集失败绝不能影响控制台输出);卸载时恢复原函数。 - 回调里只 push:
PerformanceObserver/ rAF 回调只入队,脱敏与序列化留到上报时
—— 长任务本身说明主线程紧张,回调里再做重活就是雪上加霜。 - 页面不可见时暂停 rAF 采样(
document.hidden),重新可见恢复。 - 失败静默、不重试:
fetch(...).catch()一律吞掉,绝不弹提示、绝不安排重试(避免断网雪崩)。 - 不阻塞页面:
fetch+keepalive: true,且不await。 - 不用
unload(浏览器会丢弃其中的异步请求),改用visibilitychange → hidden。 - 只传增量:每条 entry 带自增
seq,上报成功才把lastSentSeq推到已发送的最大值;
失败则不动 → 下次 flush 重发同一批(不丢数据、不重复)。 - 队列上限 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 # 改坏验证(必须「全部被捕获」)
数据样例(脱敏后的真实形状,假数据)
{
"v": 1,
"receivedAt": "2026-10-22T10:29:30.123Z",
"source": "app",
"kind": "perf",
"ua": "Mozilla/5.0 (Linux; Android 13; wv) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36",
"env": {
"screen": [390, 844], "dpr": 3, "deviceMemory": 4, "hardwareConcurrency": 8,
"supports": { "webgl": true, "compression": false }
},
"entries": [
{ "t": 1792661621796, "level": "warn", "kind": "longtask", "duration": 320, "msg": "longtask 320ms" },
{ "t": 1792661623401, "level": "warn", "kind": "fps", "duration": 22, "msg": "fps 22 for 2013ms" },
{ "t": 1792661625100, "level": "info", "kind": "fid", "duration": 180, "msg": "first-input delay 180ms" },
{ "t": 1792661626900, "level": "info", "kind": "cls", "duration": 0.0421, "msg": "layout-shift 0.0421" },
{ "t": 1792661630200, "level": "error", "kind": "console.error",
"msg": "Failed to load /api/dsh/v1/files?sessionId=abc& 401" },
{ "t": 1792661630300, "level": "error", "kind": "error", "duration": null,
"msg": "Cannot read properties of undefined (reading 'map') @ bundle.js:1204",
"extra": { "source": "window.onerror" } }
],
"meta": { "receivedEntries": 6, "keptEntries": 6, "truncated": false }
}
注意:/apis/dsh/v1/files?sessionId=abc& 里的 就是脱敏结果 ——
原串是 `?token=xxx`,落盘后只剩。
🔴 实现时踩到的坑(记录下来免得后人重踩)
这也是一份 Node 侧测试陷阱清单(test/_installer-harness.mjs 文件头有完整版):
new Function(src)求值出的函数,闭包绑定到全局作用域;而globalThis.window = x
只写属性、不产生全局变量绑定 → bundle 内部typeof window恒为undefined。
表现:安装器第一行if (!win ...) return function () {}直接早退,看起来像"什么都没装"。
Chromium 不会这样(浏览器 window 既是全局对象属性又有 var 语义)。
⇒ 测试夹具统一用(0, eval)让被测代码落在真正的全局作用域。- 只重新求值「安装器」也没用:它引用的模块级常量(阈值)属于原闭包 →
ReferenceError
→ 被安装器自己的try/catch吞掉 → "装上了但什么都不采",比①更难发现。
⇒ 安装器改为接受thresholds参数(浏览器不传 → 用模块常量),测试显式注入。 - 替换回调的参数个数:
s.replace(re, function (m, head) {...})里head是第一个捕获组;
若正则的实际第一个组是整串(例如((?:bearer|basic)\s+)之外的写法),head会拿到匹配到的数字字符
—— 实测症状是脱敏结果变成0。写替换回调时必须数清捕获组。 - 静态断言的「行距」会被无关代码干扰:
test/download-button-static.mjs用
「credentials行与/file-ticket行距 < 8」判定,任何新增的credentials: 'same-origin'
都会抢占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 一致,不放公网脚本。
🔴 安全设计(三条硬约束)
- 路径固定:文件名是编译期常量(
APP_APK_NAME/APP_VERSION_NAME),
不接受任何用户输入参与拼接;不列目录。 - realpath 归属校验:解析出的真实路径必须仍在
appDistDir内 —— 防止 app 目录被
symlink 到别处时读到任意文件。用path.relative而非startsWith
(后者会把/a/app-evil误判成/a/app的子路径)。 - 大小上限:读之前先
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 "dsh_postapi_session=" \
http://127.0.0.1:3080/api/dsh/v1/app-version
# 正常响应
# {"ok":true,"version":"0.1.1","code":2,"notes":"修复状态栏遮挡",
# "publishedAt":1792658737107,"size":6452454,"sha256":"f6acbc9...",
# "filename":"DSH-PostAPI-v0.1.1.apk","url":"/api/dsh/v1/app-latest.apk","apkReady":true}
# 未配置时
# {"ok":false,"error":"app distribution not configured"} HTTP 503
# 2) 下载 APK(支持断点续传)
curl -s -b "dsh_postapi_session=" \
-H "Range: bytes=0-1023" \
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 个。
根因:组件只在挂载时取一次数,新事件到达后不会再拉。
修法(三层):
- TTL 从 8s 缩到 3s —— 被动兜底,用户可感知延迟上限;
- 组件轮询 —— 抽屉未开 4s / 打开 2s,卸载即清 timer;
invalidateFilesCache(sessionId)主动失效 —— 拿到「有新事件」信号时立刻打掉缓存重拉。
为什么不会打爆后端:
- 每次轮询都先走 loadSessionFiles,命中缓存不联网;
- 多个组件共享同一 sessionId 缓存条目,不会 N 倍放大;
- 无 sessionId 不起轮询;组件卸载清理 timer。
实测效果:2 → 新增 1 → 3,最迟 4 秒内自动出现,无需刷新。
缓存机制
filesCache: Map<"|", { at, promise }>
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 !== 'deliverables/presented') 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['event-unreadable']++),不整批失败 ——
与「单文件不合格只跳过」同一原则。并行发起 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=<真实会话>
→ 500 {"ok":false,"error":"files list failed: cannot get property \"sessionQuery\" without inject"}
根因
export const inject = ['webServer', 'agents', 'sessions'] // ← 不含 sessionQuery
cordis 规定:访问未在 inject 里声明的服务属性会直接 throw。所以这行"防御代码"本身就会抛:
if (!ctx.sessionQuery || typeof ctx.sessionQuery.listEvents !== 'function') { // ← throw 在这一行
sendJson(res, 503, ...)
}
!ctx.sessionQuery 的读取动作就已经抛了,根本走不到判断。
/files、/file-ticket、/file/ 三个端点的全部 sessionQuery 访问都有此问题。
修法:ctx.get('sessionQuery')
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 === 'function' ? ctx.get('sessionQuery') : undefined
} catch { return undefined }
}
不改 inject(否则精简 profile 里整个插件不加载)。服务不存在时返回 undefined,
现有的 if (!sq || typeof sq.listEvents !== 'function') 判断即可正常工作。
为什么每次现取、不在 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 "x" 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 'ctx.sessionQuery' 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=<真实ID> → 400 size=0 ❌ 没进 handler
?sessionId=<真实ID> → 400 size=56 Missing required query param: sid ✅ 进了 handler
?session=<真实ID> → 400 size=56 ✅
?session_id=<真实ID> → 400 size=56 ✅
?id=<真实ID> → 400 size=56 ✅
?q=<真实ID> → 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=<真实会话> 返回 200 + ok:true + 非空 files 数组,handler 不抛异常。
防回归(⑧ 系列已反转)
⑧ 必须用 queryOf(req, 'sessionId'),不得用保留名 'sid'
⑧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 {"ok":false,"error":"Missing required query param: …"} ✅ 到我们的 handler
GET /api/dsh/v1/files?q=1&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&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=<启动令牌>,客户端持有 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(放行 + 审计,正常升级路径)