测试全绿,真机翻车:写 DSH 插件踩的三个坑

起因

我平时用 DSH(DeepSeek Harness)干活,它默认只走 DeepSeek 的 API。但我手上有 ChatGPT Plus 和 OpenRouter 的号------不想再单开一份按 token 计的账单。

DSH 的底层是 pi-ai。翻了一遍源码之后我发现:pi-ai 早就实现了 Anthropic、OpenAI Codex、GitHub Copilot、OpenRouter 这几家的 OAuth 登录,代码是完整现成的。 DSH 也有一套官方的「授权服务」,专门用来注册这些登录流程。

问题在于,这套服务在随附的 web profile 里根本没被组合进去。

一、诊断:为什么登录入口"谁都没见过"

DSH 的配置是「服务定义」和「服务实现」分离的。随附的 web profile 只装了一半------dsh-llm-pi-ai 那段注册登录流程的代码挂在 ctx.inject(['authorization'], ...) 下面,而 authorization 这一行压根不在配置里。

于是那段代码从来没有执行过。

这个结论不是猜的,是 dump 官方配置树逐行比对出来的。顺带也解释了另一个现象:市面上那几个能用的订阅插件,为什么全都自己重写了一整遍 OAuth。不是它们想,是那道缝本来就是断的。

修复动作只有一行------在 bundle patch 里补上:

yaml

复制

sql 复制代码
- id: authorization
  module: '@deepseek-ai/dsh-authorization'

注意这个包没有 apply ,它的默认导出就是那个可挂载的 Service 本身(static inject = ["credentials"])。直接当插件行写进去就行。

补上之后,登录流程立刻出现。而且因为是运行时读列表而不是写死 provider,实际拿到的是六家:

Provider 流程类型
Anthropic OAuth
OpenAI Codex OAuth
GitHub Copilot device flow
OpenRouter PKCE
Kimi Code OAuth
xAI OAuth

后两家(Kimi Code、xAI)是我在真机上跑起来之后才发现的------所有把 provider 硬编码进代码的插件都会漏掉它们。这算是"读列表"这个设计决策意外赚到的。

二、架构选择:为什么不用 Typert Remote

DSH 有一套 Remote 调用平面,看起来是最正统的插件通信方式。但它要求严格的调用描述符,也就是带 zod schema 的生成产物,而那个生成器没有随包发布------只有 DSH 仓库本身能产出。

手写一份也能跑通。但它是没有稳定性承诺的生成产物:上游改了约定,你手写的那份不会报错,只会静默地对不上。

所以我换成官方给路由型插件准备的路子:

js

复制

c 复制代码
ctx.webServer.register(ROUTE_PREFIX, handler)

然后在 handler 入口先过信任栅栏:

js

复制

scss 复制代码
const rejection = ctx.connection.requestRejection(req)
if (rejection) return respond(rejection)   // 401 | 403 | undefined

这个模式不是我发明的,dsh-host-open-in-app 的 host 半就是标准范例:Host/Origin 校验再叠浏览器登录态。

验证结果:裸 GET 拿 401 ,带 session cookie 拿 200。

关于凭据:这个插件读不到你的 key

这点值得单独说。登录流程本身跑在 dsh-llm-pi-ai 里,凭据由它写进 DSH 的凭据存储(scope 是 llm-pi-ai)。

我这个插件只调 ctx.credentials.describeRecord()------而那个返回类型里根本没有能装密钥的字段。不是我不读,是读不到。host 半和 client 半都拿不到明文。

三、三个单测全绿、但真机翻车的坑

这部分是我觉得最值得写下来的。

坑 1:前缀路由带尾斜杠,永远匹配不上

dsh-host-webserver 的匹配逻辑是:

js

复制

ini 复制代码
pathname === prefix || pathname.startsWith(prefix + '/')

我一开始把路由注册成 /plugins/dsh-subscription-login/(带尾斜杠)。于是它拿着这个前缀去找 ...//flows------两个斜杠,永远不等。

现象极其迷惑: 裸路径返回 401,看起来"路由是活的、栅栏在工作",但所有真实接口全部 404。

我对着 401 排查了很久,一度以为是自己栅栏写错了。401 是个假阳性------它只证明前缀匹配到了一层,不证明子路径通。

修完之后我把这条钉进测试里了:断言 ROUTE_PREFIX 结尾不带 /。

坑 2:给根节点写颜色兜底,浅色主题整页糊掉

我写了这么一行:

css

复制

css 复制代码
color: var(--dsh-color-text, #e6e6e6);

深色主题下一切正常------因为变量存在,兜底值不生效。浅色主题下变量存在但语义相反,而我没想过兜底值会在深色主题作者的机器上永远不被触发。

用户截图给我看的时候,所有文字是近白的,整页糊成一片。

修法是 color: inherit,不猜主题。然后我加了一条发布前的机器检查:.dsl_root 里出现硬编码颜色就拒绝发布。

这件事的教训不在颜色,在于:我的测试全是桩渲染,没有主题这个概念,所以这个 bug 在结构上就不可能被我的测试发现。

坑 3:面板插在列表下面,用户点了看不到

登录面板原本挂在 provider 列表和折叠开关后面。逻辑上没问题------按钮点了,面板确实渲染了。

但列表有 6 行加一个折叠开关,面板落在了首屏之外。用户点了按钮,视觉上什么都没发生。

改法是两处:把面板 splice 到列表上方 ,渲染后 scrollIntoView(),再加个强调边框。然后补一条测试断言面板位置在第一个 provider 行之前。

这条没什么技术含量,但它提醒我一件事: "组件渲染了"和"用户看见了"是两个断言,我只测了前者。

四、测试策略

58 个测试,全部离线跑,不需要装 DSH:

bash

复制

bash 复制代码
node --test "test/*.test.mjs"

分布上:

  • registry.test.mjs --- 尝试状态机(begin/wait/answer/cancel/logout),含超时和保留期
  • router.test.mjs --- 纯路由表,含尾斜杠那条钉子
  • host.test.mjs --- host 插件装配 + 栅栏行为
  • client.test.mjs --- 这个有点意思:写了个桩 React 渲染真实的 client bundle ,因为 client 半是手写的 window.__ModuleLoader__.load() 格式,没有构建步骤,所以可以直接喂进去渲染

client 半之所以没有构建步骤,是因为 DSH 里它只允许 require("react") 解析到东西。没有打包器、没有编译期。这既是约束也是好处------测试能直接跑发布出去的那份字节,不存在"测的和发的不一样"。

发布前还有个 guard 脚本(scripts/prepublish-check.mjs)会拦这些:

  • host 半出现 @deepseek-ai/* 导入 → 拒绝(仓库外插件不能依赖内部包)
  • client 半 require 了 react 以外的东西 → 拒绝
  • 中英文字典 key 数量不相等 → 拒绝
  • patch 里没带 authorization 那一行 → 拒绝
  • .dsl_root 硬编码颜色 → 拒绝
  • 测试不过 → 拒绝

五、现状

  • npm:dsh-subscription-login@0.1.1
  • 已收录 DSH 插件市场(分类:身份与通信)
  • MIT

bash

复制

csharp 复制代码
dsh plugin --profile web add dsh-subscription-login

仓库:github.com/woodfood111...

六、还没解决的

坦白说几个:

  1. 撤销是靠不住的。 authorization 那套是进程内状态,只在当前进程有效,而且没有服务端撤销。登出只能清本地凭据。
  2. 缺 Gemini 和国内几家。 上游 pi-ai 没实现的话我也接不上,只能等。
  3. 没有做多账号。 一个 provider 一个凭据,想同时挂两个 OpenRouter 号目前不行。
  4. GitHub Copilot 的 device flow 在无权限账号上返回 403 (我实测的返回体里有 no_copilot_access、can_signup_for_limited: true)。这个是上游行为,我原样透出了。

小结

如果只留三条给要写 DSH 插件的人:

  • 前缀路由不带尾斜杠,401 不能证明子路径通
  • 别给根节点写颜色兜底,你的桩测试没有主题
  • 能读列表就别写死列表,那六家里有两家是白捡的

最后一条其实是个更普遍的判断:上游没接上的缝,往往就是插件该待的位置。 这个插件的全部价值差不多就是补那一行配置。

相关推荐
全栈弄潮儿1 小时前
AI 辅助写单测:如何覆盖边界,而不是凑覆盖率
aigc·openai·ai编程
小虎AI生活1 小时前
助残场景下的AI培训落地,从截图、课件到台账,我总结的四条可复用经验
aigc·ai编程
ServBay1 小时前
2分钟上手,如何极速接入 Claude Opus 5.5
aigc·ai编程·claude
Behavior1 小时前
Jev 是什么:一个不生成文本的模型,「零幻觉」到底指什么
llm·aigc·ai编程
Csvn1 小时前
第 24 章 参数体系与调优
人工智能·aigc·agent
后端小肥肠1 小时前
我做了个 Skill,一句话生成小程序原型,需求文档都帮你写好了
人工智能·aigc·agent
全栈弄潮儿1 小时前
不要把报错直接丢给 AI:正确的排障上下文长什么样
aigc·openai·ai编程
flash俊杰1 小时前
让大模型稳定吐出 JSON:结构化输出的五种姿势与工程兜底
aigc·openai·ai编程
ServBay1 小时前
哑巴模型Jev到底要怎么用?一篇文章告诉你
aigc·openai·ai编程