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

小结
如果只留三条给要写 DSH 插件的人:
- 前缀路由不带尾斜杠,401 不能证明子路径通
- 别给根节点写颜色兜底,你的桩测试没有主题
- 能读列表就别写死列表,那六家里有两家是白捡的
最后一条其实是个更普遍的判断:上游没接上的缝,往往就是插件该待的位置。 这个插件的全部价值差不多就是补那一行配置。